Hardening PreToolUse Hooks and pre-commit Against User-Content Injection¶
Problem¶
When extending Claude Code's security baseline with custom PreToolUse hooks
(e.g. claudeignore-guard.sh) and tightening pre-commit, five classes of
hardening defect appear together — each one individually benign, collectively
a bypass pipeline.
Root Cause¶
Security hooks sit at a trust boundary. They receive data that looks clean (tool-call payloads, filenames, denial reasons) but contains user-controlled substrings. Naive handling — string interpolation into JSON, line-oriented filename parsing, raw path compare, no caching — lets non-malicious but unusual input bypass the guard or degrade performance.
Five Concrete Patterns¶
1. Path normalization before allowlist match¶
Wrong (SEC-2, MEDIUM):
# BAD — './foo' does not match 'foo' in .claudeignore
if grep -q "^${target}$" "$ignore_file"; then ...
Right:
# Normalize ./, ~/, and absolute-in-project prefixes first
target="${target#./}"
target="${target/#~\//$HOME/}"
target="${target#$project_dir/}"
Attackers (or typos) bypass naive compare via ./.env, ~/project/.env,
or /abs/path/project/.env. Normalize before match.
2. jq -Rn --arg for JSON output containing user strings¶
Wrong (SEC-1, HIGH):
# BAD — crafted reason "}, \"decision\":\"allow\", \"_\":\"" breaks JSON
echo "{\"decision\":\"deny\",\"reason\":\"$reason\"}"
Right:
Any hook emitting JSON from user content must use jq -Rn --arg. String
interpolation is an injection vector — same threat model as SQL injection.
3. NUL-delimited git diff -z in pre-commit¶
Wrong (SEC-4):
# BAD — filenames with spaces, quotes, newlines broken or skipped
git diff --cached --name-only | while read -r file; do ...
Right:
Filenames can legally contain any byte except NUL. Line-oriented parsing
fails on "weird name".env or a\nb.env. Regression test Case 7 in
test-pre-commit.sh pins this.
4. PreToolUse perf: cache keyed on (path, mtime)¶
Wrong (PERF-1, MEDIUM):
# BAD — rebuild temp git repo on every invocation (100ms+)
tmpdir=$(mktemp -d); cd "$tmpdir"; git init -q; ...
Right:
cache_key=$(echo "${project_dir}_$(stat -f%m "$ignore_file")" | shasum | cut -c1-16)
cache_dir="${TMPDIR}/claudeignore-guard-$cache_key"
[[ -d "$cache_dir" ]] || { mkdir -p "$cache_dir"; git init -q "$cache_dir"; ... }
Hook runs on every matching tool call — per-invocation setup is a real
latency tax. (project_dir, ignore_mtime) is the correct invalidation key.
5. Reusable 11-category deny-rule taxonomy¶
For a B2B Claude Code baseline, these 11 categories are the minimum:
| # | Category | Threat |
|---|---|---|
| 1 | network_exfiltration | curl to arbitrary endpoints |
| 2 | credential_file_access | .env, .ssh/, .aws/, cloud creds |
| 3 | destructive_commands | rm -rf /, dd, mkfs |
| 4 | shell_config_modification | .bashrc, .zshrc |
| 5 | untrusted_package_execution | npm publish, registry reconfig |
| 6 | git_safety | git push --force, git reset --hard |
| 7 | code_execution | arbitrary eval, node -e |
| 8 | database_destructive | DROP TABLE, TRUNCATE |
| 9 | infrastructure | terraform destroy, kubectl delete |
| 10 | self_modification | editing ~/.claude/settings.json |
| 11 | agent_governance | editing ~/.claude/CLAUDE.md |
See configs/security/SECURITY-CONFIG.md for authoring conventions
(colon-arg Bash matchers, enumerate-vs-wildcard rule).
Prevention Checklist¶
When writing or reviewing a PreToolUse / pre-commit hook:
- [ ] All path inputs normalized (
./,~/, absolute-in-project) before match - [ ] All JSON output uses
jq -Rn --arg— no"..."interpolation - [ ] All filename iteration uses NUL-delimited
-z+read -r -d '' - [ ] Expensive setup cached by
(input, mtime)with invalidation - [ ] SHA-256 entry added to
configs/security/trusted-hooks.yaml - [ ] Regression test for each of the above before merge
References¶
- Spec:
specs/114-security-baseline-hardening/spec.md - Review findings:
specs/114-security-baseline-hardening/review-triage.md(SEC-1 HIGH, SEC-2/3 MEDIUM, PERF-1 MEDIUM, SEC-4 LOW-MED — all resolved) - Contract:
specs/114-security-baseline-hardening/contracts/claudeignore-guard.contract.md - Config:
configs/security/SECURITY-CONFIG.md