Precision with Runs

The same guard two ways — recall vs precision.

Page one’s token list lowers to Runs, which matches the parsed argv of each command in the line. The other spelling, block_command(r"git\s+stash", ...), keeps a regex over the raw text. That reach is what lets a pattern span a pipe, a redirect, or a flag’s value, and it is also what makes this one block git commit -m "git stash" — a command you actually want to run. Same input, two verdicts, side by side:

from captain_hook import Allow, Block, Event, Input, Runs, hook

hook(
    Event.PreToolUse,
    "BLOCKED: run git stash through a branch, not directly.",
    only_if=[Runs("git", "stash")],
    block=True,
    tests={
        Input(command="git stash"): Block(),
        Input(command="sudo git stash"): Allow(),
        Input(command='sh -c "git stash"'): Block(),
        Input(command="echo git stash"): Allow(),
    },
)

Work through the presets and watch the verdicts split. The table shows what the real engine answers — Pattern is the string spelling, Runs is the token spelling page one used, and the widget runs the Runs version (it declines wrapper payloads honestly; capt-hook test covers those for real):

Command Pattern Runs
git stash blocks blocks
git commit -m "git stash" blocks allows — it’s an argument, not a command
echo "git stash" blocks allows
sudo git stash blocks allows — Runs sees sudo’s argv, not what it wraps
sh -c "git stash" blocks blocks — the parser descends into the payload

The last two rows are the lesson, and they cut in opposite directions. Runs matches structure, so a wrapper like sudo hides the command from it — cover wrappers with their own entry. But a string payload doesn’t hide anything: the parser descends into sh -c and finds the git stash inside. Precision costs you wrappers, not depth.

So pick by shape, not by nerve. Tokens when you mean a command: block_command(["rm", "-rf"], ...) names one, and a line that only quotes the words never trips it. A string when you mean text no argv prefix names — block_command(r"curl.*\|\s*sh", ...) guards a pipeline, which no token list can describe.

Next: stack conditions into one guard.