Skip to main content

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, 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.1.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 "git+https://github.com/ValentinTorassa/VT-Agent-Firewall@v0.1.0"   # library + vt-agent-firewall-mcp

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 four OAuth failure modes

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, 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-allowlist block binaries not on the allowlist (curl, python3, sh, …)
sh-args block forbidden arguments (find -exec, -delete)
sh-paths block argv that touches a protected path or leaves the sandbox
net-deny-all block all network, loopback included
taint-session extra block once the session has touched a protected path
mcp-unknown-tool block MCP server/tool pairs outside the registry
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
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/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;
  • path arguments (path, paths, source, destination) get the same protected-path check as fs.read, after realpath;
  • --pin PATH=SHA256 refuses to start a server whose code changed;
  • 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.

Layout

agent_firewall/   models, config, policy, executor, audit, approval, gateway,
                  credentials (token broker), mock_apis, mcp_proxy
policies/         default.json: sandbox, protected paths, allowlists, MCP registry
scripts/          run_demo.py, mock_receiver.py
examples/         fs_mcp_server.py (a naive MCP server for tests and demos)
tests/            acceptance tests AC1–AC12, test_delegation.py (the four OAuth failure
                  modes), test_mcp_proxy.py (a real MCP server over stdio)
docs/             THREAT_MODEL.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 are audited after they run (denials are audited before). A crash between execution and audit would leave an allowed action unrecorded.
  • 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.
  • 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.

Roadmap

v0.1.0 shipped the gateway, the token broker with the four OAuth failure modes, and the MCP proxy (CHANGELOG). Next:

  1. Sender-constrained tokens (DPoP), so a stolen access token is useless.
  2. Content-level taint, not only per path.
  3. Concurrent calls and the Streamable HTTP transport in the MCP proxy.
  4. A reproducible corpus of injection attacks to use as a benchmark.
  5. PyPI release (vt-agent-firewall), see RELEASING.md.

License

Apache-2.0.

Release files for vt-agent-firewall 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vt-agent-firewall 0.1.0
File Size Uploaded
vt_agent_firewall-0.1.0.tar.gz 35.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vt-agent-firewall 0.1.0
File Interpreter ABI Platform
vt_agent_firewall-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 64.5 kB

Release files / vt_agent_firewall-0.1.0.tar.gz

Download URL vt_agent_firewall-0.1.0.tar.gz
Size 35.7 kB
Tags Source
SHA-256 checksum
How to use checksums
3bfa910a3b05314503479494e3295f6265e15ecf9ea27885958a347e5536f50a
BLAKE2b-256 checksum
How to use checksums
ebbd4023a7c414774617e3e4b82ad0a0a43299f0c760362b4af075fd2af63bc7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / vt_agent_firewall-0.1.0-py3-none-any.whl

Download URL vt_agent_firewall-0.1.0-py3-none-any.whl
Size 28.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8853bc86ab80fb6305f1990e889854861e9681e76d012bcc23d81e2c4680294c
BLAKE2b-256 checksum
How to use checksums
beac969b7dff283858e4d024f22a51258642883baef93fb49382cab6593c06fc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page