{"slug": "build-a-homedir-deny-fixture-before-an-agent-reads-netrc", "title": "Build a Homedir Deny Fixture Before an Agent Reads `.netrc`", "summary": "A developer built a pair of local test fixtures and a fail-closed gate script, assert_agent_root.py, that refuses to let a coding agent's file tools open homedir credential files such as ~/.netrc, ~/.pgpass, and ~/.docker/config.json. The walkthrough reproduces an incident in which an agent opened at ~/work followed a curl --netrc flag into ~/.netrc and quoted an internal API credential line back into the chat, which the developer attributes to a trust-boundary miss rather than prompt injection. The gate denies agent roots that resolve inside $HOME or contain deny-listed basenames, and uses fake canary tokens as a test oracle for downstream leaks.", "body_md": "Last Tuesday a coding agent “helped” with a flaky checkout script. The repo was tiny. The workspace was not.\n\nSomeone 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.\n\nIf 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?\n\nThis 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.\n\nHere is the event order I keep reproducing in a throwaway directory. It is boring. That is the point.\n\n`~/work` because “that is where the repos live.”`~/work/shop/checkout.sh` contains `curl --netrc -f \"$API_URL/health\"`.` checkout.sh`, then follows the flag to `~/.netrc`.` machine`, `login`, and `password` lines. Those lines also land in agent traces, session exports, and whatever log shipper you forgot.\nThe 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.\n\nI 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.\n\n| Asset | Trust boundary | What the agent should never receive | Failure mode | \n|---|---|---|---|\n| `~/.netrc` | Homedir credential store, not the git tree | `machine` /`login` /`password` stanzas | Chat, traces, and fine-tunes learn internal API basic auth | \n| `~/.pgpass` | libpq client secret file | `host:port:db:user:password` | Integration-test “help” dumps a live DB password | \n| `~/.docker/config.json` | Registry auth cache | `auths.*.auth` (base64`user:pass` ) | Agent “debugs a pull” and reprints a registry token | \n\nSecondary 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.\n\nWhat 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.\n\nPython 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.\n\nLayout:\n\n```\nagent-root-lab/\n  tools/assert_agent_root.py\n  fixtures/positive/src/checkout.sh\n  fixtures/negative/.netrc\n  fixtures/negative/.pgpass\n  fixtures/negative/.docker/config.json\n  fixtures/negative/src/checkout.sh\n```\n\nPositive `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.\n\nNegative canaries. Fake on purpose:\n\n```\nmachine api.internal.example\nlogin ci-bot\npassword canary-netrc-token-NOTREAL\n127.0.0.1:5432:shop:shop:canary-pgpass-NOTREAL\n{\n  \"auths\": {\n    \"ghcr.example.invalid\": {\n      \"auth\": \"Y2FuYXJ5OmNhbmFyeS1kb2NrZXItYXV0aC1OT1RSRUFM\"\n    }\n  }\n}\n```\n\nIf 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.\n\n`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.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Fail closed if an agent workspace can see homedir credential files.\n\nLab fixture for Python 3.12. Not a sandbox. Not a vulnerability report.\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport sys\nfrom pathlib import Path\n\nDENY_NAMES = {\n    \".netrc\",\n    \".pgpass\",\n    \".npmrc\",\n    \".git-credentials\",\n    \"id_rsa\",\n    \"id_ed25519\",\n    \"credentials\",  # .aws/credentials\n    \"kubeconfig\",\n}\n\nDENY_TAIL = {\n    (\".docker\", \"config.json\"),\n    (\".aws\", \"credentials\"),\n    (\".kube\", \"config\"),\n}\n\ndef fail(msg: str) -> int:\n    print(f\"FAIL: {msg}\", file=sys.stderr)\n    return 1\n\ndef iter_files(root: Path):\n    for p in root.rglob(\"*\"):\n        if p.is_file() and not p.is_symlink():\n            yield p\n\ndef denied(path: Path) -> str | None:\n    if path.name in DENY_NAMES:\n        return f\"basename {path.name}\"\n    parts = path.parts\n    for tail in DENY_TAIL:\n        if parts[-len(tail) :] == tail:\n            return \"/\".join(tail)\n    return None\n\ndef main() -> int:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"root\", type=Path)\n    parser.add_argument(\"--i-mean-it\", action=\"store_true\")\n    args = parser.parse_args()\n\n    root = args.root.expanduser().resolve()\n    home = Path.home().resolve()\n\n    if not root.is_dir():\n        return fail(f\"{root} is not a directory\")\n    if root in {Path(\"/\"), home}:\n        return fail(f\"agent root must not be {root}\")\n    if home == root or home in root.parents:\n        return fail(f\"{root} sits inside $HOME\")\n    if root == home or home in root.parents:\n        pass\n    if str(root).startswith(str(home) + os.sep) and not args.i_mean_it:\n        return fail(f\"{root} is under $HOME; isolate the workspace\")\n\n    hits = []\n    for path in iter_files(root):\n        why = denied(path)\n        if why:\n            hits.append(f\"{path} ({why})\")\n\n    if hits:\n        print(\"FAIL: credential paths inside agent root:\", file=sys.stderr)\n        for h in hits:\n            print(f\"  - {h}\", file=sys.stderr)\n        return 1\n\n    print(f\"PASS: {root} has no deny-listed credential files\")\n    return 0\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\nExpected evidence, labeled because I am not running this against your tree:\n\n```\n# negative: must exit 1 and print .netrc / .pgpass / .docker/config.json\npython3 tools/assert_agent_root.py fixtures/negative; echo exit:$?\n\n# positive: must exit 0\npython3 tools/assert_agent_root.py fixtures/positive; echo exit:$?\n\n# homedir: must exit 1 even if the tree is “clean”\npython3 tools/assert_agent_root.py \"$HOME\"; echo exit:$?\n```\n\nIf the negative case prints `PASS`, the gate is wrong. Fix the gate. Do not “prompt the model to be careful.”\n\n| Layer | Action | Who owns it | \n|---|---|---|\n| 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 | \n| 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 | \n| Detect | Grep session exports and tool traces for canaries ( `canary-netrc-token-NOTREAL` ) | Security / SRE | \n| Detect | Deny-list is necessary but weak; also block `read` /`exec` outside the root at the tool broker | Agent runtime | \n| Recover | Rotate the canary’s real counterpart if a production file ever matched. Treat chat history as compromised. Wipe the session store. | Incident | \n\n.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.”\n\nSay it in one list so nobody has to infer it from vibes.\n\n`.netrc`, `.pgpass`, `.npmrc`, `.git-credentials`\n`~/.docker/config.json` `auths` values, even when they look like base64 sludge`kubeconfig` user tokens`repr()`, or Spring `application-local.yml`\nComments 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.\n\nThe 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.”\n\nI 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.\n\nIf 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.\n\nA 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.\n\nFilename 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.\n\nSymlinks: 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.\n\nDo not use this approach if:\n\n`read` is worthless next to `cat ~/.netrc`.\nAlso 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.\n\nWhich invariant belongs in CI, and which layer should enforce it?\n\nCI 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.\n\nIf you only do the prompt, you are calling it engineering. You are not doing it.", "url": "https://wpnews.pro/news/build-a-homedir-deny-fixture-before-an-agent-reads-netrc", "canonical_source": "https://dev.to/jaryn_123/build-a-homedir-deny-fixture-before-an-agent-reads-netrc-36g8", "published_at": "2026-09-19 08:56:19+00:00", "updated_at": "2026-09-19 09:24:39.054189+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools", "ai-tools"], "entities": ["curl", "Docker", "GitHub Container Registry", "Python", "libpq", "AWS"], "alternates": {"html": "https://wpnews.pro/news/build-a-homedir-deny-fixture-before-an-agent-reads-netrc", "markdown": "https://wpnews.pro/news/build-a-homedir-deny-fixture-before-an-agent-reads-netrc.md", "text": "https://wpnews.pro/news/build-a-homedir-deny-fixture-before-an-agent-reads-netrc.txt", "jsonld": "https://wpnews.pro/news/build-a-homedir-deny-fixture-before-an-agent-reads-netrc.jsonld"}}