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 (10)
This is the kind of failure report agent tools need more of. The interesting part is not just that deletion was blocked, but which boundary had to catch the action. A good safety layer should make the failed path inspectable, not just silently refuse it.
Thanks — and agreed, inspectability is half the design. Every denied attempt lands in an append-only audit log with its classified intent, session id, and the reason (termaxa log / termaxa report — the report even counts circuit-breaker trips). That's also how the breaker works: the counter is derived from the log rather than stored separately, so the history literally is the state.
Your "which boundary caught it" framing is the sharp part. The native-tool escape in round 3 is exactly a case where the answer was "none" — the action never crossed a boundary Termaxa owns, so there was nothing to inspect. Zero audit entries was itself the finding.
That append-only audit trail is exactly the right shape. It turns a denied action from a mystery into a training example for the system boundary. The circuit-breaker counts are especially useful because they show whether the tool is preventing rare accidents or fighting the normal workflow.
That distinction — rare-accident prevention vs fighting the workflow — is a genuinely useful lens I hadn't named. In the current design it maps to policy tuning: the starter policy ships bulk deletes as deny (safe-by-default), and the intended move is that you deliberately relax per project where deletes are routine. A high trip rate on one intent is basically the tool telling you "this policy doesn't match how this project works."
You've half-designed a feature here: termaxa report already counts trips, but surfacing trip-rate per intent as an explicit "your policy may be too strict for this repo" signal would close the loop. Filing that. Thanks.
@alexshev Filed: github.com/termaxa/termaxa/issues/4 — thanks again for the framing.
Nice, that is the best possible outcome from a comment thread: the abstract framing became a concrete issue. The next useful step is probably encoding the rare-accident case as a small regression.
Agreed — and it fits the project's existing habit: every live incident in the article ended life as a regression fixture, so the feature should arrive the same way. When #4 gets built, the test will pin both cases: scattered-trips-across-intents stays quiet, concentrated-trips-on-one-intent triggers the hint. Noted it on the issue.
Agreed. Turning the incident into a regression fixture is the part that keeps the lesson from becoming just a story. The best follow-up is not more caution in prose, it is a small test that makes the old failure hard to reintroduce.
This matches what I keep running into with multiple agents in parallel. The scary part is never the exotic failure, it's a boring rm or mv issued with complete confidence because the agent's plan sounded right in its own head. A preview step catches that in a way no summary ever will, since the agent's own description of what it's about to do is usually the thing lying to you. Curious how you're tuning the gate so it doesn't turn into a wall of prompts people click through without reading, that's the failure mode I'd worry about once the tool feels routine.
"The agent's own description of what it's about to do is usually the thing lying to you" — that's a sharper phrasing of the founding problem than mine. The built-in prompt shows intent-as-narrated; the preview shows consequence-as-computed. Those diverge exactly when it matters.
On prompt fatigue — it's the failure mode the design takes most seriously, because ask is the verdict that silently erodes into allow once approving becomes routine. Three things aim at it:
The policy is three-tier, and the goal is a small ask-surface: routine safe commands get explicit allow (no prompt at all), known-catastrophic ones get deny (no prompt — the starter policy ships bulk deletes as deny outright), and ask is reserved for the genuinely ambiguous middle. If you're seeing a wall of prompts, that's the tool telling you to move things out of the middle deliberately, per project.
The circuit breaker is specifically anti-fatigue-exploitation: if an agent retries the same destructive intent with different spellings, the third attempt escalates to deny automatically — so a tired human clicking through variant #3 isn't the last line of defense anymore.
And approved asks don't count toward that threshold (post-execution receipts verify the human actually said yes), so approving legitimate cleanup doesn't push the agent toward a false trip.
What I don't have yet: good telemetry on ask-rates in real long-running use — a reader yesterday suggested surfacing per-intent trip rates as a "your policy is fighting your workflow" signal, which is now filed (issue #4). If you're running parallel agents, your ask-volume data is exactly what I'd want to hear about.