Walk the command

When a regex isn’t enough, walk the parse.

Blocking rm outright is a blunt instrument — agents delete build artifacts all day, legitimately. What you want is a judgment call: how many files does this rm actually hit, and are they recoverable? That’s not a pattern; that’s a walk over the parsed command. evt.command gives you one:

from captain_hook import Event, HookResult, PreToolUseEvent, Tool, on


@on(Event.PreToolUse, only_if=[Tool("Bash")])
def recoverable_rm(evt: PreToolUseEvent) -> HookResult | None:
    for call in evt.command.calls("rm"):
        if call.targets.expand().exhausted:
            return evt.block("rm targets too broad to verify — narrow the glob")
        # rewrite `rm` to `trash`, targets and quoting intact: a mistake becomes a restore
        return call.sub("rm", "trash", args=call.targets)
    return None

This is the heart of the rm guard the shipped general pack runs. calls("rm") finds every rm across &&, ;, and pipes; targets.expand() resolves the arguments — globs included — against the working directory; sub rewrites the call to trash, quoting intact, so a mistake is a restore instead of a loss.

The widget below runs that guard for real: the full pack source, shown read-only, walks a declared virtual filesystem in your browser, and every preset verdict is parity-tested byte-for-byte against the real engine on a materialized copy of the same tree. The Trash toggle decides the recoverable lane: with a trash binary the guard rewrites rm to trash; without one it blocks. A command that needs more world than the demo declares (a brace expansion, a sh -c wrapper, an absolute path outside the tree) gets an honest “outside this demo” card instead of a guess:

A rewrite is a third verdict you haven’t seen yet: not allow, not block — the command runs, but not the command the agent typed. Rewrites beat blocks when a safe variant exists, because the agent’s task keeps moving.

Next: gate the session itself.