{"slug": "pin-the-roots-or-don-t-merge-a-fail-closed-agent-egress-checklist", "title": "Pin the Roots or Don't Merge: A Fail-Closed Agent Egress Checklist", "summary": "A developer has published a fail-closed egress checklist for teams shipping coding agents into shared CI or shared machines, requiring every filesystem root, network host, and executable an agent can reach to be named in a committed allowlist before a merge is permitted. The proposal pairs an agent-egress.yml manifest with a CI validator and trace comparator that fail the pipeline when the manifest is missing, the trace is absent or out of bounds, or secret-like values appear in tool arguments. The author frames the checklist as a proposed local gate to be tested on a sample manifest before adoption in a protected branch.", "body_md": "You should not merge an agent change until every filesystem root, network host, and executable it can reach is named in a committed allowlist. If that file is missing, the pipeline fails. That is the whole policy.\n\nAn LLM in a pull request is not the risk by itself. The risk is the tools you wired to it. Shell. HTTP. MCP. Those tools turn a suggestion into a process with credentials, a working directory, and a network stack.\n\nThis checklist is for teams shipping coding agents into shared CI or a shared box. Copy it. Fail closed. Do not treat a green unit test as proof that the agent stayed inside the repo.\n\nYou are not shipping a prompt. You are shipping a runtime.\n\nIf the agent can run a command, it can read files the reviewer never opened. If it can fetch a URL, it can leave your VPC with a token sitting in an environment variable. If it can call an MCP server, it can grow new verbs the next time that server updates.\n\nName the boundary. Then prove the run stayed inside it.\n\nTreat agent egress like a production firewall change:\n\nDo not negotiate item 4 in Slack. If the packet is incomplete, the merge is incomplete.\n\nUse this as a PR template. Every box needs an artifact, not a vibe.\n\n`agent_id` is stable across PRs (not `tmp` or `test`).\nIf the id changes, treat it as a new service. New service, new review.\n\n`workspace_roots` lists every directory the agent may read or write.`$HOME`. No `/tmp` unless a job-scoped directory is created in CI and destroyed after.\nA root of `.` is acceptable for a docs bot. It is not acceptable for an agent that also mounts secrets.\n\n`binaries` is an allowlist of executable basenames, not `$PATH`.` bash -lc`, `sh -c`, `python -c`) are either forbidden or require a second reviewer.` npm`, `pip`, `curl | sh`) are denied unless the PR is specifically about dependency changes.\nYou do not need a perfect sandbox to start. You need a list you can grep.\n\n`network.mode` is `deny` or `allowlist`. Never `open`.`*.cloud`.\nHost allowlists are not a full network policy. They are the minimum you can enforce in application CI.\n\n`env_allow` names every variable the process may read.`SECRET`, `TOKEN`, `PASSWORD`, `PRIVATE` are denied unless explicitly listed.\nIf a secret appears in a tool argument, the run is a failed run. Rotate. Then fix the allowlist.\n\n`max_steps` is set.`max_wall_clock_sec` is set.`max_tool_calls_per_step` is set.\nUnbounded loops are not “research mode” in a merge pipeline. They are an open invoice and an open shell.\n\nIf the server can add a tool without a digest change, it does not belong in this pipeline.\n\nA checklist without files is theater. Require these paths, or fail:\n\n| Gate | Required file | Fail closed when | \n|---|---|---|\n| Manifest present | `agent-egress.yml` | file missing or empty | \n| Schema valid | CI validator log | unknown keys, empty lists while a capability is enabled | \n| Trace exists | `artifacts/agent-trace.jsonl` | no file, or zero tool events while tools were enabled | \n| Trace in bounds | CI comparator log | path, host, binary, or env outside the manifest | \n| Bounds held | trace summary | `max_steps` or wall clock exceeded | \n| Secret scan | CI log | secret-like names in argv or stdout | \n\nStore the trace next to the manifest. Reviewers should be able to open one folder and see both the policy and the run.\n\nLabel: this is a proposed local gate. Run it on a sample manifest before you trust it in a protected branch.\n\n`agent-egress.yml`:\n\n```\nversion: 1\nagent_id: docs-triage\nowner: platform-ci\nworkspace_roots:\n  - .\nwrite_roots:\n  - ./artifacts\nbinaries:\n  - git\n  - python3\nnetwork:\n  mode: allowlist\n  hosts:\n    - api.github.com\nenv_allow:\n  - GITHUB_TOKEN\n  - CI\nmax_steps: 20\nmax_wall_clock_sec: 180\nmax_tool_calls_per_step: 3\nmcp_servers: []\n```\n\n`ci/check_agent_egress.py`:\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Fail closed if the agent egress manifest is missing or incomplete.\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport sys\nfrom pathlib import Path\n\ntry:\n    import yaml\nexcept ImportError:\n    print(\"MISSING_DEP: PyYAML is required\", file=sys.stderr)\n    sys.exit(2)\n\nREQUIRED = (\n    \"version\",\n    \"agent_id\",\n    \"owner\",\n    \"workspace_roots\",\n    \"write_roots\",\n    \"binaries\",\n    \"network\",\n    \"env_allow\",\n    \"max_steps\",\n    \"max_wall_clock_sec\",\n    \"max_tool_calls_per_step\",\n    \"mcp_servers\",\n)\nFORBIDDEN_ROOT_MARKERS = (\"$HOME\", \"~\", \"/tmp\", \"/var\", \"C:\\\\\")\n\ndef fail(msg: str) -> None:\n    print(f\"FAIL: {msg}\", file=sys.stderr)\n    sys.exit(1)\n\ndef main() -> None:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"--manifest\", default=\"agent-egress.yml\")\n    parser.add_argument(\"--fail-closed\", action=\"store_true\", default=True)\n    args = parser.parse_args()\n\n    path = Path(args.manifest)\n    if not path.is_file():\n        fail(f\"manifest not found: {path}\")\n\n    raw = path.read_text(encoding=\"utf-8\").strip()\n    if not raw:\n        fail(\"manifest is empty\")\n\n    data = yaml.safe_load(raw)\n    if not isinstance(data, dict):\n        fail(\"manifest must be a mapping\")\n\n    missing = [k for k in REQUIRED if k not in data]\n    if missing:\n        fail(f\"missing keys: {missing}\")\n\n    if not data[\"agent_id\"] or data[\"agent_id\"] in {\"tmp\", \"test\", \"default\"}:\n        fail(\"agent_id must be stable and non-placeholder\")\n\n    roots = data[\"workspace_roots\"]\n    if not roots:\n        fail(\"workspace_roots must not be empty\")\n    for root in roots:\n        if not isinstance(root, str) or any(m in root for m in FORBIDDEN_ROOT_MARKERS):\n            fail(f\"refusing root: {root!r}\")\n\n    writes = data[\"write_roots\"]\n    if not writes:\n        fail(\"write_roots must not be empty\")\n\n    binaries = data[\"binaries\"]\n    if not binaries:\n        fail(\"binaries allowlist must not be empty\")\n    if any(b in {\"bash\", \"sh\", \"zsh\", \"cmd\", \"powershell\"} for b in binaries):\n        fail(\"shell binaries require a separate exception PR\")\n\n    network = data[\"network\"]\n    if network.get(\"mode\") not in {\"deny\", \"allowlist\"}:\n        fail(\"network.mode must be deny or allowlist\")\n    if network[\"mode\"] == \"allowlist\" and not network.get(\"hosts\"):\n        fail(\"allowlist mode requires hosts\")\n\n    if int(data[\"max_steps\"]) < 1 or int(data[\"max_wall_clock_sec\"]) < 1:\n        fail(\"loop bounds must be positive\")\n\n    print(f\"PASS: {path} agent_id={data['agent_id']}\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\nRun it locally the same way CI will:\n\n```\npython3 -m pip install pyyaml\npython3 ci/check_agent_egress.py --manifest agent-egress.yml --fail-closed\necho $?\n# expected: 0 on a complete file, 1 on any gap\n```\n\nCI job sketch:\n\n```\nname: agent-egress-gate\non:\n  pull_request:\n    paths:\n      - \"agent-egress.yml\"\n      - \"ci/check_agent_egress.py\"\n      - \"**/*agent*\"\njobs:\n  gate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - run: python3 -m pip install pyyaml\n      - run: python3 ci/check_agent_egress.py --manifest agent-egress.yml --fail-closed\n```\n\nKeep the path filter honest. If your agent code lives outside `*agent*`, drop the filter. A skipped gate is an open gate.\n\nThe manifest is policy. The trace is evidence. You still need a comparator.\n\nProposed event shape (one JSON object per line):\n\n```\n{\"ts\": \"2026-09-11T12:00:00Z\", \"type\": \"tool\", \"name\": \"run_terminal_cmd\", \"binary\": \"git\", \"argv\": [\"status\"], \"cwd\": \".\", \"host\": null}\n```\n\nComparator rules you can implement in a short script:\n\n`cwd` must resolve under `binary` must be in `host` must be in `network.hosts` when not null.`<= max_steps`.` type` fails. Do not ignore fields you do not understand.\nIf you cannot produce a trace, you cannot merge. “The vendor UI does not export logs” is a vendor problem, not a reason to skip the gate.\n\nDrafting the first allowlist from a recorded trace is tedious. A coding assistant is useful there: it can turn a JSONL file into a candidate YAML. It is not useful as the gate.\n\nDisclosure: This article was prepared as part of MonkeyCode's product outreach. If you need a throwaway workspace to generate those traces without pointing the agent at production, MonkeyCode’s free model access and free server option are enough to iterate on the manifest. Keep the validator in *your* CI. The model does not get a vote.\n\nThis checklist does not replace OS sandboxes, seccomp, or a real egress proxy. Path allowlists lose if the process can follow a symlink you did not resolve. Host allowlists lose if a listed host issues a redirect you do not follow in the comparator. MCP pins lose if the server mutates tools behind the same digest.\n\nLoop bounds do not stop a single dangerous command. They stop a runaway. You still need binary and argv policy for the dangerous command.\n\nThe validator above does not parse traces. Ship the comparator before you claim production readiness. Until then, label the gate `manifest-only` in the PR so reviewers know what they are not seeing.\n\n`network.mode: open`.\nAsk one question: if this agent process is still running in ten minutes, which roots, hosts, and binaries can it still touch?\n\nIf you cannot answer from a file in the repo, do not merge. Pin the roots. Attach the trace. Fail closed.", "url": "https://wpnews.pro/news/pin-the-roots-or-don-t-merge-a-fail-closed-agent-egress-checklist", "canonical_source": "https://dev.to/codecpp_5026/pin-the-roots-or-dont-merge-a-fail-closed-agent-egress-checklist-4nb9", "published_at": "2026-09-11 16:00:18+00:00", "updated_at": "2026-09-11 16:11:30.375441+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-infrastructure", "developer-tools", "mlops"], "entities": ["GitHub", "MCP"], "alternates": {"html": "https://wpnews.pro/news/pin-the-roots-or-don-t-merge-a-fail-closed-agent-egress-checklist", "markdown": "https://wpnews.pro/news/pin-the-roots-or-don-t-merge-a-fail-closed-agent-egress-checklist.md", "text": "https://wpnews.pro/news/pin-the-roots-or-don-t-merge-a-fail-closed-agent-egress-checklist.txt", "jsonld": "https://wpnews.pro/news/pin-the-roots-or-don-t-merge-a-fail-closed-agent-egress-checklist.jsonld"}}