# 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()](../../reference/primitives.md#captain_hook.rewrite_code) edits the call before it reaches disk, and [rewrite_command()](../../reference/primitives.md#captain_hook.rewrite_command) does the same for shell commands. Each matches an [ast-grep](https://ast-grep.github.io) pattern, captures arguments as metavariables, and drops them into a replacement template.

``` python
"""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()](../../reference/primitives.md#captain_hook.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`](../../docs/reference/index.md) 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](../../reference/primitives.md#captain_hook.rewrite_code) hook rewrites:

``` python
os.system("make build")  # os.system() spawns a shell; rewritten to subprocess.run([...], check=True)
```

The command the [rewrite_command](../../reference/primitives.md#captain_hook.rewrite_command) hook rewrites:

``` bash
cat -n pyproject.toml  # rewritten to `bat -n pyproject.toml` for syntax highlighting and line numbers
```


# What it allows

Content and commands with no matching pattern pass straight through:

``` python
subprocess.run(["make", "build"], check=True)  # already the safe form; nothing to match
```

``` bash
ls -la  # not a `cat` invocation; no rewrite
```

*The catch / allow split mirrors the hook inline `tests`, so it stays true as the hook evolves.*


# Run it yourself

``` bash
uvx capt-hook --hooks docs/examples test
```


# See also

- [Primitives guide](../../docs/guide/primitives.md)
- [Reference index](../../docs/reference/index.md)
