Skip to main content
Glama
beernathan87

agent-firewall

by beernathan87

agent-firewall

Published on npm as @beernathan87/agent-firewall (the unscoped name was already taken); the command name stays agent-firewall.

A local policy gateway between AI agents and their tools. It sits in front of any MCP server (filesystem, shell, GitHub, browser, …) and applies allow / ask / block rules to every tool call: which tools, which filesystem paths, which shell commands, which network domains. Anything marked ask pops up on a local approval page; everything is written to an audit log.

# before: your MCP client config runs the server directly
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/me/project"] }

# after: run it through the firewall
"filesystem": { "command": "npx", "args": ["-y", "agent-firewall", "--policy", "/home/me/project/agent-firewall.yml", "--name", "fs", "--",
                                            "npx", "-y", "@modelcontextprotocol/server-filesystem", "/home/me/project"] }

Works with Claude Desktop, Claude Code, Cursor, Windsurf, Codex, Continue - anything that launches MCP servers over stdio. No changes to the agent or the server. Zero dependencies except yaml, Node 20+.

What happens on a tool call

  1. The firewall extracts paths, shell commands and hosts from the tool's arguments (heuristic key and value inspection: path, paths[], command, args[], URLs in strings, …).

  2. Built-in dangerous command patterns (rm -rf /, curl … | sh, git push --force, mkfs, DROP TABLE, --no-verify, …) are blocked outright.

  3. The first rule whose tool glob matches decides. Inside a rule, paths / commands / domains patterns refine it: a block pattern always wins, otherwise the longest matching pattern decides (ties prefer ask). Every extracted value contributes its result, including the rule decision or policy default for unmatched values; the most restrictive result wins. Patterns can override the enclosing rule decision. Put narrow tool rules before broad rules: later rules do not run.

  4. allow → forwarded. block → the agent receives an isError tool result explaining why (so it stops instead of looping). ask → the call waits for you on http://127.0.0.1:<port>/<token> (printed to stderr): Approve, Approve for this session, or Deny; unanswered requests are denied after askTimeout seconds.

  5. Every decision is appended to agent-firewall-audit.jsonl (tool, decision, rule, reason, extracted paths/commands/hosts, truncated args).

Related MCP server: cordon

Policy

default: ask              # anything no rule covers
askTimeout: 120
rules:
  - id: reads
    tool: [read_file, list_directory, search_files, "*_read"]
    decision: allow
    paths: { block: ["**/.env", "**/*.pem", "~/.ssh/**", "**/secrets/**"] }
  - id: writes
    tool: [write_file, edit_file, "*_write"]
    paths:
      allow: ["./src/**", "./test/**"]     # relative to the firewall's working directory
      ask: ["./**"]
      block: ["**/.git/**", "~/**"]
  - id: deletes
    tool: ["delete*", "remove*"]
    decision: block
    reason: deleting is done by humans in this repo
  - id: shell
    tool: [execute_command, run_command, shell, bash]
    commands:
      allow: ["git status*", "git diff*", "npm test*", "ls*"]
      ask: ["git commit*", "git push*", "npm install*"]
      block: ["sudo *", "rm *", "curl *", "ssh *"]
  - id: web
    tool: [fetch, http_request]
    domains: { allow: ["api.github.com", "*.githubusercontent.com"], ask: ["*"] }
  - id: github-writes
    server: github          # only when started with --name github
    tool: [create_issue, create_pull_request, push_files, "merge_*"]
    decision: ask

Globs: * matches within one path segment, ** crosses segments, ? one character; command and domain patterns are plain globs (* matches anything). A plain list (paths: ["./src/**"]) means "only these, block everything else", including calls with no extracted values for that list. Leading **/ path patterns apply outside the working directory too. **/.env does not match .env.local; include **/.env.*. Paths normalize dot segments, backslashes, file URLs and percent escapes; Windows path inspection also removes alternate-stream suffixes and trailing dots/spaces from segments; ~ uses the firewall process home at policy load and evaluation. Windows path matching ignores case; other platforms use case-sensitive matching, even on case-insensitive macOS volumes. Use conservative tool decisions where filesystem case aliases matter. Domain patterns should use lowercase ASCII/punycode; extracted hosts use URL hostname parsing, including IP addresses and userinfo. A server: rule requires a nonempty --name. Without a policy file the built-in defaults apply: reads allowed (except secrets), writes / deletes / shell / network ask, selected exact shell commands allowed; additional flags require approval. Builtins escalate shell syntax (chains, substitutions, redirection, quoting) and interpreter/wrapper invocations to ask; common direct destructive patterns block case-insensitively. All string arguments of shell-like tool names are inspected as commands. builtins: false disables both protections.

