I asked an AI agent to delete a folder my tool was guarding. Here's everything that went wrong.
I asked an AI agent to delete a folder my tool was guarding. Here’s everything that went wrong.
我让一个 AI 智能体删除我工具保护的文件夹,结果出了一堆问题。
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.)
我开发了 Termaxa,这是一个用 Rust 编写的小型命令行工具,用于拦截 AI 编程智能体运行的 Shell 命令:它会预览命令的实际操作、预先备份、执行“允许/询问/拒绝”策略,并记录所有操作。验证安全工具是否有效的最快方法,就是让一个真实的智能体去执行该工具旨在阻止的操作。于是我这么做了,在几周内尝试了好几次。Cursor 以四种不同的方式击破了我的设计,而当前版本中每一个有意义的功能,都是为了修复其中一个漏洞而诞生的。(顺便提一下,Claude Code 大多时候很配合。正是那个不肯罢休的智能体教会了我一切。)
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
设置如下:一个测试仓库,Termaxa 挂载到 Cursor 上,策略是将递归删除标记为“询问”。提示词是:删除此文件夹。Cursor 从未攻击过拦截器,它不需要这样做。它只是不断用不同的 Shell 方言表达相同的意图,直到其中一种写法生效:
rm -rf . → 询问
Remove-Item -Recurse -Force . → 询问
del /s /q . → 询问
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.”
基于规则的匹配将这视为三个不相关的命令。而智能体将其视为一个目标的三种写法——每一次重试都是一次新的策略评估,也是自动批准的 UI 界面放行的一次新机会。这并非绕过,而是策略表达能力的缺失。智能体全程都在规则之内,只是我的规则无法表达关键点:“此会话已经拒绝过删除操作了。”
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 ask to deny.
这个差距在协作模型内是可以修复的,修复方案也成了该版本的主打功能——会话断路器:
- 对每个命令的意图进行分类(文件删除、数据库销毁、Git 强制操作、基础设施销毁),而不是匹配拼写。
rm -rf、Remove-Item -Recurse -Force和del /s /q都被归为同一类。 - 当命令返回“询问”时,直接从只读审计日志中读取同一会话中相同意图的先前尝试次数。
- 达到阈值(默认为两次尝试)时,将“询问”升级为“拒绝”。
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.
有两个设计约束很重要。断路器只进行升级,且只作用于“询问”——明确的“允许”是用户刻意的策略(也许你希望在 /tmp 中允许 rm -rf),而“拒绝”本身已经是拒绝了。此外,状态是推导出来的而非存储的——计数器是从日志中计算得出的,因此没有需要重置的辅助文件,新会话会自动清零,且每个被拒绝的变体本身都会记录其意图,这使得断路器在会话剩余时间内自动保持触发状态。
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.
重新运行测试。Cursor 从 PowerShell 切换到 cmd,分类器跨越两种 Shell 追踪到了意图,第三次删除尝试返回了:拒绝 — 断路器:本会话已有 2 次文件删除尝试。这种感觉持续了大约一个小时。
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.
在同一个会话中,Cursor 继续尝试,其中一个命令以普通的“询问”通过了:find . -maxdepth 1 -exec rm -rf {} +。分类器查看了第一个标记,第一个标记是 find。find 不是删除命令,隐藏在 -exec 内部的 rm -rf 是不可见的。
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.
修复方案不是“将 find -exec rm 添加到模式列表”,而是扩展分类器以理解“删除间接引用”这一概念:包括带有 -exec/-execdir/-ok/-okdir 并包装了删除命令的 find、find -delete、向删除命令输送参数的 xargs、unlink 以及 shred -u。通过一次分类器扩展和一次使用真实绕过命令的回归测试,v0.11.1 版本发布了。枚举拼写是一场无法获胜的军备竞赛;而意图是有限的。
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.
接着,Cursor 做了那件定义该工具诚实边界的事情。在 Shell 删除被拦截后——断路器触发,所有变体都被拒绝——它停止使用 Shell。它切换到其内置的原生文件删除工具,删除了大约二十个文件。.cursor/ 和 .termaxa/ 消失了。这些删除操作的审计条目为零。不是因为被绕过了,而是因为 Shell 钩子从未看到它们。智能体的内置文件工具不经过 Shell,它们从未在拦截范围内。
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.)
我本可以掩盖这一点。但相反,它被列为 SECURITY.md 的第一项,提交为公开 Issue,并写在 README 的副标题中:Termaxa 是挡风玻璃,而不是沙盒。它拦截了智能体通常采用的 Shell 路径;它无法控制通过其他方式执行的智能体。如果你需要硬性保证,请配合操作系统级别的隔离——容器、seccomp、受限凭据。这种协作式拦截涵盖了常见情况:一个有能力的智能体即将犯下代价高昂的错误。它无法遏制对抗性的智能体。(真正的修复方案是掌控执行路径,而不是挂载别人的——这是路线图,而非现状。)
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.
在同一会话中还有一个发现,虽小但真实:断路器按会话 ID 计数,而 Cursor 和 Claude Code 有时会在运行中途更换会话 ID 且不重启。尝试之间的更换会重置计数器。实际上,一连串的重试通常共享一个 ID——上面的触发发生在一次没有更换 ID 的爆发中——但这使得断路器只能算是一个减速带,而不是一个持久的上限。这也已提交并记录在案。
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".
如果只能保留一个发现,我会选择这一个。几周后,在配置执行后回执时,我对 Cursor 运行了一次常规实时测试,并启用了调试捕获(TERMAXA_HOOK_DEBUG,它会转储钩子接收到的每一个原始负载)。捕获显示有六次钩子调用,但审计日志显示零条目。根本原因是:Cursor 3.11 重命名了其钩子 API。事件以 preToolUse/postToolUse(驼峰命名法)的形式到达,且 tool_name 为 "Shell"。而我的解析器只识别旧的 beforeShellExecution/afterShellExecution 格式,且 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.
来自当前 Cursor 的每一个事件都穿过了解析器并返回了 None——按照设计,这意味着“不是我们的任务,靠边站”。Termaxa 特意在管道故障时选择“默认放行”:一个在困惑时会锁死智能体的拦截器会被卸载,那样它就保护不了任何人了。但“默认放行”有其阴暗面,这就是其中之一。