Last Tuesday a coding agent “helped” with a flaky checkout script. The repo was tiny. The workspace was not.
Someone had opened the agent at ~/work. checkout.sh called curl --netrc. The agent did what file tools do: it followed the comment, opened ~/.netrc, and quoted a machine api.internal line back into the chat. No prompt injection. No jailbreak theater. A trust-boundary miss.
If your agent can read a credential file, the model is not the interesting part. The path is. Would you paste that file into Slack? Then why can a tool loop open it?
This is a lab walkthrough, not a vulnerability report. I did not find a CVE. I built two fixtures and a gate that fails closed when the agent root can see homedir secrets. Treat the commands as a local regression, not evidence that any hosted product leaked production credentials.
The failing sequence
Here is the event order I keep reproducing in a throwaway directory. It is boring. That is the point.
- Developer points a coding agent at
~/workbecause “that is where the repos live.” -
~/work/shop/checkout.shcontainscurl --netrc -f "$API_URL/health". - The agent lists files, then reads
checkout.sh, then follows the flag to~/.netrc. - Chat now contains
machine,login, andpasswordlines. Those lines also land in agent traces, session exports, and whatever log shipper you forgot.
The trust boundary is not “the model is aligned.” The boundary is “which realpaths the file tool may open.” Mix $HOME into that set and you are doing vibe ops. You are not doing engineering.
Threat model, kept small
I only care about three files in this fixture. Expand later if you must. Do not start with a 40-row spreadsheet and zero failing tests.
| Asset | Trust boundary | What the agent should never receive | Failure mode |
|---|---|---|---|
~/.netrc |
Homedir credential store, not the git tree |
machine / login / password stanzas |
Chat, traces, and fine-tunes learn internal API basic auth |
~/.pgpass |
libpq client secret file | host:port:db:user:password |
Integration-test “help” dumps a live DB password |
~/.docker/config.json |
Registry auth cache |
auths.*.auth (base64 user:pass) |
Agent “debugs a pull” and reprints a registry token |
Secondary assets I deny by name in the same gate: id_rsa, id_ed25519, .git-credentials, kube user.token files, and credentials under .aws. I still do not treat a filename deny-list as a sandbox. It is a CI tripwire. Shell tools can walk $HOME unless you block that too.
What about logs? Same rule. If the agent cannot open .netrc, it also cannot open a support zip that already copied .netrc. Redact at the workspace edge. Do not pray at the prompt.
Two fixtures, pinned to a layout
Python 3.12. No third-party packages. Unexecuted against your laptop until you run it; expected evidence is the gate’s exit code, not a screenshot of a vendor UI.
Layout:
agent-root-lab/
tools/assert_agent_root.py
fixtures/positive/src/checkout.sh
fixtures/negative/.netrc
fixtures/negative/.pgpass
fixtures/negative/.docker/config.json
fixtures/negative/src/checkout.sh
Positive checkout.sh talks to a public health endpoint and does not mention --netrc. Negative checkout.sh does. That is deliberate. The gate must fail on the credential files even if you later rewrite the script.
Negative canaries. Fake on purpose:
machine api.internal.example
login ci-bot
password canary-netrc-token-NOTREAL
127.0.0.1:5432:shop:shop:canary-pgpass-NOTREAL
{
"auths": {
"ghcr.example.invalid": {
"auth": "Y2FuYXJ5OmNhbmFyeS1kb2NrZXItYXV0aC1OT1RSRUFM"
}
}
}
If a later export, RAG chunk, or chat transcript contains canary-netrc-token-NOTREAL, you have a leak in the pipeline. That string is the test oracle. Keep it ugly so grep never collides with a real password.
The gate
tools/assert_agent_root.py is the artifact. It refuses $HOME, /, and any agent root that contains deny-listed basenames. It also refuses a root whose resolved path is inside $HOME unless you pass --i-mean-it — which CI must not pass.
#!/usr/bin/env python3
"""Fail closed if an agent workspace can see homedir credential files.
Lab fixture for Python 3.12. Not a sandbox. Not a vulnerability report.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
DENY_NAMES = {
".netrc",
".pgpass",
".npmrc",
".git-credentials",
"id_rsa",
"id_ed25519",
"credentials", # .aws/credentials
"kubeconfig",
}
DENY_TAIL = {
(".docker", "config.json"),
(".aws", "credentials"),
(".kube", "config"),
}
def fail(msg: str) -> int:
print(f"FAIL: {msg}", file=sys.stderr)
return 1
def iter_files(root: Path):
for p in root.rglob("*"):
if p.is_file() and not p.is_symlink():
yield p
def denied(path: Path) -> str | None:
if path.name in DENY_NAMES:
return f"basename {path.name}"
parts = path.parts
for tail in DENY_TAIL:
if parts[-len(tail) :] == tail:
return "/".join(tail)
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("root", type=Path)
parser.add_argument("--i-mean-it", action="store_true")
args = parser.parse_args()
root = args.root.expanduser().resolve()
home = Path.home().resolve()
if not root.is_dir():
return fail(f"{root} is not a directory")
if root in {Path("/"), home}:
return fail(f"agent root must not be {root}")
if home == root or home in root.parents:
return fail(f"{root} sits inside $HOME")
if root == home or home in root.parents:
pass
if str(root).startswith(str(home) + os.sep) and not args.i_mean_it:
return fail(f"{root} is under $HOME; isolate the workspace")
hits = []
for path in iter_files(root):
why = denied(path)
if why:
hits.append(f"{path} ({why})")
if hits:
print("FAIL: credential paths inside agent root:", file=sys.stderr)
for h in hits:
print(f" - {h}", file=sys.stderr)
return 1
print(f"PASS: {root} has no deny-listed credential files")
return 0
if __name__ == "__main__":
sys.exit(main())
Expected evidence, labeled because I am not running this against your tree:
# negative: must exit 1 and print .netrc / .pgpass / .docker/config.json
python3 tools/assert_agent_root.py fixtures/negative; echo exit:$?
# positive: must exit 0
python3 tools/assert_agent_root.py fixtures/positive; echo exit:$?
# homedir: must exit 1 even if the tree is “clean”
python3 tools/assert_agent_root.py "$HOME"; echo exit:$?
If the negative case prints PASS, the gate is wrong. Fix the gate. Do not “prompt the model to be careful.”
Prevent / detect / recover
| Layer | Action | Who owns it |
|---|---|---|
| Prevent | Agent root is a dedicated directory with an allowlist of src/, tests/, and lockfiles. Homedir is not mounted. Shell tool working directory is that root. |
Platform / agent harness |
| Prevent | CI runs assert_agent_root.py on the directory you pass to the agent, not on the git repo after a hopeful .gitignore
|
CI |
| Detect | Grep session exports and tool traces for canaries (canary-netrc-token-NOTREAL) |
Security / SRE |
| Detect | Deny-list is necessary but weak; also block read/exec outside the root at the tool broker |
Agent runtime |
| Recover | Rotate the canary’s real counterpart if a production file ever matched. Treat chat history as compromised. Wipe the session store. | Incident |
.gitignore is not a trust boundary. Agents do not only see committed files. They see the working tree, and they see whatever you bind-mounted “for convenience.”
What never goes to a model
Say it in one list so nobody has to infer it from vibes.
- Contents of
.netrc,.pgpass,.npmrc,.git-credentials -
~/.docker/config.jsonauthsvalues, even when they look like base64 sludge - SSH private keys and
kubeconfiguser tokens - Live connection strings from pytest failures, SQLAlchemy
repr(), or Springapplication-local.yml - Prior chat transcripts that already quoted any of the above
Comments in Makefiles that say “uses netrc” are fine. The file those comments point at is not. If the agent needs to fix checkout.sh, give it a fake NETRC=/workspace/fixtures/netrc.example with canaries, not your laptop file.
Where a free, isolated server actually helps
The temptation is to enable a coding agent on the same box you use for git push and docker login. Free compute makes that temptation worse. You skip isolation because the experiment “should only take ten minutes.”
I use an isolated workspace for this lab: source plus fixtures, no homedir overlay, no production kube context. MonkeyCode’s free model access and free server option are relevant here as a disposable box for that workspace — not as a reason to rsync $HOME. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If the server still mounts your developer dotfiles, the price being zero does not change the threat model. Isolation is the feature. The model is just the consumer on the far side of a path allowlist.
A public clone of the gate plus the two fixtures is enough to review the idea. You do not need my account, and you do not need a vendor UI, to fail the negative case.
Limitations, and who should not use this
Filename deny-lists miss secrets with polite names. local.settings.json, helm/values-prod.yaml, and terraform.tfvars will sail through unless you add content scanning. This script does not open files. That is intentional. It is a path invariant, not a secret scanner.
Symlinks: I skipped them on purpose so a malicious tree cannot make the walker follow ../../.netrc into an infinite mess. Your harness still must refuse readlink outside the root. Otherwise the gate is theater.
Do not use this approach if:
- Your agent has an unrestricted shell. A deny-list on
readis worthless next tocat ~/.netrc. - You need to share a real homedir with the agent “so it knows my tooling.” Teach it via a checked-in dotfiles subset with canaries.
- You think a system prompt that says “never read secrets” is an invariant. It is not. Invariants live in CI and in the tool broker.
Also do not paste the negative fixture into a shared eval set that ships to a third-party trainer unless those canaries are the only secrets in the tree. Eval corpora become training corpora. You already knew that. Act like it.
The boundary question
Which invariant belongs in CI, and which layer should enforce it?
CI should fail if the directory you will pass to an agent contains deny-listed credential paths, or if that directory resolves under $HOME. The tool broker should enforce the same realpath allowlist at runtime, including shell cwd. The model should see neither .netrc nor a log that already copied it.
If you only do the prompt, you are calling it engineering. You are not doing it.
Top comments (0)