agent-firewall check execute_command '{"command":"git push --force"}'    # BLOCK (exit 2); ASK exits 3, ALLOW 0
agent-firewall explain --policy agent-firewall.yml                       # print the compiled rules

Options

Flag

Default

Meaning

--policy FILE

./agent-firewall.yml or built-ins

Policy file

--name NAME

Server name for server: rules and the audit log

--audit FILE

./agent-firewall-audit.jsonl

JSONL audit log (- disables)

--ask prompt|deny|allow

prompt

What ask does: local approval page, deny, or auto-approve (unsafe)

--ask-port N

random

Port of the approval page (127.0.0.1 only)

--timeout S

policy askTimeout / 120

Seconds before an unanswered approval is denied

--audit-max-mb N

50

Rotate the audit file to <file>.1 when it exceeds N MB (one generation kept)

Limits (read this)

It gates what goes through the MCP server it wraps. A shell tool that is allowed to run node script.js can do anything script.js does; path extraction is heuristic (arguments that look like paths or commands), so a tool with an unusual schema may need an explicit decision. It is not a sandbox - combine it with OS-level permissions for hard guarantees. Approval decisions are per firewall process; "approve for this session" covers the same server, tool and complete serialized arguments until the server exits. The approval page shows complete arguments. Changed content or additional paths require another approval; reordered object keys may also prompt again.

Only individual newline-delimited JSON-RPC objects are accepted from the client. Batches are rejected in full, malformed tool calls are rejected, and tools/call notifications are dropped without execution. CRLF and split UTF-8 chunks are supported. Messages exceeding 8 MiB terminate the connection. An approval wait does not serialize later calls; clients must await results for dependent actions. Stdin EOF waits for pending approvals before closing the server input.

The built-in read-tool names and shell allowances are convenience heuristics, not proof of harmless behavior: a tool called get_* may mutate state, npm test runs project code, and user-authored prefix globs can permit flags that change behavior. Review the wrapped server and use default: ask with explicit tool rules for untrusted schemas. The firewall does not resolve symlinks, inspect script bodies, enforce network redirects/DNS resolution, or constrain processes after a permitted call. Shell analysis is conservative pattern matching, not a shell parser; wrapped destructive commands may ask instead of block. On Windows, .cmd/.bat launchers (the shims npm creates, e.g. mcp-server-filesystem.cmd) are started through cmd.exe; the launcher path and every argument are quoted by the firewall, so paths with spaces work, but arguments containing double quotes or newlines are rejected. Prefer launching node.exe with the server's entrypoint when you can. Bare npx child-command resolution is not guaranteed on Windows.

Audit JSONL escapes embedded newlines. Obvious credentials in the argument summary are masked (Bearer …, token=/password=/api_key= values, sk-/ghp_/AKIA… style keys, PEM private keys), but the audit is not a redacted record: arguments (truncated to 500 characters), extracted paths/commands/hosts and tool names can still contain sensitive content. Protect the audit file with OS permissions and retention appropriate to its contents. Audit write failures warn on stderr and do not stop execution. The approval URL is a local bearer secret; do not share it. The page sends no referrer and rejects foreign Host/Origin headers.

Not in v1 (on purpose)

HTTP/SSE MCP transports, non-MCP agent frameworks, central policy sync, desktop notifications, argument rewriting. Team policies, central audit and AgentGuard integration are the intended paid layer.

Installing into a client

npm install -g @beernathan87/agent-firewall (or npx -y @beernathan87/agent-firewall inside the client's command) and wrap each server entry:

// Claude Desktop / Claude Code / Cursor / Codex: claude_desktop_config.json, .mcp.json, mcp.json ...
"filesystem": {
  "command": "agent-firewall",
  "args": ["--policy", "C:\\Users\\me\\my project\\agent-firewall.yml", "--name", "fs", "--audit", "C:\\Users\\me\\my project\\agent-firewall-audit.jsonl", "--",
           "C:\\Users\\me\\AppData\\Roaming\\npm\\mcp-server-filesystem.cmd", "C:\\Users\\me\\my project"]
}

Verified with the official MCP client SDK over stdio on Windows (agent-firewall.cmd shim from a prefix with a space, wrapping @modelcontextprotocol/server-filesystem's .cmd launcher rooted at a directory with spaces) and on Linux/Node 20 (~ in policy paths, case-sensitive matching: a write to SRC/ is not covered by an **/src/** allowance). Approvals were exercised in Chromium and Firefox. When the wrapped server exits, the firewall exits with the same code and the client sees the server as gone; check exits 0 (allow), 2 (block), 3 (ask).

Default policy - what you are trusting

With no policy file the built-ins apply. Read this before relying on them:

  • Reads are allowed by name: tools whose names look like reads (read_*, list_*, get_*, search_*, …) run without asking, except on secret-looking paths (.env*, keys, ~/.ssh, …). A tool that mutates state but is called get_something is allowed. If you wrap a server you have not read, start with default: ask and explicit tool rules.

  • Exact shell allowances: only a short list of harmless commands (git status, git diff, ls, pwd, …) is auto-allowed, and only as exact matches - any extra flag, chain, redirection, substitution or wrapper (bash -c, xargs, sudo, env) asks. Destructive patterns block outright.

  • Writes, deletes, network and everything unknown ask. ask without an approval page (--ask deny, headless CI) is a deny.

  • Extraction is heuristic: arguments are scanned for path-, command- and URL-shaped values; a server with an unusual schema can carry a path in a field the firewall does not recognise. Give such tools an explicit decision.

Security boundary

  • Trusted: you (the approval page and policy file), the MCP client, the OS user account. The firewall runs with your privileges and enforces policy only on tools/call messages that pass through it.

  • Untrusted: the agent (tool-call arguments), the wrapped server's behaviour after a permitted call, and anything a permitted command executes.

  • Approval page: bound to 127.0.0.1 with an unguessable URL token; rejects foreign Host/Origin, Origin: null and cross-site Sec-Fetch-Site; Referrer-Policy: same-origin, X-Frame-Options: DENY, CSP default-src 'none'. Do not expose the port.

  • Not a sandbox: no symlink resolution, no script inspection, no network enforcement; combine with OS permissions. Report vulnerabilities privately to the maintainer before disclosure.

Troubleshooting

Symptom

Cause / fix

Client shows the server as failed immediately

Run the same command in a terminal: the wrapped server's stderr is passed through. On Windows check that .cmd paths are correct and contain no quotes

Every call is blocked "(not approved)"

--ask deny or no browser reached the approval page before --timeout; open the URL printed on stderr

Approve button does nothing

Old versions sent Origin: null from Chromium - update to 0.1.0+; the page must be opened at the exact printed URL (127.0.0.1, not localhost)

agent-firewall-audit.jsonl grows

--audit-max-mb rotates it; --audit - disables it

A path rule does not match on Linux/macOS

Matching is case-sensitive there; write patterns in the real case

Credits

Created by Nathan Beer. Developed by Nathan Beer with AI-assisted engineering using Claude and ChatGPT.

MIT - see LICENSE; third-party licenses in THIRD_PARTY_NOTICES.md.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    C
    maintenance
    Security gateway that wraps any MCP server with per-tool policies, approval gates, and optional Ed25519-signed decision receipts. Shadow mode logs every tool call without blocking; enforce mode applies block, rate-limit, and minimum-tier rules. Receipts are independently verifiable offline with no accounts needed.
    5
    1,279 npm
    10
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Security gateway for MCP tool calls. Sits between your LLM client and MCP servers, enforcing per-tool policies (allow/block/approve/read-only), logging every call, and pausing dangerous operations for human approval in terminal or Slack.
    4 npm
    1
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    A drop-in proxy that guards MCP servers with policy enforcement, secret redaction, prompt-injection screening, rug-pull detection, rate limiting, and audit logging.
    12 npm
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    A policy-enforcing MCP gateway that intercepts all tool calls to downstream MCP servers, applying allow/deny/ask rules with human approval and audit logging for safe access to dangerous tools.
    8 npm
    MIT