VT-Agent-Firewall
Health Warn
- License — License: Apache-2.0
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 7 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
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.
VT-Agent-Firewall
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 callsopen, 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.

Status: alpha (0.3.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). 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 # 26 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 |
|---|---|
fs-protected |
block reads/writes of protected paths and anything under them, after realpath (symlinks included) |
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-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 |
taint-session |
extra block once the session has named or reached a protected path, even in a blocked request |
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 |
unknown-tool / parse-error |
block a tool with no policy and a request that cannot be parsed (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) |
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/callgoes through the policy and the audit log; a blocked call never
reaches the server and the client getsisError: truenaming the rule; tools/listis filtered, so unregistered tools are not even shown to the model;- every argument that carries a path gets the same checks as
fs.read, afterrealpath: the usual names (path,file,source,target, …, configurable
withmcp_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/readgoes through the policy and is denied unless the server is listed
inmcp_resources;- only known MCP methods are relayed; JSON-RPC batches are rejected and a
tools/callsent as a notification is dropped, so nothing reaches the server
around the policy; --pin PATH=SHA256refuses to start a server whose code changed;- approval never reads stdin (it is the MCP channel): it is denied unless
--tty-approvalis 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, andmove_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 andapply_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 passsh-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. 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, 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; 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: ... --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 adds 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. Interpreters, network clients, binaries
that write, MCP tools and files in the root are listed for your decision, with
how often they were seen. Protected paths, the sandbox, network and forbidden
arguments are never relaxed. - Not everything is evidence. What a session did after it named or reached a
protected path, and what was refused at approval, is 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,
executor, audit, approval, gateway, credentials (token broker),
mock_apis, mcp_proxy, hook (Claude Code / Codex PreToolUse),
learn (learning mode: traces and policy proposals)
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, 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)
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. - 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 simulateddemoMCP 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; whoever controls the host's settings can remove it, the host runs the
tool anyway when the hook times out or cannot start, and aliases orPATHdecide
what an allowlisted name really runs. See docs/AGENT_HOOKS.md. - 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 the
hook does not see your answer to a prompt. 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. 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. Passaudit_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 twenty-six synthetic untrusted instructions (three 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. A case can add symlinks (setup.symlinks) or protected
paths (policy.protected_paths_add) 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 corpuscase_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 thePreToolUse hook for Claude Code and Codex; v0.3.0 adds learning mode, which records a
session and proposes a policy (CHANGELOG).
Next:
- Sender-constrained tokens (DPoP), so a stolen access token is useless.
- Content-level taint, not only per path.
- Concurrent calls and the Streamable HTTP transport in the MCP proxy.
- Expand the synthetic corpus and add a separate model-following benchmark.
License
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found