Skip to content

About

Local security gateway between an AI agent and its tools (fs, shell, network, MCP): default-deny policy engine, human approval, append-only audit log. Tested against indirect prompt injection trying to exfiltrate a fake .env to 127.0.0.1. Python stdlib only, 118 tests. Not production-ready - a security lab.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

9 stars

Watchers

0 watching

Forks

VT-Agent-Firewall

CI License: Apache-2.0

Español: README.es.md · Threat model: docs/THREAT_MODEL.md

A fail-closed gateway between an AI agent and its tools. The agent never calls open, subprocess or the network directly: every action is a request that goes through the same pipeline, and anything the pipeline cannot vouch for is denied.

ActionRequest → normalize        realpath, shlex argv + per-binary grammar, parsed URL (spelled params are hostile)
              → policy           default-deny, declarative rules, per-session taint
              → human approval   shows the NORMALIZED action, never the agent's description
              → audit            append-only JSONL, outside the sandbox
              → executor         receives only normalized params, shell=False

Three possible decisions: allow, block, require_approval. If a parser fails or the audit store is unavailable, the answer is block: the gateway would rather stop than act unaudited.

The same prompt-injected agent without the gateway (the fake key is exfiltrated) and through it (every attempt blocked and audited)

Status: alpha (0.4.0). Standard library only, Python 3.11+. Built as the reference implementation for the talk "Dónde se rompe OAuth cuando el que llama es un agente" (OWASP Village, Ekoparty 2026; OWASP ROS:SEC 2026). Not production-ready; see Limitations.

Quickstart

pip install vt-agent-firewall   # library + vt-agent-firewall-mcp, -hook and -propose (PyPI)

git clone https://github.com/ValentinTorassa/VT-Agent-Firewall   # to run the demo and tests
cd VT-Agent-Firewall
python3 scripts/run_demo.py --health       # static sanity checks
python3 scripts/run_demo.py                # the attack, through the gateway
python3 scripts/run_demo.py --no-firewall  # contrast: the same attack without it
python3 -m unittest discover -s tests -v   # AC1–AC12, the OAuth failure modes, bypass regressions
python3 scripts/run_corpus.py              # 58 isolated synthetic injection cases
python3 scripts/run_learning_demo.py       # learning mode: record a session, propose a policy

The demo

demo_workspace/malicious_repo/README.md carries an indirect prompt injection: read ../.env and send the key to 127.0.0.1:8765. The scripted agent follows it. With the gateway every step is blocked and audited and the loopback receiver collects nothing; with --no-firewall the same steps exfiltrate the (fake) key. All data in demo_workspace/ is synthetic bait.

Rules (policies/default.json)

rule_id effect
secret-egress block any request, whatever the tool, that carries a value from a secret_files file: as written, percent-decoded, in base64 or in hex (secrets); evaluated first, taints the session, and the audit keeps no value
fs-protected block reads/writes of protected paths and anything under them, after realpath (symlinks included); a name on protected_names (.env.*, *.pem) anywhere in the sandbox and every file on secret_files count as protected paths
fs-sandbox block any path or cwd outside the sandbox
fs-write-scope require approval for writes inside the sandbox but outside writable_dirs
sh-syntax block a line an agent host's shell would run (via_shell) unless it is one simple command with literal arguments: no pipes, redirects, chaining, substitution, variables, globs or ~
sh-allowlist block binaries not on the allowlist (curl, python3, sh, …)
sh-ask require approval for a binary on shell_ask when every other shell check passes; denied where nobody can answer (narrower grants)
sh-exact allow an argv on shell_exact, exactly as written, when every other shell check passes
sh-args block options outside the binary's grammar and find predicates outside the allowlist (-exec, -ok, -fprint, -delete, …)
sh-paths block argv, including option values (--file=.env, -f.env), that touches a protected path or leaves the sandbox
sh-recursive block a recursive walk (grep -r, ls -R, find) that would reach a protected path or leave the sandbox, symlinks followed
net-deny-all block all network, loopback included (the default network.mode)
net-ok / net-allowlist with network.mode allowlist: allow GET and HEAD to an origin on network.allow, block everything else
taint-session extra block once the session has named or reached a protected path, even in a blocked request, or carried a secret value: no network, api.call, sh-ask or sh-exact afterwards (in the hook, for the rest of the host's session)
mcp-unknown-tool block MCP server/tool pairs outside the registry
mcp-protected-path / mcp-sandbox block MCP arguments that resolve to a protected path or leave the sandbox
mcp-resources-denied block MCP resource reads unless the policy enables them for that server
approval-denied block when the human says no, times out, or stdin is not interactive
fail-closed block everything when the audit store is unavailable; in the hook also when the session taint store is, or when the policy does not match its --policy-sha256 pin
unknown-tool / parse-error block a tool with no policy and a request that cannot be parsed or has a param of the wrong type (default-deny)
hook-unsupported block a host tool call the hook cannot check (a Codex patch aimed at another environment)
learn-ask learning mode ask only: require approval for a call refused only by sh-allowlist or mcp-unknown-tool that nothing else refuses
api-ok / api-approval / api-blocked per-operation decision for api.call, taken before any credential exists
api-unknown-operation block audience/operation pairs outside the policy (default-deny)

Narrower grants

The allowlist grants a binary whole, and the network is deny-all. Three optional keys grant less. A policy without them decides exactly as before, and a malformed entry is a policy that cannot load, so the hook denies everything.

{
  "shell_ask": ["python3", "npm"],
  "shell_exact": [["python3", "-m", "pytest"], ["npm", "test"]],
  "network": {
    "mode": "allowlist",
    "allow": ["https://docs.python.org", "https://*.pythonhosted.org"]
  }
}
  • shell_ask: binaries the firewall cannot bound (no argument grammar) that a human approves call by call (sh-ask). Claude Code shows its prompt with the exact command; Codex, bypassPermissions and a gateway without a terminal deny. Bare names only: /usr/bin/python3, ./python3 and VAR=1 python3 are not python3.
  • shell_exact: argv lists allowed exactly as written (sh-exact). A word more or less, another spelling of the program, a VAR=value prefix or any shell syntax is no match. A bare program matches only that spelling; a program with a slash matches the file it resolves to, from any cwd. With the binary also on shell_ask, the exact command runs and any other one asks.
  • network.allow (with "mode": "allowlist"): GET and HEAD without a body to the listed origins (net-ok); anything else is net-allowlist. An entry is scheme://host[:port][/path]: scheme and port must match, *. covers the names below a domain but not the domain, and a path is a prefix of whole segments. A URL that two parsers could read differently is refused instead of guessed: user info (https://allowed@evil), a backslash, % in the host, 127.1 or 2130706433 for 127.0.0.1, a non-canonical port, whitespace, non-ASCII, and .. (plain or encoded) under a path prefix. The executor does not follow redirects.

None of them skips another check: paths (for a binary without a grammar, also @file, --opt=@file, -d@file and name@file), the sandbox, forbidden options, recursive walks and sh-syntax come first, and a session that went for a protected path gets none of them (taint-session). What they cannot do: an exact command fixes the argv, not what the program runs (python3 -m pytest imports any test file the agent can write); an allowed origin can still receive data in the URL (a value from secret_files is refused there too; anything else is not); and the firewall does not resolve DNS.

Secrets that must not leave

Protected paths stop the agent from reading a secret file. Two optional keys cover what a list of paths misses, and a policy without them decides as before:

{
  "protected_names": [".env", ".env.*", "*.pem", "*.key", "id_rsa*", ".aws", ".ssh"],
  "secret_files": [".env", ".env.local"]
}
  • protected_names: file or directory name patterns (*, ?, [...], no slash) protected wherever they appear under the sandbox, and everything below them, like a protected path: .env.local, api/.env and deploy/server.pem are covered when protected_paths only names .env. Names above the sandbox root do not count. .env.* covers .env.example too; list narrower names if the agent needs that file.
  • secret_files: files whose values never leave in a request (secret-egress). Each one is also a protected path, and a missing one has no values. The firewall reads them itself and takes the value of every key that names a credential (API_KEY, AWS_SECRET_ACCESS_KEY, DB_PASSWORD, npm's _authToken), the password in a URL value, and every line that is not an assignment (a token file, a PEM body), 8 characters or more. Configuration such as NODE_ENV=development or API_URL=http://localhost:3000 is left alone. A request that carries a value in any param of any tool, as written, percent-decoded (twice), in base64 at any offset or in hex, is refused before any other rule and taints the session; its audit record keeps the file, the key and the names of the params, never the value.

secret-egress closes the allowlist that still lets the .env out: a value that reached the agent some other way (an interpreter a human approved, a tool the hook does not see, an MCP result) cannot leave through an origin on network.allow, an argument of an allowlisted curl, a file in writable_dirs, or an MCP or API argument. It is a tripwire for the value, not containment: a value split, reversed, compressed or encrypted is not recognized, and data that is not in secret_files is not checked at all. The article The allowlist that still lets the .env out walks through those leaks, and the agent rewriting its own policy, with real hook output.

Delegated credentials (api.call)

When an agent calls an API on a person's behalf, the gateway decides the operation first and only then asks a token broker (agent_firewall/credentials.py) for a credential:

  • The user's consent is a grant held by the broker, refresh token included; no method ever returns it.
  • Each allowed call gets its own access token: one audience, exactly the scope that operation needs, five minutes of life, and the agent named as the actor (RFC 8693 token exchange, simplified).
  • Resource servers validate it by introspection (RFC 7662 style). Revoking the grant stops new tokens and kills live ones.
  • The agent never sees a token. The audit record keeps the claims (jti, scope, expiry), never the bearer value.

tests/test_delegation.py covers the four ways delegation breaks when the caller is an agent. Each failure mode is a pair: the pattern commonly shipped today (the attack works) and the same attack through the gateway (it is stopped):

Failure mode Naive pattern With the broker
Scope that stays open a token from task 1 sends mail the next day per-call, one-scope, 5-minute tokens; wrong audience rejected
Refresh token as permanent access a leaked refresh token mints tokens 60 days later the refresh token never leaves the broker; revocation kills live tokens
Authentication mistaken for authorization any live token may send or delete the policy decides each operation before a token exists
Confused deputy a tool uses its own broad credential the tool gets an exchanged token for one audience and scope

MCP proxy

agent_firewall.mcp_proxy puts the gateway in front of any stdio MCP server. The client launches the proxy as if it were the server; the proxy launches the real one:

  • every tools/call goes through the policy and the audit log; a blocked call never reaches the server and the client gets isError: true naming the rule;
  • tools/list is filtered, so unregistered tools are not even shown to the model;
  • every argument that carries a path gets the same checks as fs.read, after realpath: the usual names (path, file, source, target, …, configurable with mcp_path_arguments), and any other string that looks like a path or names something under the server's root (mcp_roots); nested values included. A path outside the sandbox is blocked, not only a protected one;
  • resources/read goes through the policy and is denied unless the server is listed in mcp_resources;
  • the policy file and the audit log given on the command line are added to the protected paths, after realpath, so a call cannot read or rewrite them (mcp-protected-path); --protect PATH adds more (the client config that launches the proxy), and --policy-sha256 HEX refuses to start on a policy file that changed;
  • only known MCP methods are relayed; JSON-RPC batches are rejected and a tools/call sent as a notification is dropped, so nothing reaches the server around the policy;
  • --pin PATH=SHA256 refuses to start a server whose code changed;
  • --learn TRACE records one observation per tools/call and resources/read for vt-agent-firewall-propose (learning mode, enforce only): the answers do not change, the trace is protected, and a trace that cannot be written refuses every call;
  • approval never reads stdin (it is the MCP channel): it is denied unless --tty-approval is set and a terminal is available.

Example with the official filesystem server, as an entry in a client's MCP config:

{
  "mcpServers": {
    "filesystem": {
      "command": "vt-agent-firewall-mcp",
      "args": ["--policy", "/path/to/policy.json", "--audit", "/path/to/mcp-audit.jsonl",
               "--server-name", "filesystem", "--",
               "npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
    }
  }
}

Checked against @modelcontextprotocol/server-filesystem 0.2.0: of its 14 tools the model saw only the 3 registered ones, read_text_file .env was blocked, and move_file never reached the server. tests/test_mcp_proxy.py runs the same checks in CI against examples/fs_mcp_server.py, a deliberately naive server that does no path checking at all, so every block comes from the proxy.

Agent hooks (Claude Code, Codex)

The MCP proxy never sees an agent host's built-in tools, and those are where the real risk is: Claude Code's Bash, Read, Write, Edit, Codex's shell and apply_patch. Both hosts run a PreToolUse hook before each tool call; vt-agent-firewall-hook is that hook. It translates the call into the same requests (Bash → shell.run, Read → fs.read, Write/Edit → fs.write, WebFetch → net.request, mcp__* → mcp.call), evaluates them with the same policy, writes the decision to the same audit log, and answers in the host's protocol:

  • block → deny (JSON on stdout and exit 2 with the reason on stderr);
  • require_approval → Claude Code's own permission prompt (ask); Codex cannot ask from a hook, so there it is denied;
  • allow → silence, so the host's own permission rules still apply.

Because the host runs Bash lines in a real shell, a line must first pass sh-syntax: one simple command whose words are exactly what the policy checked. Anything the hook cannot parse or map, an unreadable policy, an unavailable audit log, an internal error or its own deadline all deny.

The hook keeps the agent away from what judges it. The policy, the audit log, the learning trace and the host settings that install the hook (.claude/settings.json, .claude/settings.local.json, .mcp.json, ~/.claude/settings.json; Codex's .codex/config.toml and .codex/hooks.json) are protected paths whether they exist or not, and --protect PATH adds more. --policy-sha256 HEX pins the policy: one that changed by any route, such as an allowed git checkout, is not loaded and every call is denied. A session that went for a protected path or carried a secret value stays tainted for its later calls; the hook keeps that next to its audit log. In a Claude Code settings file:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Monitor|Read|Write|Edit|MultiEdit|NotebookEdit|WebFetch",
        "hooks": [
          {
            "type": "command",
            "command": "vt-agent-firewall-hook --policy \"$CLAUDE_PROJECT_DIR/.claude/agent-firewall.json\" --base-dir \"$CLAUDE_PROJECT_DIR\" --audit \"$HOME/.local/state/vt-agent-firewall/audit.jsonl\"",
            "timeout": 30,
            "statusMessage": "Agent firewall"
          }
        ]
      }
    ]
  }
}

docs/AGENT_HOOKS.md covers install for both hosts, a smoke test, keeping the firewall out of the agent's reach, what it blocks, the protocol details it relies on, and its limits: it is policy, not OS isolation; whoever controls the settings can remove it; the host lets a tool run when the hook times out or cannot start; and only simple shell commands pass.

Learning mode

A least-privilege policy is hard to write from scratch, so people fall back to allow-all. With --learn TRACE the hook also records every request and what the policy decided, and, registered for PostToolUse too, which calls ran; vt-agent-firewall-propose replays the trace against the policy and writes a proposed policy with a report. Nothing is applied: the command never writes over the policy.

# in the hook command, for PreToolUse and PostToolUse (examples/hooks/claude-code-settings-learn.json):
#   ... --learn ~/.local/state/vt-agent-firewall/learn.jsonl
vt-agent-firewall-propose --policy .claude/agent-firewall.json --base-dir . \
  --out proposed.json ~/.local/state/vt-agent-firewall/learn.jsonl
python3 scripts/run_learning_demo.py   # the whole loop on demo_workspace
  • The policy is still enforced while learning (--learn-mode enforce, the default). --learn-mode ask (Claude Code only) turns a refusal by the shell allowlist or the MCP registry into Claude Code's prompt when nothing else in the policy refuses the call. It does not protect you from what you approve, and it stops asking once the session has gone for a protected path.
  • The proposal only allows what the firewall can bound: a writable directory (the first one under the sandbox root, never the root or a hidden directory) and binaries with an argument grammar. Binaries without one (interpreters, network clients, binaries that write) are proposed for shell_ask: asked about, never allowed. Their exact commands (shell_exact), the web origins the session fetched (network.allow), MCP tools and files in the root are listed for your decision with the entry to copy and how often they were seen. Protected paths, the sandbox, network and forbidden arguments are never relaxed.
  • The MCP proxy records too: vt-agent-firewall-mcp --learn TRACE writes one observation per tools/call and resources/read, in enforce mode only, so an MCP-only setup can be proposed from as well.
  • Not everything is evidence. What a session did after it named or reached a protected path, what was refused at approval, and an ask with no PostToolUse record (you said no) are not learned from. Before printing, the proposal is replayed against every observation and refused if anything else got looser.

