# Build a Homedir Deny Fixture Before an Agent Reads `.netrc`

> Source: <https://dev.to/jaryn_123/build-a-homedir-deny-fixture-before-an-agent-reads-netrc-36g8>
> Published: 2026-09-19 08:56:19+00:00

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.

Here is the event order I keep reproducing in a throwaway directory. It is boring. That is the point.

`~/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.
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.

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.

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.

`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.

``` bash
#!/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.”

| 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.”

Say it in one list so nobody has to infer it from vibes.

`.netrc`, `.pgpass`, `.npmrc`, `.git-credentials`
`~/.docker/config.json` `auths` values, even when they look like base64 sludge`kubeconfig` user tokens`repr()`, or Spring `application-local.yml`
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.

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.

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:

`read` is worthless next to `cat ~/.netrc`.
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.

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.
