cfgaudit

Rules / CFG009

CFG009 hook command interpolates a shell variable

Severity: warn (escalates to error at user scope) 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 — and flags any reference to a shell variable: $NAME / ${NAME} (Bourne shells) or %NAME% (Windows cmd.exe).

Hook commands run with the privileges of the Claude Code process and fire automatically on lifecycle events. Whenever part of a hook command is interpolated from a variable, that value may originate from agent output, tool output, or filenames the agent chose — i.e. attacker-influenced content. Crafted input can break out of unquoted arguments or escape quotes when shells re-parse strings, turning the hook into an arbitrary-command-execution sink.

Command substitution ($(...)) and positional parameters ($1, $@, $?) are intentionally not matched by this rule — they are tracked separately (issue #38).

Quoting (#792). A reference that the shell expands as exactly one argument and never parses again is not reported: inside double quotes that hold only the reference and path characters ("$f", "${SRC}.bak", "$DIR/out.txt"), not as the command name, and with nothing in the command that parses text again (eval, sh -c, bash -lc, xargs … sh, source, .). A reference inside single quotes is not expanded by the shell and is not reported either, unless the command parses it again. Everything else still is: an unquoted reference, a reference inside a longer quoted string (jq ".path == \"$f\"", python3 -c "print('$f')", where the receiving program parses it), and a quoted reference in command position ("$CMD" --version runs whatever the variable names).

The finding is warn for project and project-local scope, but escalates to error for a user-global ~/.claude/settings.json — a malicious hook there fires on every project the user opens.

Trigger example

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "notify-send $TOOL_OUTPUT" }
        ]
      }
    ]
  }
}

Safe alternative

Prefer fixed commands. When the hook genuinely needs context, pass it via stdin rather than as a shell-expanded argument:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "/usr/local/bin/notify-from-stdin" }
        ]
      }
    ]
  }
}

If you must interpolate, put the reference in double quotes on its own ("$f"), so the shell passes it as one argument, and make sure the receiving program treats it as data. That form is not reported. Single quotes are not a fix: inside them the shell does not expand the variable at all.

References

Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation