Grit — a policy gateway for MCP tool calls
Grit sits between an MCP client and the tools it calls. Policy is keyed by (server, tool). Path arguments are fully resolved — following symlinks — and checked component-wise against allowed and denied roots. A workspace snapshot is taken before any tool that may mutate runs, so a bad call can be undone from what the filesystem actually shows. Credentials are AES-256-GCM encrypted at rest and decrypted only for the tool that declared them. Every decision lands in an append-only audit log.
Architecture
- 1Authorise — look up the (server, tool) policy; undeclared means denied
- 2Contain — resolve every path argument, check it against allowed and denied roots
- 3Snapshot — capture the workspace if the tool may mutate
- 4The host runs the tool — not Grit's business
- 5Complete — size cap, injection scan, rollback on refusal, audit either way
The problem
An agent talked into passing a bad path, a tool that writes more than it claimed, an injected instruction that redirects a file operation — these are the realistic failures of tool-using agents, and none of them are caught by asking the agent to behave. They need a layer that decides before the call and measures after it, from outside the agent.
Approach & decisions
- A two-phase API — authorise before, complete after — because the interesting moment belongs to somebody else: Grit never executes the tool, the host does. A single function would have to snapshot too late or never roll back.
- Paths are resolved first, then compared. Checking the string the tool was handed, rather than where it actually points, is exactly how a symlink walks a permitted call out of its root.
- mutates defaults to true because the cost of being wrong is asymmetric: a read marked mutating wastes a snapshot; a write marked read-only means no rollback exists when it matters.
- Snapshots are bounded — 5,000 files or 64 MB — and exceeding the bound is an error, never a partial capture, because a rollback from half a snapshot would appear to succeed.
- In the secret store, a secret's name is authenticated as associated data under GCM, so swapping two ciphertexts in the store file fails closed instead of quietly handing a tool the production token it was not meant to have. Cryptographic failures return one opaque error: distinguishing a bad key from a bad tag is how oracles get built.
- A real race surfaced only once CI was actually executing the suite: one test mutated the process-wide master-key variable while others were decrypting. Fixed by serialising those tests behind a mutex, and reproduced clean over eight consecutive runs before the fix was called done.
Results
- 49 tests across policy, containment, snapshot-and-rollback and the crypto store, green in CI with secret and dependency scanning on every push.
- Rollback is measured from the filesystem, not from anything the tool says about itself, so it holds regardless of how well-behaved the tool turns out to be.
- The injection heuristics are documented as a signal, not a boundary: pattern-matching on prose is evaded by rephrasing. The containment is the policy layer; the patterns raise an alarm.
What this honestly is not
- Not kernel sandboxing. No namespaces, no seccomp, no cgroups — a tool Grit permits could ignore the path it was handed and open something else directly. It checks arguments, not syscalls, and is built to sit inside a real sandbox rather than replace one.
- Not network policy. allow_network is declared and recorded, not enforced.