Rules / CFG015
CFG015 hook command contains shell substitution
Severity: warn (escalates to error if a network call appears inside the substitution)
OWASP: LLM01:2026 – Prompt Injection
What cfgaudit checks
cfgaudit scans every command-bearing key in settings.json — hooks (hooks.*[].hooks[].command) and the credential/runtime helpers Claude Code also executes (apiKeyHelper, awsCredentialExport, awsAuthRefresh, gcpAuthRefresh, otelHeadersHelper, statusLine.command, subagentStatusLine.command, fileSuggestion.command) and each MCP server's headersHelper — for shell-evaluated substitutions:
$( … )— POSIX command substitution` … `— backtick command substitution
The output of a substituted command is spliced into the shell line at runtime. When any input is attacker-controlled (filename Claude chose, tool output, an injected commit message, …), the substitution becomes a command-injection sink.
A finding is emitted at warn when a substitution is present and at error when the substituted command itself reaches the network (curl, wget, nc, ncat, ssh, scp, rsync, ftp, telnet, nslookup, dig, host). Network calls inside a substitution mean the value spliced into the hook is whatever some external endpoint chose to return.
This rule is complementary to:
- CFG009 — flags plain
$VAR/${VAR}interpolation (variable, not substitution). - CFG014 — flags
curl … | bash-style pipelines (substitution piping into an interpreter is the same family, but the surface is different).
Multiple substitutions in one command are consolidated into a single finding; the message lists each substitution string.
Not reported:
$(git rev-parse --show-toplevel)and the othergit rev-parsepath queries, alone in the substitution: a fixed local command with no input.- A substitution that only reads the hook's stdin and is only the value of an assignment:
f=$(jq -r '.tool_input.file_path'),input=$(cat)(#792). That is the pattern the safe alternative below recommends; the output is stored in the variable, not spliced into a command line, and how the variable is then used is CFG009's question.jqmust have no file argument and nothing else may ride along (a pipe,;, a second command), and the same substitution used as an argument elsewhere in the command is still reported.
Trigger examples
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "notify-send \"$(cat /etc/passwd)\"" },
{ "type": "command", "command": "echo `date`" }
]
}
]
}
}
Both fire. The first is warn (no network call); the second is also warn. Replace cat /etc/passwd with curl … and the severity becomes error.
Safe alternative
Hooks should be deterministic and not depend on values evaluated at hook time. If you need dynamic data, read it from stdin, the event JSON Claude Code pipes into the hook, into a variable and pass that in double quotes (f=$(jq -r '.tool_input.file_path'); tool "$f"), or use a program that reads stdin itself; do not let attacker-controlled inputs flow into a $(…) that is spliced into the command:
{
"type": "command",
"command": "/usr/local/bin/notify-from-stdin"
}
References
- OWASP LLM01:2026 – Prompt Injection
- Claude Code docs: Hooks
- CFG009: hook command interpolates a shell variable
- CFG014: hook command downloads and executes remote code
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation