DEV Community

jaryn
jaryn

Posted on

Plant Decoy Credentials Before Your Coding Agent Leaves Your Laptop

The first thing most teams do with a free coding agent is point it at a real repository. Why not? The tokens are free, the server is free, and the repo is right there. That instinct is exactly the one worth interrogating before you press the sync button.

I am not going to claim anyone's agent leaked anything. I have no such evidence, and vague fear is useless. What I can give you is a boundary you can measure yourself: decoy credentials, a classification pass, and an outbound capture point that tells you whether a string you planted crossed a line you can observe.

The four boundaries nobody labels

A remote coding agent moves your code across at least four trust boundaries. Most threat models collapse them into one, which is why the conversation goes nowhere.

 B1            B2                 B3                  B4
laptop  -->  workspace sync  -->  model endpoint  -->  provider logs
 |                |                    |                   |
 files          remote FS           prompt/body        retention
 .env?          tmp dirs?           tool output?       policy?
Enter fullscreen mode Exit fullscreen mode

B1 is what you choose to send. B2 is what the workspace keeps after the run. B3 is what actually enters a request body. B4 is what survives the session. You control B1 fully, B3 partially (if you can proxy), and B2/B4 mostly by contract and by deletion discipline.

So the useful question is not "is this tool safe?" It is: which boundary do I have evidence about, and which one am I guessing at?

Step 1 — Classify before you sync

Start with a file-level gate. This is the cheapest control and the one people skip. The script below is a template I wrote for this article and have not run against your repo; run it, read the output, and adjust the patterns to your stack.

#!/usr/bin/env python3
# preflight.py - classify repo files before a remote agent sees them.
import hashlib, json, re, sys
from pathlib import Path

DENY = [
    (r"(^|/)\.env(\..*)?$",                    "env-file"),
    (r"(^|/)id_(rsa|ed25519|ecdsa|dsa)$",       "private-key"),
    (r"\.(pem|p12|pfx|jks|keystore)$",          "key-material"),
    (r"(^|/)\.(aws|ssh|gnupg|kube)/",           "credential-dir"),
    (r"(^|/)(secrets?|credentials?)\.(ya?ml|json|toml|ini)$", "secret-config"),
    (r"(^|/)terraform\.tfstate(\.backup)?$",    "state-with-secrets"),
]
REVIEW = [
    (r"(^|/)fixtures?/",             "fixture-may-hold-real-data"),
    (r"\.(sql|dump|bak|zip|tar|gz)$", "archive-or-dump"),
    (r"(^|/)docker-compose\.ya?ml$",  "may-inline-env"),
]

def classify(rel: Path):
    p = rel.as_posix()
    for pat, label in DENY:
        if re.search(pat, p): return "deny", label
    for pat, label in REVIEW:
        if re.search(pat, p): return "review", label
    return "allow", ""

def sha256(path: Path) -> str:
    h = hashlib.sha256(); h.update(path.read_bytes()); return h.hexdigest()

root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
files, counts = [], {"allow": 0, "review": 0, "deny": 0}
for p in sorted(root.rglob("*")):
    if not p.is_file() or ".git" in p.parts:
        continue
    rel = p.relative_to(root)
    verdict, label = classify(rel)
    counts[verdict] += 1
    files.append({"path": rel.as_posix(), "verdict": verdict,
                  "label": label, "sha256": sha256(p)})
json.dump({"root": str(root), "counts": counts, "files": files},
          open("manifest.json", "w"), indent=2)
print(counts)
Enter fullscreen mode Exit fullscreen mode

Run it and look at the deny list explicitly:

python3 preflight.py .
python3 - <<'PY'
import json
m = json.load(open("manifest.json"))
print("\n".join(f["path"] for f in m["files"] if f["verdict"] == "deny"))
PY
Enter fullscreen mode Exit fullscreen mode

The manifest is the second half of the value: hashes plus an upload decision, so a later "what exactly did we send?" question has an answer that is not a memory.

Step 2 — Plant a decoy that must never leave

Classification tells you what you intended to exclude. A decoy tells you whether the intent held. Generate one unique marker per run, keep it outside the repo, and plant it only inside a fixture that a careless agent would happily read.

#!/usr/bin/env bash
# tools/decoys/make.sh - one decoy per run; marker never enters git.
set -euo pipefail
OUT="${DECOY_STORE:-$HOME/.local/share/agent-decoys}"
mkdir -p "$OUT"
RUN="$(date -u +%Y%m%dT%H%M%SZ)"
CANARY="sk-decoy-$(openssl rand -hex 16)"
printf '%s\n' "$CANARY" > "$OUT/$RUN.canary"
mkdir -p fixtures/agent-decoys
cat > fixtures/agent-decoys/staging.env <<EOF
# decoy value, not a real credential, nothing to rotate
STAGING_API_KEY=$CANARY
EOF
echo "canary: $OUT/$RUN.canary"
Enter fullscreen mode Exit fullscreen mode

