Skip to main content
Glama
beernathan87

agent-firewall

by beernathan87
README.md
# 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.

```bash
# 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).

## Policy

```yaml
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.

```bash
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:

```jsonc
// 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`.