Rewriting code structurally
Rewrite unsafe code and command patterns in place — before the edit lands or the command runs — using ast-grep structural matching.
os.system() runs its argument through a shell, so anything interpolated into it is a shell-injection hole that subprocess.run([...], check=True) closes. A warning leaves the fix to the agent. rewrite_code() edits the call before it reaches disk, and rewrite_command() does the same for shell commands. Each matches an ast-grep pattern, captures arguments as metavariables, and drops them into a replacement template.
"""Rewrite unsafe code and command patterns structurally before they reach disk."""
from __future__ import annotations
from captain_hook import Allow, Input, Rewrite, rewrite_code, rewrite_command
rewrite_code(
"os.system($CMD)",
"subprocess.run([$CMD], check=True)",
note="os.system() spawns a shell, so subprocess.run([...], check=True) is the safer call.",
tests={
Input(tool="Edit", file="deploy.py", content='os.system("make build")\n'): Rewrite(),
Input(tool="Edit", file="deploy.py", content='subprocess.run(["make", "build"], check=True)\n'): Allow(),
},
)
rewrite_command(
"cat $$$ARGS",
"bat $$$ARGS",
note="bat renders files with syntax highlighting and line numbers.",
tests={
Input(tool="Bash", command="cat -n pyproject.toml"): Rewrite(pattern="bat -n pyproject.toml"),
Input(tool="Bash", command="ls -la"): Allow(),
},
)rewrite_code() registers a PreToolUse hook, the one stage where the tool input is still editable, and rewrites the new content of an Edit, Write, MultiEdit, or NotebookEdit. $CMD matches the single argument, while $$$ARGS matches a whole argument list with its whitespace preserved. The rewrite stays idempotent: code that already reads subprocess.run(...) has nothing to match, so the hook passes it through. The language comes from the edited file’s extension, and lang= overrides it.
The second hook applies the same idea to shell commands. rewrite_command("cat $$$ARGS", "bat $$$ARGS") reads as a regex rewrite but works structurally, parsing the command with tree-sitter-bash via the ast_grep.rewrite seam. Structural matching ignores a cat that appears as an argument or inside a string, where a regex would not. Watch one footgun: a single metavariable binds only the first argument, so "cat $F" turns cat -n foo.txt into bat -n and drops the file — reach for $$$ARGS to carry the whole list. note rides along as additionalContext so the model sees that its call was rewritten and why.
What it catches
This page composes two rewrite primitives. The edit content the rewrite_code hook rewrites:
os.system("make build") # os.system() spawns a shell; rewritten to subprocess.run([...], check=True)The command the rewrite_command hook rewrites:
cat -n pyproject.toml # rewritten to `bat -n pyproject.toml` for syntax highlighting and line numbersWhat it allows
Content and commands with no matching pattern pass straight through:
subprocess.run(["make", "build"], check=True) # already the safe form; nothing to matchls -la # not a `cat` invocation; no rewriteThe catch / allow split mirrors the hook inline tests, so it stays true as the hook evolves.
Run it yourself
uvx capt-hook --hooks docs/examples test