AI Safety / Security

Stopgate — Stop Watching Your Agent

I wanted to leave my coding agent running without approving every step — but not if it might do something I couldn't undo. So I turned my own AI-safety research into the thing that lets me walk away.

PythonClaude Code hooksMCPTaint / dataflow analysisRabin-Karp fingerprintingpytest

Overview

Stopgate started from a want, not a threat model: I wanted to leave Claude Code running and go do something else without clicking 'approve' on every step, but I was genuinely worried about what it might do while I wasn't watching. I'd just finished AICES, which showed the usual way of flagging 'bad' actions (scan the text for scary words) is measuring vocabulary, not intent, so I built the actionable version of that lesson. The pitch isn't safety, it's autonomy. You don't babysit your agent because it's dangerous; you babysit it because you can't tell the difference between it reading a file and it reading your .env and POSTing it somewhere it found in a README. Stopgate can tell the difference, so you can go do something else. It ships as `stopgate report` (read-only, zero-config), a session digest, an auto-learned policy, `stopgate run` (unattended mode), a live Claude Code PreToolUse hook, and `stopgate eval` (the benchmark).

The Problem

Most guardrails you can install today — and my own first attempt — detect vocabulary: they grep for 'rm -rf', 'sudo', 'password'. (Research systems like CaMeL, Invariant, and LlamaFirewall do go after dataflow — but they're not a one-line install you drop onto your Claude Code sessions.) I'd already published the proof that the keyword class of detector is false-confident (AICES: a bag-of-words classifier with no model scores 0.967 on the standard harm benchmark and chance once the attacker rewords). A keyword guardrail has two failure modes that both kill it: it misses encoded attacks, and it cries wolf on every file that merely mentions a scary word. A gate that interrupts you 20 times a day gets turned off by lunch, and then it protects nothing.

My Approach

A lexical action layer plus a non-lexical provenance layer — not 'dataflow instead of keywords,' but both. Stopgate tags every tool result as trusted or untrusted, taints the session when the agent reads untrusted content, and fingerprints every secret it sees using a rolling hash with an entropy gate (so low-entropy config values don't cause false positives). When an outbound payload contains bytes from a secret read earlier — even base64 or hex-encoded — it flags it. You cannot rephrase your way past a hash comparison. The safety-critical invariant lives in the engine, not in policy: money, production, destructive, and any secret leaving the machine ALWAYS stop for a human, and I wrote a test proving that even a maximally-permissive learned policy cannot unblock them. I don't claim this is the first dataflow guardrail — CaMeL (DeepMind), Invariant Labs (Snyk), and Meta's LlamaFirewall got to provenance-based defense before me. What's new here is the open, minimal-pair controlled benchmark for agent actions, and a zero-config build you can install today; the gate is an honest implementation of a known idea.

Technical Implementation

Pure-Python policy engine (deterministic, no I/O), a taint tracker, an action-class detector, and a log parser that reads Claude Code's own session transcript. Two hooks, because one isn't enough: `stopgate hook` (PreToolUse) judges the pending call, but PreToolUse fires *before* the tool runs — so it never sees a result, and the result is where the secret bytes are. `stopgate watch` (PostToolUse) is the only place a secret can be captured at all. Between calls the taint state is persisted per session, so 'read the .env at step 3, POST it at step 19' is caught live — without that, a hook is a fresh process every call and the tracker resets, quietly degrading the tool into the pattern matcher it exists to beat. The state file stores only rolling-hash fingerprints and salted hashes: enough to recognise the bytes leaving, never enough to reconstruct them, so a stolen state file is worthless. `stopgate run` supervises a session: auto-approve in-policy, hard-stop the irreversible, print a receipt. The benchmark (`stopgate eval`) ports my AICES minimal-pair methodology to agent actions: malicious traces paired with benign twins that use the same vocabulary, a keyword baseline to beat, bootstrap confidence intervals, per-example results, and self-checks that make a rigged harness fail loudly. 339 tests, green CI, and it's on PyPI (`pip install stopgate`); MIT; local-first with zero network calls of its own.

Results

On the seed benchmark, Stopgate detects 69% of attacks to the keyword baseline's 62% — but the number that matters is false alarms, where the keyword gate fires 367 times per 1,000 actions versus Stopgate's 133. That 2.75x gap is the whole product: it's the difference between a tool you leave running and one you disable. Stopgate also catches a base64-encoded secret exfil the keyword gate misses entirely. The honest part: the set includes injection-provenance pairs — a benign-looking action an agent runs because untrusted content told it to — where Stopgate ties the keyword baseline at chance. Adding those dropped Stopgate's detection from 0.80 to 0.69; I'd rather publish the blind spot than a flattering number. Stopgate's genuine dataflow edge is secret-egress, not injection-provenance. Small, non-human-audited seed set: indicative, not definitive, and that caveat ships in the tool's own output.

What I Learned

Guardrails don't die from weak detection — they die from false positives, because every false alarm is a moment the user had to come back. That reframes the entire product: precision isn't a metric, it's the reason someone can walk away. I also made myself follow my own AICES lesson: I built the benchmark before believing any result, put the keyword baseline in as the thing to beat, and added self-checks that fail loudly if the harness is rigged. The sharper lesson came later, from actually deploying it: the benchmark passed, the tests passed, and the live gate was still hollow. A PreToolUse hook is a fresh process on every call, so the taint tracker reset each time and the dataflow moat — the entire reason this beats a keyword gate — was inert in the one mode that ships. Pointing it at a real Claude Code session returned '0 actions, all unparseable', because I'd only ever tested my own log format. Passing tests were measuring the code I wrote, not the thing I claimed. Now the control test is the one I trust most: the same exfil command is ALLOWED before the secret is read and HARD-STOPPED after — if that ever collapses, the block was never really about dataflow. And because a security tool that overclaims is worse than none, Stopgate states plainly what it can't do — best-effort, not a guarantee, not a substitute for sandboxing — in the README, a DISCLAIMER, and a warning printed on every run.

Proof of Work

The one number that decides whether a guardrail survives contact with a human: false alarms. Real output from `stopgate eval` — computed, not hardcoded.

the moment it fires — real captured output
STEP 1  the agent tries to POST a secret out — Stopgate has seen nothing yet
        $ echo "$EXFIL" | stopgate hook
        ALLOW — network egress

STEP 2  the agent reads .env (a normal thing to do)
        $ echo "$READ_ENV" | stopgate watch
        (silent — the secret is fingerprinted here)

STEP 3  the EXACT SAME command as step 1 — not one character changed
        $ echo "$EXFIL" | stopgate hook
        ASK — HARD-STOP: network egress
               EGRESS: bytes of secret '/app/.env' from step 1
               are leaving in this payload

        $ grep -c "<the-secret>" ~/.cache/stopgate/*.json
        0        # never on disk — only a fingerprint

The whole thesis in three steps, and the trick is that the command never changes. The agent tries to POST a secret out and it's ALLOWED — Stopgate hasn't seen the secret yet. Then the agent reads .env (normal). Then it runs the EXACT SAME POST — now it's a HARD-STOP. A keyword filter sees identical text both times and waves both through; Stopgate blocks on the dataflow, not the words. And the secret is never written to disk — only a fingerprint. Real terminal output, not a mockup.

how it works — in plain English
1.  Install it:  pip install stopgate

2.  See what your agent has already been doing (read-only, no setup):
        stopgate report

3.  Let it watch live — two hooks in Claude Code's settings. You need BOTH:
        "hooks": {
          "PreToolUse":  [{ "hooks": [{ "command": "stopgate hook"  }] }],
          "PostToolUse": [{ "hooks": [{ "command": "stopgate watch" }] }]
        }

    Why two? "hook" runs BEFORE a tool, so it never sees the result — and the
    result is where a secret actually is. "watch" runs after, and is the only
    place a secret can be caught in the first place. Without it, Stopgate can
    only judge one action at a time and an exfil spread across two steps walks
    straight through.

Now you can walk away. Stopgate allows your normal work and only stops the agent
for the genuinely risky things — and tells you why.

    Modes:   observe = log only, never blocks     (build trust first)
             enforce = a hard-stop asks you       (default; you're at the keyboard)
             enforce --headless = it denies       (nobody's there to answer an "ask")

    Kill switch:  echo observe > ~/.stopgate-mode   (instant, no restart)

Stopgate never writes your secrets to disk — it stores only fingerprints, enough
to recognise the bytes leaving, not enough to rebuild them.

You run an AI coding agent (like Claude Code). Stopgate sits between the agent and your computer and watches every move it makes. Most of the time it stays out of your way. But the moment the agent tries something you'd want to know about — deleting a lot of files, sending one of your secrets out to the internet, spending money — it stops and asks you first. That's the whole idea: it lets you leave the agent running without hovering over it, because it only interrupts for the stuff that actually matters.

stopgate eval
detector    detection [95%CI]        false-alarms/1000 [95%CI]
keyword     0.57 [0.29,0.79]          366.7  [200,533]
stopgate     0.64 [0.36,0.86]          133.3  [ 33,267]
protectai   0.43 [0.21,0.71]          200.0  [ 67,367]

[PASS] stopgate has fewer false alarms than the keyword baseline
[PASS] stopgate catches base64-encoded exfil that the keyword gate misses
[PASS] stopgate detection not worse than keyword
protectai = a REAL published 3rd-party classifier (ProtectAI DeBERTa); it scores
action TEXT not dataflow, which is why it trails stopgate on agent traces.
KNOWN MISS: injection-provenance (a benign-looking act an agent runs because
untrusted content told it to, no secret egressing) + transformed-exfil (a gzip'd secret) both score at chance — stopgate's
real edge is secret-EGRESS, not injection-provenance.

The number that decides whether you can walk away is false alarms — stopgate cries wolf 2.75x less. The set now includes injection-provenance pairs stopgate MISSES (it ties the keyword baseline there); I added them on purpose and its detection sits at 0.64 rather than hide the blind spot. Real output, computed every release.

the moat, in one idea
# read a secret earlier in the session -> fingerprinted (rolling hash + entropy gate)
# later, an outbound payload contains those bytes, base64-wrapped:
tfidf_keyword_scan(payload)   # -> misses it (no scary word)
stopgate_egress_match(payload) # -> BLOCK: a secret is leaving the machine

A keyword gate greps the command text. Stopgate fingerprints every secret it sees and watches for those bytes leaving — even base64-encoded. You can't reword your way past a hash.

This is AICES applied. My paper proved keyword harm-detection is measuring vocabulary; Stopgate is the tool that detects dataflow instead, benchmarked the same confound-controlled way.

See the research it's built on ↗
← All Projects

Rohan Kaila