Inspect and rewrite commands
Every Bash-carrying event gives you the parsed command line as evt.command — a Cmd you can match against, query, walk call by call, and rewrite. evt.cmd is the same object, shorthand. The raw text, when you want the string itself, is evt.command.raw (or str(evt.command)).
Block a command family
block_command reads its first argument by shape. A token list is argv and lowers to Runs, which catches the command anywhere in a && chain or pipeline and never fires on a line that only quotes the words.
from captain_hook import Allow, Block, Input, block_command
block_command(
["git", "stash"],
reason="Stashing loses uncommitted agent work",
hint="Commit to a WIP branch instead",
tests={
Input(command="cd /tmp && git stash"): Block(),
Input(command="git status"): Allow(),
},
)Pass a string when you mean text no argv prefix names — r"git\s+push\s+--force(?!-)" blocks a force-push while allowing --force-with-lease, and r"curl.*\|\s*sh" guards a pipeline. A string compiles to a Command regex over the raw line, which also reaches text the shell parser cannot structure, such as a comment or a malformed line. It reaches quoted mentions too, so scope it accordingly.
One token list keeps the regex: ["rm", "-rf", "*"]. The "*" stands for a word that must be there, and Runs matches an argv prefix, so lowering it would widen the block onto the bare rm -rf it used to let through.
Match the exact program
When false positives break real workflows, match structure instead of text. Runs matches the argv prefix of any parsed command in the line — git commit -m "git stash" and echo git stash don’t fire, because there the words are arguments, not a command:
from captain_hook import Event, Runs, hook
hook(
Event.PreToolUse,
"BLOCKED: Stashing loses uncommitted agent work.",
only_if=[Runs("git", "stash")],
block=True,
)Runs is not wrapper-transparent: sudo git stash is sudo’s argv, so it doesn’t match. That’s the precision you asked for — cover wrappers with an extra entry (Runs("sudo", "git", "stash")) or fall back to the conservative pattern. The tutorial walks this tradeoff live.
Two Command classes exist: captain_hook.types.Command is the regex condition; top-level captain_hook.Command is the parsed-command dataclass re-exported from cc_transcript. Import the condition from captain_hook.types — passing the other one to only_if fails at registration.
Ask questions of the parsed line
For one-off predicates, the query surface on evt.command.q beats hand-rolled string checks:
from captain_hook import Event, on
@on(Event.PreToolUse)
def pin_pytest_runner(evt):
if (q := evt.command.q).runs("pytest") and not q.runs("uv"):
return evt.block("Run the suite via `uv run pytest` so the project venv is used.")
return Noneq.runs, q.has_subcommand, q.contains_token, and q.uses_redirect cover most questions; evt.command.line exposes the full CommandLine (.commands, .primary, .head) when you need the structure itself.
Walk the calls
evt.command.calls(name) yields every invocation of a program across ;, &&, ||, and pipes — each a Call with dequoted args, flags, wrappers, and resolved targets:
for call in evt.command.calls("curl"):
if "-k" in call.flags:
return evt.block("curl -k disables TLS verification — drop the flag or pin a CA.")call.nested tells you the call sits inside a bash -c/eval payload; call.wrappers names the sudo/env/timeout chain in front of it.
Judge the blast radius
call.targets resolves the call’s operands against the working directory, globs included. Each Target carries the predicates a deletion-or-mutation guard needs — is_repo_root, in_repo, contains_repo, is_home, is_scratch — and targets.expand() materializes globs with an explicit exhausted flag when the expansion is too broad to verify. Unverifiable targets should fail closed:
for call in evt.command.calls("rm"):
if call.targets.expand().exhausted:
return evt.block("rm targets too broad to verify — narrow the glob")
return call.sub("rm", "trash", args=call.targets)This is the heart of the shipped general pack’s rm guard — the full example adds recoverability checks and repo-root protection.
Rewrite instead of blocking
A rewrite beats a block when a safe variant exists, because the agent’s task keeps moving. Three tiers:
call.sub(old, new, *, args=None, note=None) swaps one call’s program, re-emitting targets quote-safely — the rm-to-trash line above.
rewrite_command(pattern, replace) applies a regex rewrite to the raw line:
from captain_hook import rewrite_command
rewrite_command(
r"git push --force\b(?!-)",
"git push --force-with-lease",
note="Rewrote to --force-with-lease: refuses to clobber unseen remote work.",
)The conditional form takes to= — a callable receiving the event, returning the replacement line or None — for rewrites that depend on the parsed structure. For per-occurrence rewrites inside one line (every cat in a pipeline, say), use rewrite_command_occurrences — worked example.
React to commands that already ran
Pre-flight isn’t the only hook point. warn_command fires on PostToolUse — after the command ran — and RanCommand matches the session’s command history in a Stop gate’s skip_if:
from captain_hook import RanCommand, Regex, TouchedFile, gate
gate(
"You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
only_if=[TouchedFile("**/*.py")],
skip_if=[RanCommand(Regex(r"\bpytest\b"))],
)RanCommand’s arguments are argv tokens, matched as a leading prefix: wrapper-transparent (sudo/env/timeout are stripped) but launcher-literal, so RanCommand("pytest") does not match uv run pytest. Listing every launcher is what Regex avoids. One RanCommand(Regex(r"\bpytest\b")) covers pytest, uv run pytest and poetry run pytest, where the literal form needs an entry each and silently misses the one you forgot.
Opting in is the Regex call, never the shape of the string, so a literal token holding metacharacters (a.out, c++) keeps matching literally. The trade is that a regex matches command text: it searches each parsed command’s argv join, so a heredoc body cannot match it, but echo pytest can. Where that decides whether a gate stands down, list the literal spellings instead.
Where to go next
- Tutorial: your first block and walk the command — the same surfaces, interactive.
- Command safety example — the full guard with inline tests.
- Reference: Files & Commands — every Cmd/Call/Target member, generated from source.