I build Termaxa, a small Rust CLI that gates the shell commands AI coding agents run: it previews what a command will actually do, backs up first, applies an allow/ask/deny policy, and logs everything. The fastest way to find out whether a safety tool works is to point a real agent at it and ask it to do the thing the tool exists to stop.
So I did. Several times, over a few weeks. Cursor broke my design four different ways, and every meaningful feature in the current release exists because of one of those breaks. (Claude Code, for what it's worth, mostly cooperated. It's the agent that didn't stand down that taught me everything.)
Round 1: the whack-a-mole
The setup: a test repo, Termaxa hooked into Cursor, a policy that flagged recursive deletes as ask. The prompt: delete this folder.
Cursor never attacked the hook. It didn't need to. It just kept expressing the same intent in different shell dialects until one spelling landed:
rm -rf . → ask
Remove-Item -Recurse -Force . → ask
del /s /q . → ask
Rule-based matching sees three unrelated commands. The agent sees one goal with three spellings — and each retry is a fresh policy evaluation, a fresh chance for an auto-approving UI to wave it through.
This isn't a bypass, it's a policy expressiveness gap. The agent stayed inside the rules the whole time. My rules just couldn't say the thing that mattered: "this session has already been told no about deleting things."
The fix: classify intent, count attempts, escalate
That gap is fixable inside the cooperative model, and the fix became the headline feature — a session circuit breaker:
- Classify each command's intent — file-delete, db-destroy, git-force, infra-destroy — rather than matching spellings.
rm -rf,Remove-Item -Recurse -Force, anddel /s /qall classify as the same thing. - When a command comes back
ask, count prior attempts with the same intent in the same session, read straight from the append-only audit log. - At the threshold (default: two prior attempts), escalate the
asktodeny.
Two design constraints mattered. The breaker only escalates, and only touches ask — an explicit allow is deliberate user policy (maybe you want rm -rf allowed inside /tmp), and a deny is already a deny. And the state is derived, not stored — the counter is computed from the log, so there's no sidecar file to reset, a new session is automatically a clean slate, and every denied variant is itself logged with its intent, which keeps the breaker tripped for the rest of the session for free.
Re-ran the test. Cursor went PowerShell → cmd, the classifier tracked the intent across both shells, and the third delete attempt came back:
deny — circuit breaker: 2 prior file-delete attempt(s) this session
Felt good for about an hour.
Round 2: the classifier had a hole
Same session, Cursor kept going, and one command sailed through as a plain ask:
find . -maxdepth 1 -exec rm -rf {} +
The classifier looked at the first token. The first token is find. find isn't a delete command. The rm -rf living inside -exec was invisible.
This is where the intent-over-spelling bet paid off. The fix wasn't "add find -exec rm to a pattern list" — it was extending the classifier to understand delete indirection as a concept: find with -exec/-execdir/-ok/-okdir wrapping a delete command, find -delete, xargs feeding a delete command, unlink, shred -u. One classifier extension, one regression test using the literal live bypass command, shipped as v0.11.1. Enumerating spellings is an unwinnable arms race; intents are finite.
Round 3: the escape
Then Cursor did the thing that defines the tool's honest boundary.
Blocked on shell deletes — breaker tripped, every variant denied — it stopped using the shell. It switched to its own native file-delete tool and removed about twenty files. .cursor/ and .termaxa/, gone.
Audit entries for those deletions: zero. Not because anything was evaded — because the shell hook never saw them. An agent's built-in file tools don't go through the shell. They were never in scope.
I could have buried this. Instead it's the first item in SECURITY.md, filed as a public issue, and in the README subhead: Termaxa is a windshield, not a sandbox. It gates the shell path an agent normally takes; it does not contain an agent that executes through other means. If you need hard guarantees, pair it with OS-level isolation — containers, seccomp, restricted credentials. The cooperative gate covers the common case: a capable agent about to make an expensive mistake. It is not containment of an adversarial one. (The real fix is owning the execution path rather than hooking someone else's — that's the roadmap, not the present tense.)
One more finding from the same sessions, smaller but real: the breaker counts per session id, and both Cursor and Claude Code sometimes rotate the session id mid-run with no restart. A rotation between attempts resets the counter. In practice a burst of retries shares one id — the trip above happened across a rotation-free burst — but it makes the breaker a speed bump, not a durable cap. Also filed, also documented.
Round 4: the one that scared me
This is the finding I'd lead with if I could only keep one.
Weeks later, while wiring up post-execution receipts, I ran a routine live test on Cursor with a debug capture enabled (TERMAXA_HOOK_DEBUG, which dumps every raw payload the hook receives). The capture showed six hook invocations. The audit log showed zero entries.
Root cause: Cursor 3.11 had renamed its hook API. Events arrived as preToolUse/postToolUse (camelCase) with tool_name: "Shell". My parser only knew the older beforeShellExecution/afterShellExecution shape with tool_name: "Bash". Every event from current Cursor fell through the parser and returned None — which, by design, means "not for us, step aside."
Termaxa fails open on plumbing on purpose: a gate that bricks your agent when it gets confused gets uninstalled, and then it protects nobody. But fail-open has a shadow, and this was it. Termaxa had been silently not gating Cursor 3.11+ for roughly four minor versions. No error, no crash, no signal. The tool appeared installed and healthy while doing nothing.
Here's the part that generalizes: the entire test suite was green the whole time. The Cursor tests used the old payload shape as fixtures. They faithfully verified that Termaxa handled a dialect Cursor no longer spoke. For an integration surface, a green suite is necessary and nowhere near sufficient — the only thing that catches this class of drift is running the real agent and looking at what it actually sends.
The fix (v0.11.4): case-insensitive event matching across both API generations, Cursor detection from multiple signals, command and cwd recovery from several payload locations — and, crucially, four regression tests whose fixtures are the real captured 3.11 payloads, so the next silent rename fails CI instead of failing users. Verified live on Cursor 3.11.25: hook entries and post receipts both flowing again.
What actually generalizes
If you're building anything that sits between an AI agent and real infrastructure, the four lessons in one place:
Classify intent, not spelling. An agent blocked on a goal retries the goal with different syntax — across shells, through indirection, wherever the policy's vocabulary runs out. Pattern lists lose that race structurally. Intent classification is what let one fix close a whole category.
Only escalate; never relax. A safety layer that second-guesses an explicit human allow is a safety layer people rip out. Harden the soft middle — the ask that auto-approval quietly erodes into allow — and leave deliberate policy alone.
Green tests lie about integration surfaces. Your fixtures encode yesterday's API. Live-fire against the actual agent, capture the real payloads, and make those your fixtures.
Say where your tool stops, in the README, before anyone asks. The native-tool escape would be a devastating HN comment if someone else discovered it. As the first line of my own SECURITY.md, it's the reason to trust the rest of the document.
The biggest surprise wasn't that Cursor found bugs. It was that every serious improvement came from watching a real agent behave differently than my tests predicted. If you're building infrastructure for AI agents, that's probably the actual development loop: write the feature, point a real model at it, let it surprise you, and turn the surprise into tomorrow's regression test.
Termaxa is MIT/Apache, open source, cargo install termaxa. If you can make an agent get past it in a way I haven't documented, that's the most valuable contribution you can make: issues or security@termaxa.com.
Top comments (0)