DEV Community

Gary
Gary

Posted on

Gate: a deterministic PII boundary between your data and AI agents

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"
}, ...]
Enter fullscreen mode Exit fullscreen mode

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"]}}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Deterministic. No LLM-in-the-loop redaction. Same input → same output, every run, on a plane with no network.
  2. 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.
  3. Fast on the hot path. It runs on every Bash command the agent invokes. If it's slow, people turn it off.
  4. 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─┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 CodePreToolUse hook in ~/.claude/settings.json; Claude Code substitutes the rewritten command via updatedInput before spawning.
  • OpenCode — a TypeScript plugin's tool.execute.before handler mutates output.args.command in-flight.
  • CursorPreToolUse hook in .cursor/mcp.json; Cursor substitutes the rewritten command before spawning.
  • GitHub Copilot CLIPreToolUse hook in .github/hooks/PreToolUse.json returns modifiedArgs.
  • Codex CLIPreToolUse hook trusted and enabled via the Permissions UI; substitutes the rewritten command before spawning.
  • Gemini CLIPreToolUse hook; Gemini CLI substitutes the rewritten command after gate init --harness gemini and 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'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Forced columns from Gate 1 → always redact, regardless of value.
  2. Column-name heuristics → tokenise the JSON key (handling snake_case, camelCase, PascalCase, UPPER_CASE) and match against ~50 PII categories. userEmail, user_email, and USER_EMAIL all resolve to the same rule.
  3. 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": []}
}
Enter fullscreen mode Exit fullscreen mode

Honesty about the gaps

The full threat model is in the repo but the headlines are:

  • gate is not a sandbox. It only filters commands explicitly listed in tools:. 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. Combine gate with 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 — 123456789 slips), 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/+64 prefixes) — 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; extend pii.patterns for your region.
  • MCP resources/read and prompts/get are not redacted. Only tools/call responses 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 uses jq -c . for curl and a 3-line Python csv.DictReader for psql --csv).
  • Disable mechanisms exist. enabled: false in 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:

  1. Cost. Every query result would round-trip through a model call. A single agent session might run hundreds of queries.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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/read redaction. 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)