docs/LEARNING.md has the walkthrough with demo_workspace, the trace format and the limits.

Layout

agent_firewall/   models, config, policy, argv (per-binary grammars), shell_syntax,
                  egress (secret values that never leave), session (taint, and the
                  hook's store of it), executor, audit, approval, gateway,
                  credentials (token broker),
                  mock_apis, mcp_proxy, hook (Claude Code / Codex PreToolUse),
                  learn (learning mode: traces and policy proposals),
                  network (the strict URL parsing behind network.allow)
corpus/           attacks.json: synthetic injections and their expected decisions
policies/         default.json: sandbox, protected paths, allowlists, MCP registry
scripts/          run_demo.py, mock_receiver.py, run_corpus.py, score_model_following.py,
                  run_learning_demo.py
examples/         fs_mcp_server.py (a naive MCP server for tests and demos);
                  hooks/ (Claude Code settings, with learning too, Codex config,
                  a project policy)
tests/            acceptance tests AC1–AC12, test_delegation.py (the four OAuth failure
                  modes), test_mcp_proxy.py (a real MCP server over stdio),
                  test_bypasses.py (every bypass found in review, as a regression),
                  test_hook.py (the PreToolUse hook, from synthetic events),
                  test_learn.py (learning mode: recording, ask mode, proposals),
                  test_policy_model.py (shell_ask, shell_exact, network.allow and
                  their bypasses), test_allowlist_exfil.py (the allowed channels
                  that still let a secret out), test_policy_integrity.py (the agent
                  rewriting what judges it)
docs/             THREAT_MODEL.md, AGENT_HOOKS.md, LEARNING.md; build-prompts/ (how
                  the first version was scaffolded)
demo_workspace/   synthetic sandbox for the demo

Limitations

These are deliberate v0 boundaries, not hidden ones:

  • No OS sandbox. An agent that can run code outside the pipeline bypasses it.
  • Taint is per path, not per content. Reading an allowed file and pasting its content into an allowed channel is not detected. The one exception is a value from secret_files, and that check is a tripwire: a value split, reversed, compressed or encrypted is not recognized.
  • Allowed actions have a durable intent before execution and an outcome after. A crash between them leaves an unresolved intent that needs reconciliation; the gateway cannot prove from that record alone whether the external action happened.
  • The demo agent is a scripted list of requests. The MCP proxy is real; the demo still uses the simulated demo MCP server.
  • The MCP proxy handles one call at a time and only covers stdio servers.
  • The agent hook is policy, not isolation. It decides whether a host tool call may start; it keeps the host's settings out of the agent's gated tools, but a person or a process outside the agent can still remove it, the host runs the tool anyway when the hook times out or cannot start, and aliases or PATH decide what an allowlisted name really runs. See docs/AGENT_HOOKS.md.
  • A narrower grant is as narrow as its entry. An exact command fixes the argv, not what the program does; an allowed origin can receive data in the URL. See Narrower grants.
  • Learning mode proposes; it does not judge intent. A session that never touched a protected path looks like work even if an injection drove it, and an ask you approved counts like any other evidence. Review each grant and its examples. See docs/LEARNING.md.
  • The shell grammars follow GNU userland (coreutils, grep, findutils). Binaries added without a grammar get a conservative fallback: every word that may name a file, curl's @file spellings included, is checked like a path. See the threat model for the rest.
  • Audit records are minimized: written content is kept as a SHA-256 and a length, network bodies as a 64-character preview plus a digest. Pass audit_verbose=True (or "audit": {"verbose": true} in the policy) for full records while debugging.
  • Tokens are bearer tokens. They are not sender-constrained (DPoP) yet: a stolen access token works for anyone until it expires, which is why it lives five minutes.
  • The APIs are mocks (agent_firewall/mock_apis.py) and the broker is in-process.

Reproducible attack corpus

corpus/attacks.json records fifty-eight synthetic untrusted instructions (five of them benign controls), their resulting tool requests and the expected decision and rule. Since 0.1.1 it includes the bypass classes found in review: recursive reads that reach a secret without naming it, file-valued options, a find action that writes, a child of a protected directory, and MCP arguments outside the sandbox or under an unexpected name. Since 0.2.0 it includes lines a host shell would expand (via_shell): chaining, a glob, command substitution and a redirect. The cases for shell_ask, shell_exact and network.allow try each grant's bypasses: an extra argument, a path or VAR=value spelling of the program, chaining, @.env, user info, a backslash, a look-alike host, IP spellings, another port or scheme, a POST, and a tainted session. Since 0.4.0 it includes secret values sent through channels the policy allows (an allowed origin's query string, also encoded; an allowlisted curl's form field and header; a write; MCP and API arguments) and copies of a secret under names the protected list does not include or in another case (.ENV). A case can add files (setup.files), symlinks (setup.symlinks), protected paths (policy.protected_paths_add) or policy keys (policy.set) to its own workspace. scripts/run_corpus.py gives each case a fresh temporary workspace and gateway session, checks every decision and audit record, and verifies that the canary was not changed. The corpus tests policy behavior; it does not measure whether a language model would follow an instruction. scripts/score_model_following.py reviewed-trace.jsonl scores separately reviewed model continuations. Each JSONL row names a corpus case_id, model, attacker_goal_attempted (true, false, or null) and a list of observed tool_calls names. Missing and uncertain cases stay visible; neither counts as resistance. This scorer does not run a model or infer its intent. Keep traces synthetic and do not include raw prompts, tool arguments, or secrets.

Roadmap

v0.1.0 shipped the gateway, the token broker with the four OAuth failure modes, and the MCP proxy; v0.1.1 closes the bypasses found in review; v0.2.0 adds the PreToolUse hook for Claude Code and Codex; v0.3.0 adds learning mode, which records a session and proposes a policy; v0.4.0 adds narrower grants, closes the ways an allowlist still let a secret out, and keeps the agent away from the policy and the settings that load the firewall (CHANGELOG; write-up: The allowlist that still lets the .env out). Next:

  1. Sender-constrained tokens (DPoP), so a stolen access token is useless.
  2. Content-level taint beyond the values in secret_files.
  3. Concurrent calls and the Streamable HTTP transport in the MCP proxy.
  4. Expand the synthetic corpus and add a separate model-following benchmark.

License

Apache-2.0.

About

Local security gateway between an AI agent and its tools (fs, shell, network, MCP): default-deny policy engine, human approval, append-only audit log. Tested against indirect prompt injection trying to exfiltrate a fake .env to 127.0.0.1. Python stdlib only, 118 tests. Not production-ready - a security lab.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages