Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,5 @@
"min": 1
}
},
"version": "0.28.4"
"version": "0.28.5"
}
9 changes: 9 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
All notable changes to the `guardrails` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.28.5]

### Fixed

- **Five verdict-owning hooks now consult `HOOK_JQ_FIELDS_NUL` and refuse on a NUL byte** (#2136):
`block-convention-violation`, `block-hook-bypass`, `block-noncanonical-commit`,
`secret-pattern-detection`, and `hardcoded-path-check`. `block-dangerous-git` and
`block-no-verify` already did.

## [0.28.4]

### Fixed
Expand Down
10 changes: 10 additions & 0 deletions plugins/guardrails/hooks/block-convention-violation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ hook::require_jq "PreToolUse" "guardrails-block-convention-violation" "$INPUT"
# visible once per session. The `// "Bash"` default moves to the bash-side
# expansion, matching block-dangerous-git.
hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' '.cwd' || exit 0

# A NUL byte in ANY field read above is fail-CLOSED (#2136): the helper strips NUL
# bytes before matching, so a clean verdict would not reflect the bytes carried.
if ((HOOK_JQ_FIELDS_NUL)); then
echo "BLOCKED: the payload carries a NUL byte, which a command cannot reliably carry." >&2
echo "What a guard can read is not dependably what would run, so this is refused rather than matched." >&2
echo "Fix: reissue the tool call without the embedded NUL." >&2
exit 2
fi

COMMAND="${HOOK_JQ_FIELDS[0]}"
[[ -n "$COMMAND" ]] || exit 0
TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}"
Expand Down
7 changes: 7 additions & 0 deletions plugins/guardrails/hooks/block-convention-violation.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,11 @@ CLAUDE_PROJECT_DIR="$r" CLAUDE_PLUGIN_OPTION_BLOCK_CONVENTION_GATE_ENABLED=false
bash "$HOOK" <<<"$json" >/dev/null 2>&1
assert_exit "kill switch off: violating subject allowed" 0 $?

# --- NUL in payload must fail closed (#2136) ----------------------------------
r="$(newrepo "$TICKET")"
nul_rc=0
CLAUDE_PROJECT_DIR="$r" bash "$HOOK" <<<"$(jq -n --arg d "$r" \
'{tool_name:"Bash",tool_input:{command:("git status" + ([0]|implode))},cwd:$d}')" >/dev/null 2>&1 || nul_rc=$?
assert_exit "NUL in command (blocked)" 2 "$nul_rc"

report
10 changes: 10 additions & 0 deletions plugins/guardrails/hooks/block-hook-bypass.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ hook::require_jq "PreToolUse" "guardrails-block-hook-bypass" "$INPUT"
# session. The `// "Bash"` default moves to the bash-side expansion, matching
# block-dangerous-git.
hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0

# A NUL byte in ANY field read above is fail-CLOSED (#2136): the helper strips NUL
# bytes before matching, so a clean verdict would not reflect the bytes carried.
if ((HOOK_JQ_FIELDS_NUL)); then
echo "BLOCKED: the payload carries a NUL byte, which a command cannot reliably carry." >&2
echo "What a guard can read is not dependably what would run, so this is refused rather than matched." >&2
echo "Fix: reissue the tool call without the embedded NUL." >&2
exit 2
fi

COMMAND="${HOOK_JQ_FIELDS[0]}"
[[ -n "$COMMAND" ]] || exit 0
TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}"
Expand Down
5 changes: 5 additions & 0 deletions plugins/guardrails/hooks/block-hook-bypass.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1223,4 +1223,9 @@ run_pwsh "#2217: PS python -c read-only os.path.normpath (allowed)" \
run_pwsh "#2217: PS python script run, open( in an arg, no -c (allowed)" \
"python build.py --path \"open('x','w')\"" 0

# --- NUL in payload must fail closed (#2136) ----------------------------------
nul_rc=0
bash "$HOOK" <<<"$(jq -n '{tool_name:"Bash",tool_input:{command:("git status" + ([0]|implode))}}')" >/dev/null 2>&1 || nul_rc=$?
assert_exit "NUL in command (blocked)" 2 "$nul_rc"

report
10 changes: 10 additions & 0 deletions plugins/guardrails/hooks/block-noncanonical-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ hook::require_jq "PreToolUse" "guardrails-block-noncanonical-commit" "$INPUT"
# visible once per session. The `// "Bash"` default moves to the bash-side
# expansion, matching block-dangerous-git.
hook::jq_fields "$INPUT" '.tool_input.command' '.cwd' '.tool_name' || exit 0

# A NUL byte in ANY field read above is fail-CLOSED (#2136): the helper strips NUL
# bytes before matching, so a clean verdict would not reflect the bytes carried.
if ((HOOK_JQ_FIELDS_NUL)); then
echo "BLOCKED: the payload carries a NUL byte, which a command cannot reliably carry." >&2
echo "What a guard can read is not dependably what would run, so this is refused rather than matched." >&2
echo "Fix: reissue the tool call without the embedded NUL." >&2
exit 2
fi

COMMAND="${HOOK_JQ_FIELDS[0]}"
[[ -n "$COMMAND" ]] || exit 0
HOOK_CWD="${HOOK_JQ_FIELDS[1]}"
Expand Down
5 changes: 5 additions & 0 deletions plugins/guardrails/hooks/block-noncanonical-commit.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,11 @@ psout=$(bash "$HOOK" <<<"$(pwsh_command_json "$(printf '%s\n%s\n%s' "git commit
assert_contains "PS block message shows the here-string form" "$psout" "'@ | git commit -F -"
assert_absent "PS block message omits the Bash heredoc" "$psout" "<<'EOF'"

# --- NUL in payload must fail closed (#2136) ----------------------------------
nul_rc=0
bash "$HOOK" <<<"$(jq -n '{tool_name:"Bash",tool_input:{command:("git commit -m ok" + ([0]|implode))}}')" >/dev/null 2>&1 || nul_rc=$?
assert_exit "NUL in command (blocked)" 2 "$nul_rc"

echo
echo "passed: $PASS failed: $FAIL"
((FAIL == 0))
10 changes: 10 additions & 0 deletions plugins/guardrails/hooks/hardcoded-path-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ hook::require_jq "PreToolUse" "guardrails-hardcoded-path-check" "$INPUT"
hook::jq_fields "$INPUT" \
'.tool_name' '.tool_input.file_path' \
'.tool_input.content' '.tool_input.new_string' '.tool_input.new_source' || exit 0

# A NUL byte in ANY scanned content field is fail-CLOSED (#2136): stripping joins
# text across the byte, so a clean scan would not reflect the bytes carried.
if ((HOOK_JQ_FIELDS_NUL)); then
echo "BLOCKED: the payload carries a NUL byte in scanned content." >&2
echo "The helper strips NUL bytes before matching, so a clean scan would not reflect the bytes the payload carried." >&2
echo "Fix: reissue the tool call without the embedded NUL." >&2
exit 2
fi

TOOL="${HOOK_JQ_FIELDS[0]}"

case "$TOOL" in
Expand Down
2 changes: 1 addition & 1 deletion plugins/guardrails/hooks/hardcoded-path-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ NUL_PAYLOAD=$(MSYS_NO_PATHCONV=1 jq -nc --arg fp "$FIXTURE" --arg p "$LINUX_HOME
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$NUL_PAYLOAD" 2>&1)
RC=$?
assert_exit "machine path AFTER a NUL byte in content → exit 2" 2 "$RC"
assert_contains "path after NUL → message" "$OUT" "Linux user path"
assert_contains "path after NUL → NUL refusal message" "$OUT" "NUL byte"

# Cross-OS leak: a Linux path inside a .ps1 still fires (only Windows is suppressed).
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$PS1_FIXTURE" "Set-Location ${LINUX_HOME}")" 2>&1)
Expand Down
10 changes: 10 additions & 0 deletions plugins/guardrails/hooks/secret-pattern-detection.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ hook::require_jq "PreToolUse" "guardrails-secret-pattern-detection" "$INPUT"
hook::jq_fields "$INPUT" \
'.tool_name' '.tool_input.file_path' \
'.tool_input.content' '.tool_input.new_string' '.tool_input.new_source' || exit 0

# A NUL byte in ANY scanned content field is fail-CLOSED (#2136): stripping joins
# text across the byte, so a clean scan would not reflect the bytes carried.
if ((HOOK_JQ_FIELDS_NUL)); then
echo "BLOCKED: the payload carries a NUL byte in scanned content." >&2
echo "The helper strips NUL bytes before matching, so a clean scan would not reflect the bytes the payload carried." >&2
echo "Fix: reissue the tool call without the embedded NUL." >&2
exit 2
fi

TOOL="${HOOK_JQ_FIELDS[0]}"

case "$TOOL" in
Expand Down
2 changes: 1 addition & 1 deletion plugins/guardrails/hooks/secret-pattern-detection.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ NUL_PAYLOAD=$(MSYS_NO_PATHCONV=1 jq -nc --arg fp "$FIXTURE" --arg tok "$AWS_TOKE
'{tool_name:"Write",tool_input:{file_path:$fp,content:("harmless first line" + ([0] | implode) + "config = " + $tok)}}')
OUT=$(bash "$HOOK" <<<"$NUL_PAYLOAD" 2>&1); RC=$?
assert_exit "secret AFTER a NUL byte in content → exit 2" 2 "$RC"
assert_contains "secret after NUL → message" "$OUT" "AWS Access Key"
assert_contains "secret after NUL → NUL refusal message" "$OUT" "NUL byte"

# In-project secret still blocks when CLAUDE_PROJECT_DIR is set (file under root).
OUT=$(CLAUDE_PROJECT_DIR="/repo" bash "$HOOK" <<<"$(write_json "/repo/src/config.env" "config = '$AWS_TOKEN'")" 2>&1); RC=$?
Expand Down
Loading