Two rules make this honest. First, the decoy is shaped like a secret but is worthless, so a hit is embarrassing, not an incident. Second, fixtures/ shows up as review in the manifest — which is exactly right, and proves your classifier is not asleep.

Step 3 — Watch the outbound body

Now the interesting part. If you can route the agent's traffic through a proxy you control, you can check B3 directly. Here is a mitmproxy addon that reads a canary file and reports any request body containing the marker.

# tripwire.py - mitmdump -s tripwire.py ; CANARY_FILE=/path/to/run.canary
import os, sys
from mitmproxy import http

CANARIES = [l.strip() for l in open(os.environ["CANARY_FILE"]) if l.strip()]

class Tripwire:
    hits = 0

    def request(self, flow: http.HTTPFlow) -> None:
        body = flow.request.get_text(strict=False) or ""
        for c in CANARIES:
            if c in body:
                Tripwire.hits += 1
                print(f"HIT {flow.request.host}{flow.request.path}", file=sys.stderr)

    def done(self) -> None:
        print(f"tripwire hits: {Tripwire.hits}", file=sys.stderr)

addons = [Tripwire()]
Enter fullscreen mode Exit fullscreen mode

Wire it up. Replace <agent-cli> with whatever entrypoint your tool ships — I am deliberately not naming one, because the point is the capture point, not the client.

mitmdump -q -s tripwire.py --set confdir=/tmp/mitm &
CANARY_FILE="$HOME/.local/share/agent-decoys/20261026T090000Z.canary" \
HTTPS_PROXY=http://127.0.0.1:8080 \
  <agent-cli> run "add a health endpoint to fixtures/agent-decoys"
Enter fullscreen mode Exit fullscreen mode

A hit means the marker reached a request body you could see. Zero hits means the marker did not cross that boundary during that run. It is not a privacy certificate, and reading it as one is how teams talk themselves into bad defaults.

Where a free tier actually fits

This is where free model access and a free server option become genuinely useful — not as a production platform, but as a disposable surface you can test boundaries on without spending budget. That is the honest framing.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open-source AI development platform, and per the current outreach brief it offers free model access (10 million tokens) and a free server option. Treat those as operator-supplied claims with moving parts: models, quotas, hardware, and expiry can change without notice, so read the current terms on the signup page before you build a workflow on them. What matters for this article is that a free workspace is a good place to run the decoy test, precisely because you should assume you will throw it away.

Data class Free remote workspace? Control
Public OSS clone, no history rewrite Yes Pin commit SHA in the manifest
Internal source, no secrets Yes, with review Decoy run + tripwire before real work
.env, key material, tfstate No Deny in preflight, keep local
Customer data, dumps, fixtures from prod No Synthesize fixtures instead
Incident timelines, auth logs No Summarize by hand, not by paste
Throwaway reproduction of a public CVE Yes Ideal case for a free tier

That last row is the one worth defending. Reproducing a public failure in a scratch workspace is the highest-value, lowest-risk use of somebody else's compute.

Prevent, detect, recover

Layer Mechanism Evidence it worked
Prevent preflight.py deny list + repo allowlist manifest.json with hashes
Prevent Decoy stored outside the tree Canary file exists only locally
Detect tripwire.py on outbound bodies Hit line naming host and path
Detect Post-run grep of the workspace Marker absent after deletion
Recover Rotate anything the decoy resembled, not the decoy Rotation ticket referencing the run ID

Note the recover row carefully. If a hit ever involves a real credential, the decoy is irrelevant — you rotate the real thing on the assumption it left, and you do it in dependency order rather than clicking the first "rotate" button you find.

Limitations I will not paper over

TLS pinning or a client that ignores HTTPS_PROXY will break the capture step, and then you are back to guessing about B3. Body matching only sees text; multipart uploads, gzip streams, and base64 blobs will slip past a naive in check. The decoy test says nothing about B4 retention, which is a policy question, not a packet question. And a single run is a sample, not a distribution — run it again after every agent or CLI upgrade, because the code path that assembles a prompt is exactly the kind of thing that changes quietly.

Who should skip this? Solo developers working on public repos with no credentials in the tree gain little. Teams under a compliance regime that forbids third-party inference entirely should not be testing the boundary at all; they should be enforcing an egress deny and stopping there.

What I would actually ship

Two files in CI, one habit. preflight.py fails the build if a deny file is staged for a synced workspace. tripwire.py runs before the first real task on any new workspace, with a fresh canary each time. The habit is asking, before every sync: which of the four boundaries am I about to trust without evidence?

So here is the boundary question I keep coming back to. The decoy check belongs in CI, where it is cheap and repeatable. The deny list belongs in the repo, where reviewers can argue about it. But the decision about what a workspace retains after the run — that one is not enforceable by you at all, is it? Which layer do you think should own it?

Top comments (0)