The thing that should have been a 2 a.m. incident
You wire a database tool into your coding agent. PostgreSQL, Databricks, an internal HTTP API — whatever. The agent is useful. It joins tables you'd forgotten existed, drafts the migration, and writes the report. Productivity goes up.
Two weeks later, you scroll back through a transcript and see this:
> select * from users where signup_at > '2026-04-01' limit 5;
[{
"id": 41021,
"full_name": "Alice Johnson",
"email": "alice.johnson@example.com",
"phone": "+1 415-555-0142",
"card_last_four": "4242",
"ssn": "123-45-6789",
"status": "active"
}, ...]
That's now in the model's context. From there it's in the conversation log, in any summary the agent generates, in the file it just wrote to /tmp, and — if the harness has any kind of memory or "share session" feature — potentially somewhere on someone else's machine. The agent didn't do anything wrong. Neither did you. The tool returned what you asked for, the model ingested it, life went on.
This is the default. Every CLI client, MCP server, and curl | jq pipeline returns the same bytes a human would see — and with agents, there's no human in the loop to triage what enters the model's window.
This post is about a tool that fixes the leak at the layer where it can be fixed deterministically.
What gate is
gate is a single Rust binary that sits between an AI agent and the data tools it calls. It intercepts the output of configured commands, scans it for PII, and rewrites the values to typed placeholders before the bytes reach the model:
See the demo on the original post
- {"id": 1, "email": "alice@example.com", "ssn": "123-45-6789", "status": "active"}
+ {"id": 1, "email": "[PII:email]", "ssn": "[PII:ssn]", "status": "active", "_gate_summary": {"redacted": 2, "types": ["email", "ssn"]}}
The original JSON shape is preserved. The agent can still iterate, count, and reason about rows; it just never gets to see the values it doesn't need.
The design constraints, in order of priority:
- Deterministic. No LLM-in-the-loop redaction. Same input → same output, every run, on a plane with no network.
- Bypass-resistant within the harness's threat model. The agent should not be able to disable the filter by asking nicely, calling the tool in a clever way, or shelling out through a different verb.
- Fast on the hot path. It runs on every Bash command the agent invokes. If it's slow, people turn it off.
- Honest about its limits. A false-negative is worse than a false-positive — the failure mode is silent data exposure, not a noisy block.
A small Rust tool with under 10ms overhead on the hot path, MIT-licensed, builds with cargo build, and ships on Homebrew.
The two access paths
Modern agent harnesses give models data through two doors. The Model Context Protocol has become the de-facto integration layer for Postgres, Snowflake, GitHub, Linear, and internal APIs — and most of the published material on it is about building servers, not about auditing what they return. The model trusts what the server hands back. Nobody is reading the bytes.
gate covers both doors: MCP servers via a stdio proxy, and Bash/CLI tools via a harness hook.
1. MCP servers (via a stdio proxy)
gate mcp is a tiny stdio JSON-RPC proxy. You register it as the MCP server in your harness; it spawns the real server underneath and forwards every message verbatim — except tools/call responses, which are passed through the value-scanner before the bytes return to the model.
AI ──tools/call──> gate mcp ──forward──> upstream MCP server
│
│ <── tools/call response with PII
│
│ Gate 2 scan + redact
│
AI <───redacted result─┘
The proxy is transparent. Upstream servers run unchanged, and you migrate the whole fleet in one shot:
gate init --wrap-mcp # dry-run: lists every server that would be wrapped
gate init --wrap-mcp --yes # apply
gate init --wrap-mcp --servers postgres,github --yes # opt-in subset
This converts every server in your ~/.claude.json (or ./.mcp.json for project scope, or the OpenCode / Copilot CLI equivalents) into a gate mcp <original-command> proxy in one shot. Already-proxied servers are skipped, so re-running is idempotent. When you add a new MCP server later, run it again.
What this means concretely: you can adopt a third-party MCP server you don't control — a vendor's Postgres connector, an internal team's CRM bridge — and still get a deterministic PII boundary between what it returns and what your model ingests. Without changing the server. Without trusting its author to have thought about redaction.
2. Bash tools (via a harness hook)
Every command the agent wants to run — tkpsql query ..., psql -c ..., databricks api post ..., curl https://internal/... — passes through gate hook first. The hook checks whether the command matches a tool listed in config. If it does, the command is silently rewritten to:
gate run -- <original command>
gate run spawns the original subprocess, captures its stdout, runs the two-stage redaction pipeline on the bytes, and emits the sanitized result back. The agent sees the same JSON structure it always did, with values replaced by [PII:<type>].
The rewrite happens in the harness's pre-tool-execution hook, which means it is enforcing, not advisory:
-
Claude Code —
PreToolUsehook in~/.claude/settings.json; Claude Code substitutes the rewritten command viaupdatedInputbefore spawning. -
OpenCode — a TypeScript plugin's
tool.execute.beforehandler mutatesoutput.args.commandin-flight. -
Cursor —
PreToolUsehook in.cursor/mcp.json; Cursor substitutes the rewritten command before spawning. -
GitHub Copilot CLI —
PreToolUsehook in.github/hooks/PreToolUse.jsonreturnsmodifiedArgs. -
Codex CLI —
PreToolUsehook trusted and enabled via the Permissions UI; substitutes the rewritten command before spawning. -
Gemini CLI —
PreToolUsehook; Gemini CLI substitutes the rewritten command aftergate init --harness geminiand a session restart.
The agent doesn't know gate is there, and humans running the same commands in a normal terminal are untouched — there's no wrapper script on PATH.
The two-gate detection pipeline
Despite the name, gate is two filters, applied in sequence, with very different jobs.
Gate 1: SQL intent analysis (best-effort)
When the intercepted command has a sql_arg configured (e.g. tkpsql --sql, psql -c, databricks --json statement), gate extracts the SQL string and runs it through a hand-written tokenizer. The goal is modest: figure out which columns the query selects, so they're marked for guaranteed redaction regardless of what comes back in the value.
SELECT u.first_name, u.email AS contact, p.phone
FROM users u JOIN profiles p ON u.id = p.user_id
WHERE u.signup_at > NOW() - INTERVAL '30 days'
Gate 1 extracts first_name, email (aliased as contact), and phone. Any of those that match a PII heuristic gets added to a forced_columns map. Gate 2 then redacts those fields unconditionally — even if the value happens to be NULL or "unknown" or fails a regex check.
Why a hand-written tokenizer instead of
sqlparser-rs?
Because Gate 1 only needs to find column references. Pulling in a full SQL parser turned out to be a bad trade: more dependencies, more dialect bugs, more code paths where a parse failure could silently drop columns from the plan. The tokenizer is ~300 lines, dialect-agnostic, and on a parse failure it errs toward "I don't know which columns" — which is fine, because Gate 2 then runs on every field.
Gate 1 is explicitly best-effort. It is documented as such. Wildcards, CTEs, function calls around columns, and weird dialects all degrade gracefully:
| Pattern | Gate 1 behaviour | Safety net |
|---|---|---|
SELECT email, name FROM u |
columns extracted ✓ | — |
SELECT LOWER(email) FROM u |
function call — column skipped | Gate 2 catches the value via email regex |
SELECT email AS contact |
alias tracked: contact → email ✓ |
— |
SELECT * FROM u |
wildcard — no column hints | Gate 2 runs on every field; wildcard_policy: reject can block |
WITH x AS (SELECT email...) |
only outermost SELECT analysed | Gate 2 catches via value regex |
| Non-standard dialect | may produce empty plan | Gate 2 catches via value regex |
This is the load-bearing design choice in gate: Gate 1 is allowed to be wrong, because Gate 2 is the safety net.
Gate 2: value scanning + column-name heuristics
Gate 2 runs on the JSON response after the subprocess returns. For each field, it applies three checks:
- Forced columns from Gate 1 → always redact, regardless of value.
-
Column-name heuristics → tokenise the JSON key (handling
snake_case,camelCase,PascalCase,UPPER_CASE) and match against ~50 PII categories.userEmail,user_email, andUSER_EMAILall resolve to the same rule. - Value patterns → regex matches for email, US/AU/NZ phone, US SSN, AU ABN, AU Medicare, AU/NZ TFN/IRD (formatted), NZ NHI, NZ bank account numbers, plus a Luhn check for payment cards.
The column-name match adds a confidence boost to any value match in the same field. Gate 2 always redacts on any match — there is no threshold to clear. Low-confidence matches (e.g. a 9-digit string in a column called tax_id) are redacted and flagged with a low-confidence warning in _gate_summary. Review flagged columns via gate retro, then silence a false positive with gate allowlist add <column>.
The output goes back as the same JSON the tool produced, with values rewritten in-place and a _gate_summary block appended so the agent can reason about what was scrubbed:
{
"rows": [{"id": 1, "email": "[PII:email]", "ssn": "[PII:ssn]"}],
"count": 1,
"_gate_summary": {"redacted": 2, "types": ["email", "ssn"], "warnings": []}
}
Honesty about the gaps
The full threat model is in the repo but the headlines are:
-
gateis not a sandbox. It only filters commands explicitly listed intools:. Anything else passes through. -
The adversary model is an inadvertent agent, not a malicious one.
sudo gate protect(Unix) chowns the config to root so a hijacked agent can't disable gate via config edits, but a jailbroken agent that deliberately base64-encodes data, requests CSV output, or exfiltrates through a non-intercepted tool is still out of scope. Combinegatewith harness-level tool restrictions and a read-only database role if you need that boundary. -
Value regex covers the common cases and AU/NZ. Email, US SSN (dashes required —
123456789slips), payment cards (via Luhn), and phone numbers including AU/NZ mobile and landline in both local format (04XX/02X,0[2378]/0[34679]) and international format (+61/+64prefixes) — international-prefix numbers auto-redact in any column; local-format numbers require a PII-named column. AU/NZ-specific identifiers caught by value: ABN (mod-89 checksum), Medicare card (mod-10), formatted TFN and IRD (mod-11, separators required), NZ NHI, and NZ bank account numbers. Bare TFN/IRD strings without separators are not caught by value alone — column-name matching is the safety net there. IBAN, passport, NHS, Aadhaar, and other non-AU/NZ formats rely on column-name matching only; extendpii.patternsfor your region. -
MCP
resources/readandprompts/getare not redacted. Onlytools/callresponses go through the scanner. -
Non-JSON output is not redacted. If a tool emits CSV or plain text, configure a
pipe:to convert it (the example config usesjq -c .for curl and a 3-line Pythoncsv.DictReaderforpsql --csv). -
Disable mechanisms exist.
enabled: falsein config, deleting the config file, or removing the hook entry from the harness settings.sudo gate protect(Unix) chowns the config to root to block the first two from inside the agent, but the harness settings file is still user-writable.
If any of these are deal-breakers, the tool is honest about it up front. Better than discovering it in a post-mortem.
Why not just mask the data at the source?
Database-level masking — static anonymised copies, dynamic data masking (DDM), row security policies — is the right answer when you control the source and have the access to configure it. Gate fills the gap when you don't, and covers the paths masking can't reach.
| gate | Database masking | |
|---|---|---|
| Requires DB admin access | ✅ No changes to the database | ❌ Needs column-level config by a DBA |
| Works on vendor / external DBs | ✅ Wraps any JSON-returning tool | ❌ Only databases you administer |
| Covers MCP and API tools | ✅ GitHub, Linear, internal APIs — any tools/call response |
❌ No masking concept at this layer |
| Production data freshness | ✅ Works against live data | ❌ Static copies drift; DDM may lag |
| Agent bypass resistance | ✅ Direct value exposure blocked in harness hook | ❌ Aggregate functions and CASE expressions can bypass DDM |
| Known gaps | ✅ Documented | ❌ DDM gaps are often silent |
They're complementary: if you have DDM configured, gate is the safety net for the paths and patterns DDM misses.
Why a deterministic CLI and not "just ask the model"
It is technically possible to ask the model to redact its own input before it ingests it. People are building this. I chose not to, for three reasons:
- Cost. Every query result would round-trip through a model call. A single agent session might run hundreds of queries.
-
Latency. A hook on every Bash command needs to return in single-digit milliseconds.
gate hook's passthrough path is in that ballpark; an LLM call is not. - Auditability. "Why was this field redacted?" needs an answer that survives review. A regex and a tokenizer can be inspected, golden-file tested, and re-run on the same input forever. A model in 2026 will not give the same output on the same input in 2027, and you will not get a stack trace.
Existing PII tools (Presidio, Nightfall, Skyflow) take the opposite trade — they're mostly built for data-pipeline or SaaS-gateway use, sitting at API boundaries or in batch jobs, not in the agent's tool-execution path with single-digit-ms latency and harness-level hook enforcement. gate is shaped specifically for that boundary.
What it costs to try
# macOS / Linux via Homebrew
brew tap GaaraZhu/gate && brew install gate
# or cargo binstall / direct download — see the README
gate scan # pipe your schema in — risk report by PII tier, exits 1 if any found
gate config # creates ~/.config/gate/config.yaml in your editor
gate init # registers the PreToolUse hook in ~/.claude/settings.json
gate init --wrap-mcp # dry-run: shows which MCP servers would be wrapped
gate validate # compiles all regex patterns, lints the config
gate retro # after a few sessions: tally of what was redacted and where
Run gate disable to turn it off if you need to debug something, and gate enable to switch it back on. gate uninstall removes everything gate added to your system and asks for confirmation before each step.
Where this is going
What's next, roughly in priority order:
- More built-in patterns by region. AU/NZ identifiers are now covered natively. Community PRs adding IBAN, passport, NHS, Aadhaar, and other regional formats are welcome.
-
MCP
resources/readredaction. Closing the one documented gap in the MCP path. -
Windows hardening. The binary builds and runs on Windows, but test coverage is thin —
gate protect(config ownership transfer) is Unix-only, and edge cases around path handling and terminal output are less exercised. Contributions welcome.
If you've been holding off on connecting your AI agent to a real data source — database, internal API, or MCP server — because "what the model sees" was a vibes-based decision, this is the layer that turns it into a config file. Try it, scan your schema, and share what you find. The repo is github.com/GaaraZhu/gate. The issue tracker is open. The license is MIT.
I'd rather hear "gate redacted something it shouldn't have" than "gate let something through that it shouldn't have." If you find the second one, that's a security bug and there's a process for it.
Top comments (0)