From 518fce7677638c6521cdec9a05a5dac0031d8401 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:34:31 -0400 Subject: [PATCH 01/10] fix(guardrails): close PowerShell-tool bypass of Bash-matched guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git/commit guards matched only the Bash tool, so `git commit --no-verify` (and the other guarded forms) ran unblocked through Claude Code's opt-in PowerShell tool, which surfaces its command in the same tool_input.command field — a bypass proven live on Windows (#912 f1). Widen the PreToolUse matchers for block-no-verify, block-noncanonical- commit, block-dangerous-git, block-hook-bypass, and flag-commit-pr-skill- bypass from `Bash` to `Bash|PowerShell`, and add a guardrails-local classifier (lib/powershell/ps-command.sh) that reduces a PowerShell command to a Bash-tokenizer-faithful form or fails closed: - Here-strings are blanked to an inert placeholder, so the canonical PowerShell commit form (a here-string piped to `git commit -F -`) reduces to ` | git commit -F -` and is allowed exactly as the Bash `-F -` form; `git commit -m @'...'@` reduces to `git commit -m ` and is blocked. - A `git commit`/`git push`-shaped PowerShell command carrying a construct the Bash tokenizer cannot faithfully handle (backtick, `--%`, subexpression, script-block grouping, or an unbalanced here-string) is blocked fail-closed rather than waved through. The blanker is over-block- never-under-block on ambiguity, so a trailing pipeline can never be swallowed into the placeholder. - block-hook-bypass gains PowerShell file-write coverage (Set-Content, Add-Content, Out-File, Tee-Object, and content-producer `>`/`>>` redirects), producer-scoped like the Bash detection. - Block messages are shell-agnostic (f3): block-noncanonical-commit shows the here-string form on the PowerShell tool, not a Bash heredoc. Full PowerShell grammar parsing (faithful here-strings beyond the canonical form, backticks, `--%`, subexpressions) and content scanning of PowerShell writes are deferred to the A2b follow-up. Closes #915. Co-Authored-By: Claude Opus 4.8 --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 32 +++ .../guardrails/hooks/block-dangerous-git.sh | 32 ++- .../hooks/block-dangerous-git.test.sh | 17 ++ plugins/guardrails/hooks/block-hook-bypass.sh | 35 ++- .../hooks/block-hook-bypass.test.sh | 26 ++ plugins/guardrails/hooks/block-no-verify.sh | 29 ++- .../guardrails/hooks/block-no-verify.test.sh | 26 ++ .../hooks/block-noncanonical-commit.sh | 44 +++- .../hooks/block-noncanonical-commit.test.sh | 27 ++ .../hooks/flag-commit-pr-skill-bypass.sh | 26 +- .../hooks/flag-commit-pr-skill-bypass.test.sh | 10 + .../hooks/guardrails-test-helpers.sh | 5 + plugins/guardrails/hooks/hooks.json | 4 +- .../guardrails/lib/powershell/ps-command.sh | 233 ++++++++++++++++++ 15 files changed, 526 insertions(+), 22 deletions(-) create mode 100644 plugins/guardrails/lib/powershell/ps-command.sh diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 16b94ed0b..cfd554c86 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.9.8", + "version": "0.9.9", "description": "Eight safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, (advisory) hallucinated CLI flags, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 834260524..1fb4b8468 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,38 @@ 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.9.9] + +### Fixed + +- **Git/commit guards are no longer bypassed via the PowerShell tool.** The + `block-no-verify`, `block-noncanonical-commit`, `block-dangerous-git`, and + `flag-commit-pr-skill-bypass` guards matched only the `Bash` tool, so the same + `git commit --no-verify` ran unblocked through Claude Code's opt-in PowerShell + tool (`CLAUDE_CODE_USE_POWERSHELL_TOOL=1`) — a bypass proven live on Windows. + Their PreToolUse matchers are now `Bash|PowerShell`, and a bundled classifier + (`lib/powershell/ps-command.sh`) reduces a PowerShell command to a + Bash-tokenizer-faithful form or fails closed: the canonical PowerShell commit + form (a here-string piped to `git commit -F -`) is allowed exactly as the Bash + `-F -` form is, while a `git commit`/`git push`-shaped PowerShell command + carrying a construct the guard cannot parse with confidence (backtick, `--%`, + subexpression, script-block grouping, or an unbalanced here-string) is blocked + rather than waved through. +- **`block-hook-bypass` now covers the PowerShell file-write surface.** + `Set-Content`, `Add-Content`, `Out-File`, `Tee-Object`, and content-producer + `>`/`>>` redirects that bypass the Write/Edit hook gate are blocked on the + PowerShell tool, producer-scoped like the Bash detection (a tool's own output + redirect — e.g. `git diff > out.txt` — is still allowed). Scope: this closes + the write-GATE bypass; secret-pattern and hardcoded-path CONTENT scanning of + PowerShell writes remains on the `Write|Edit`-matched guards (deferred). + +### Changed + +- **Guard block messages are shell-agnostic.** `block-noncanonical-commit` shows + the PowerShell here-string form when the call originates from the PowerShell + tool (not a Bash heredoc), and `block-hook-bypass`'s remediation no longer + assumes Bash. + ## [0.9.8] ### Fixed diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index 215bebc29..3405d8654 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -37,6 +37,15 @@ source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" hook::check_enabled "BLOCK_DANGEROUS_GIT" +# Bundled PowerShell-command classifier — the git guards are matched on both the +# Bash and the (opt-in) PowerShell tool, whose command arrives in the same +# tool_input.command field with PowerShell grammar. Resolved under the plugin +# root (CC sets CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract +# tests working when it is unset). +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck source=../lib/powershell/ps-command.sh +source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" + # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry (the block # still fires). Referencing it bare under `set -u` would abort before exit. @@ -61,13 +70,14 @@ INPUT=$(hook::buffer_stdin) || { } COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // "Bash"' 2>/dev/null | tr -d '\r') # Above this length the command is not parsed — a pathologically long command is # assumed to be obfuscation and blocked FAIL-CLOSED (generous cap; real git # commands are well under it). The linear parser keeps normal commands cheap. MAX_COMMAND_LEN=16384 -SUBJECT=$(hook::extract_bash_subject "Bash" "$COMMAND") +SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") # Emit one telemetry envelope: $1 status, $2 form ("" when not blocked). Gated # on the high-res start stamp and the opt-in sink, so the unwired default path @@ -76,8 +86,8 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data - data=$(jq -n --arg subject "$SUBJECT" --arg form "$2" \ - '{tool:"Bash",subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' + data=$(jq -n --arg tool "$TOOL_NAME" --arg subject "$SUBJECT" --arg form "$2" \ + '{tool:$tool,subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' hook::emit_telemetry "block-dangerous-git" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } @@ -710,6 +720,22 @@ if ((${#COMMAND} > MAX_COMMAND_LEN)); then exit 2 fi +# Reduce a PowerShell command to a Bash-tokenizer-faithful form, or fail closed. +# For the Bash tool this is a no-op (COMMAND unchanged). The fail-closed branch +# fires only for `git commit`/`git push`-shaped PowerShell this guard cannot +# parse; other dangerous ops carrying an A2b-deferred PowerShell construct are +# deferred (allowed), not this issue's proven bypass surface. +ps::classify_git_command "$TOOL_NAME" "$COMMAND" +case $? in +2) + ps::print_unparseable_block_message + emit_tel "blocked" "powershell-unparseable" + exit 2 + ;; +1) exit 0 ;; # non-commit/push PowerShell with an A2b-deferred construct +*) COMMAND="$PS_SAFE_COMMAND" ;; +esac + hook::bash_parse_segments "$COMMAND" check_segment emit_tel "ok" "" diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 3353ac188..de8d6dfdc 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -254,4 +254,21 @@ else bad "telemetry: no envelope written on block" fi +# --- PowerShell tool coverage (issue #915) ------------------------------------ +# The guard is matched on Bash|PowerShell. PowerShell-simple dangerous ops are +# caught; push-shaped PowerShell the guard cannot parse fails closed. +run_pwsh() { + local label="$1" command="$2" expected="$3" rc + bash "$HOOK" <<<"$(pwsh_command_json "$command")" >/dev/null 2>&1 + rc=$? + assert_exit "$label" "$expected" "$rc" +} +run_pwsh "PS: git push --force (blocked)" "git push --force" 2 +run_pwsh "PS: git reset --hard (blocked)" "git reset --hard" 2 +run_pwsh "PS: git push --force-with-lease (allowed — safe force)" "git push --force-with-lease" 0 +run_pwsh "PS: git push (plain, allowed)" "git push origin main" 0 +run_pwsh "PS: git status (allowed)" "git status" 0 +run_pwsh "PS: backtick-continued force push (fail-closed block)" \ + "$(printf 'git push `\n --force')" 2 + report diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index f2c28d4d1..5fe14939a 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -35,6 +35,14 @@ source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" hook::check_enabled "BLOCK_HOOK_BYPASS" +# Bundled PowerShell-command classifier — this guard is matched on both the Bash +# and the (opt-in) PowerShell tool. Resolved under the plugin root (CC sets +# CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract tests working +# when it is unset). +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck source=../lib/powershell/ps-command.sh +source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" + # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry (the block # still fires). Referencing it bare under `set -u` would abort before exit. @@ -59,6 +67,7 @@ INPUT=$(hook::buffer_stdin) || { } COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // "Bash"' 2>/dev/null | tr -d '\r') # Privacy-safe telemetry subject: `Bash:` with leading `sudo` / # env-assignment prefixes stripped and the token basenamed. Never the full @@ -75,7 +84,11 @@ bash_subject() { printf 'Bash:%s' "${tok##*/}" } -SUBJECT=$(bash_subject "$COMMAND") +if [[ "$TOOL_NAME" == "Bash" ]]; then + SUBJECT=$(bash_subject "$COMMAND") +else + SUBJECT="$TOOL_NAME" +fi # Emit one telemetry envelope: $1 status, $2 form ("" when not blocked). Gated # on the high-res start stamp and the opt-in sink, so the unwired default path @@ -84,8 +97,8 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data - data=$(jq -n --arg subject "$SUBJECT" --arg form "$2" \ - '{tool:"Bash",subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' + data=$(jq -n --arg tool "$TOOL_NAME" --arg subject "$SUBJECT" --arg form "$2" \ + '{tool:$tool,subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' hook::emit_telemetry "block-hook-bypass" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } @@ -411,11 +424,25 @@ producer_redirect_bypass() { block_bypass() { local form="$1" reason="$2" echo "BLOCKED: $reason" >&2 - echo "Use the Write or Edit tool instead of Bash file-write workarounds." >&2 + echo "Use the Write or Edit tool instead of a shell file-write workaround." >&2 emit_tel "blocked" "$form" exit 2 } +# PowerShell tool: the Bash strip / producer scan below does not model the +# PowerShell write surface. Detect PowerShell file-write forms (Set-Content / +# Add-Content / Out-File / Tee-Object, or a content-producer `>`/`>>` redirect) +# and skip the Bash-specific scans. SCOPE: this closes the write-GATE bypass; +# secret-pattern and hardcoded-path CONTENT scanning of PowerShell writes stays +# on the Write|Edit-matched guards (deferred to A2b). +if [[ "$TOOL_NAME" == "PowerShell" ]]; then + if ps::write_bypass "$COMMAND"; then + block_bypass "powershell-write" "PowerShell file-write cmdlet/redirect bypasses Write/Edit hooks" + fi + emit_tel "ok" "" + exit 0 +fi + # cat > file (allow cat without redirect). EXEC_LC (lowercased stripped form) for # case-insensitive command-token detection. if [[ "$EXEC_LC" =~ $_cat_redir ]]; then diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 18e7df881..c69cd3f66 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -385,4 +385,30 @@ else bad "telemetry: no envelope written on block" fi +# --- PowerShell tool coverage (issue #915) ------------------------------------ +# The guard is matched on Bash|PowerShell. PowerShell file-write forms that +# bypass the Write/Edit gate are blocked; content-producer scoping is preserved +# (a tool's own output redirect is allowed, matching the Bash producer scope). +run_pwsh() { + local label="$1" command="$2" expected="$3" rc + bash "$HOOK" <<<"$(pwsh_command_json "$command")" >/dev/null 2>&1 + rc=$? + assert_exit "$label" "$expected" "$rc" +} +run_pwsh "PS: Set-Content (blocked)" "Set-Content -Path f.txt -Value 'x'" 2 +run_pwsh "PS: Add-Content (blocked)" "Add-Content f.txt 'x'" 2 +run_pwsh "PS: Out-File (blocked)" "'secret' | Out-File creds.txt" 2 +run_pwsh "PS: Tee-Object (blocked)" "'x' | Tee-Object f.txt" 2 +run_pwsh "PS: string > file (blocked)" "'content' > file.txt" 2 +run_pwsh "PS: echo > file (blocked)" "echo hi > out.txt" 2 +run_pwsh "PS: tool output > file (allowed — producer is the tool)" "git diff > out.txt" 0 +run_pwsh "PS: redirect to \$null (allowed — discard)" "git log > \$null" 0 +run_pwsh "PS: Set-Content mentioned in quoted arg (allowed)" "echo 'run Set-Content later'" 0 +run_pwsh "PS: plain git status (allowed)" "git status" 0 + +# The block message is shell-agnostic (no 'Bash' assumption). +psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) +assert_contains "PS write block names Write/Edit" "$psout" "Write or Edit tool" +assert_absent "PS write block message is shell-agnostic" "$psout" "Bash file-write" + report diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index d18abaabf..fe1b48421 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -39,6 +39,15 @@ source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" hook::check_enabled "BLOCK_NO_VERIFY" +# Bundled PowerShell-command classifier — the git guards are matched on both the +# Bash and the (opt-in) PowerShell tool, whose command arrives in the same +# tool_input.command field with PowerShell grammar. Resolved under the plugin +# root (CC sets CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract +# tests working when it is unset). +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck source=../lib/powershell/ps-command.sh +source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" + # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry (the block # still fires). Referencing it bare under `set -u` would abort before exit. @@ -63,13 +72,14 @@ INPUT=$(hook::buffer_stdin) || { } COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // "Bash"' 2>/dev/null | tr -d '\r') # Above this length the command is not parsed — a pathologically long command is # assumed to be obfuscation and blocked FAIL-CLOSED (generous cap; real git # commands are well under it). The linear parser keeps normal commands cheap. MAX_COMMAND_LEN=16384 -SUBJECT=$(hook::extract_bash_subject "Bash" "$COMMAND") +SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") # Hook-manager env-var disable prefixes, built once into a regex alternation. # The default set covers the common managers; a consumer extends it via the @@ -94,8 +104,8 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data - data=$(jq -n --arg subject "$SUBJECT" --arg form "$2" \ - '{tool:"Bash",subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' + data=$(jq -n --arg tool "$TOOL_NAME" --arg subject "$SUBJECT" --arg form "$2" \ + '{tool:$tool,subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' hook::emit_telemetry "block-no-verify" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } @@ -213,6 +223,19 @@ if ((${#COMMAND} > MAX_COMMAND_LEN)); then "Shorten the command, or set the guardrails block_no_verify_enabled option to false (/plugin configure) to bypass." fi +# Reduce a PowerShell command to a Bash-tokenizer-faithful form, or fail closed. +# For the Bash tool this is a no-op (COMMAND unchanged). +ps::classify_git_command "$TOOL_NAME" "$COMMAND" +case $? in +2) + ps::print_unparseable_block_message + emit_tel "blocked" "powershell-unparseable" + exit 2 + ;; +1) exit 0 ;; # non-commit PowerShell with an A2b-deferred construct — not this guard's proven surface +*) COMMAND="$PS_SAFE_COMMAND" ;; +esac + hook::bash_parse_segments "$COMMAND" check_segment emit_tel "ok" "" diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index 6320186a7..2215e5f18 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -208,4 +208,30 @@ else bad "telemetry: no envelope written on block" fi +# --- PowerShell tool coverage (issue #915) ------------------------------------ +# The guard is matched on Bash|PowerShell. The proven bypass must be caught on +# the PowerShell tool; the canonical PowerShell commit form must be allowed; and +# commit/push-shaped PowerShell the guard cannot parse must fail closed. +run_pwsh() { + local label="$1" command="$2" expected="$3" rc + bash "$HOOK" <<<"$(pwsh_command_json "$command")" >/dev/null 2>&1 + rc=$? + assert_exit "$label" "$expected" "$rc" +} +run_pwsh "PS: git commit --no-verify (blocked — the proven bypass)" "git commit --no-verify -m x" 2 +run_pwsh "PS: git commit -n (blocked)" "git commit -n -m x" 2 +run_pwsh "PS: git push --no-verify (blocked)" "git push --no-verify" 2 +run_pwsh "PS: canonical here-string | git commit -F - (allowed)" \ + "$(printf '%s\n%s\n%s' "@'" "fix: x" "'@ | git commit -F -")" 0 +run_pwsh "PS: git commit -m here-string (allowed here — noncanonical's concern, no bypass)" \ + "$(printf '%s\n%s\n%s' "git commit -m @'" "msg" "'@")" 0 +run_pwsh "PS: git status (allowed)" "git status" 0 +run_pwsh "PS: backtick-continued commit (fail-closed block)" \ + "$(printf 'git commit `\n --no-verify')" 2 +run_pwsh "PS: unbalanced here-string hiding --no-verify (fail-closed block)" \ + "$(printf '%s\n%s\n%s' "@'" "body" "'X | git commit --no-verify")" 2 +run_pwsh "PS: brace-grouped commit --no-verify (fail-closed block)" \ + "& { git commit --no-verify }" 2 +run_pwsh "PS: LEFTHOOK=0 git commit (env bypass, blocked)" "LEFTHOOK=0 git commit -m x" 2 + report diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index d4f87f91d..e134e9b69 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -62,6 +62,15 @@ source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" hook::check_enabled "BLOCK_NONCANONICAL_COMMIT" +# Bundled PowerShell-command classifier — the git guards are matched on both the +# Bash and the (opt-in) PowerShell tool, whose command arrives in the same +# tool_input.command field with PowerShell grammar. Resolved under the plugin +# root (CC sets CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract +# tests working when it is unset). +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck source=../lib/powershell/ps-command.sh +source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" + # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry (the block # still fires). Referencing it bare under `set -u` would abort before exit. @@ -86,15 +95,16 @@ INPUT=$(hook::buffer_stdin) || { COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 HOOK_CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null | tr -d '\r') +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // "Bash"' 2>/dev/null | tr -d '\r') -SUBJECT=$(hook::extract_bash_subject "Bash" "$COMMAND") +SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data - data=$(jq -n --arg subject "$SUBJECT" --arg form "$2" \ - '{tool:"Bash",subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' + data=$(jq -n --arg tool "$TOOL_NAME" --arg subject "$SUBJECT" --arg form "$2" \ + '{tool:$tool,subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' hook::emit_telemetry "block-noncanonical-commit" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } @@ -307,15 +317,37 @@ check_segment() { echo "BLOCKED: \`git commit\` without \`-F -\` — the message must be piped via stdin." >&2 echo "Use the /commit skill (source-control plugin), or its canonical form directly:" >&2 - echo " git commit -F - --cleanup=verbatim <<'EOF'" >&2 - echo " " >&2 - echo " EOF" >&2 + if [[ "$TOOL_NAME" == "PowerShell" ]]; then + echo " @'" >&2 + echo " " >&2 + echo " '@ | git commit -F -" >&2 + else + echo " git commit -F - --cleanup=verbatim <<'EOF'" >&2 + echo " " >&2 + echo " EOF" >&2 + fi echo "A \`-m\` message flattens newlines unpredictably across shells. --amend, -C/-c," >&2 echo "--fixup/--squash, -F , and an in-progress merge/rebase are exempt." >&2 emit_tel "blocked" "message-flag" exit 2 } +# Reduce a PowerShell command to a Bash-tokenizer-faithful form, or fail closed. +# For the Bash tool this is a no-op (COMMAND unchanged). The canonical PowerShell +# commit form (a here-string piped to `git commit -F -`) reduces to +# ` | git commit -F -`, which the parser below recognizes as the +# stdin form and allows. +ps::classify_git_command "$TOOL_NAME" "$COMMAND" +case $? in +2) + ps::print_unparseable_block_message + emit_tel "blocked" "powershell-unparseable" + exit 2 + ;; +1) exit 0 ;; # non-commit PowerShell with an A2b-deferred construct +*) COMMAND="$PS_SAFE_COMMAND" ;; +esac + hook::bash_parse_segments "$COMMAND" check_segment emit_tel "ok" "" diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index f3166e758..ec130c037 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -213,6 +213,33 @@ out=$(bash "$HOOK" <<<"$(command_json "git commit -m 'feat: x'")" 2>&1) assert_contains "block message names -F -" "$out" '-F -' assert_contains "block message names the skill" "$out" '/commit' +# --- PowerShell tool coverage (issue #915) ------------------------------------ +# The canonical PowerShell commit form (a here-string piped to `git commit -F -`) +# must be allowed exactly as the Bash `-F -` form is; a `-m` PowerShell commit +# must be blocked; commit-shaped PowerShell the guard cannot parse fails closed. +run_pwsh() { + local label="$1" command="$2" expected="$3" rc + bash "$HOOK" <<<"$(pwsh_command_json "$command")" >/dev/null 2>&1 + rc=$? + assert_exit "$label" "$expected" "$rc" +} +run_pwsh "PS: canonical here-string | git commit -F - (allowed)" \ + "$(printf '%s\n%s\n%s' "@'" "feat: x" "'@ | git commit -F -")" 0 +run_pwsh "PS: git commit -m here-string (blocked — not the stdin form)" \ + "$(printf '%s\n%s\n%s' "git commit -m @'" "feat: x" "'@")" 2 +run_pwsh "PS: git commit -m literal (blocked)" "git commit -m 'feat: x'" 2 +run_pwsh "PS: git commit --amend (allowed — exempt)" "git commit --amend" 0 +run_pwsh "PS: git status (allowed — not a commit)" "git status" 0 +run_pwsh "PS: backtick-continued commit (fail-closed block)" \ + "$(printf 'git commit -m x `\n --cleanup=verbatim')" 2 +run_pwsh "PS: unbalanced here-string hiding a -m commit (fail-closed block)" \ + "$(printf '%s\n%s\n%s' "@'" "body" "'X ; git commit -m sneaky")" 2 + +# The PowerShell block message shows the here-string form, not a Bash heredoc. +psout=$(bash "$HOOK" <<<"$(pwsh_command_json "git commit -m 'x'")" 2>&1) +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'" + echo echo "passed: $PASS failed: $FAIL" ((FAIL == 0)) diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index fe1ddffc6..2661c0048 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -49,6 +49,14 @@ source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" hook::check_enabled "FLAG_COMMIT_PR_SKILL_BYPASS" +# Bundled PowerShell-command classifier — this guard is matched on both the Bash +# and the (opt-in) PowerShell tool. Resolved under the plugin root (CC sets +# CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract tests working +# when it is unset). +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck source=../lib/powershell/ps-command.sh +source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" + # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry. start=${EPOCHREALTIME:-} @@ -66,6 +74,14 @@ fi INPUT=$(hook::buffer_stdin) || exit 0 COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // "Bash"' 2>/dev/null | tr -d '\r') +# On the PowerShell tool, neutralize here-strings first so a `gh pr create` +# mention inside message text is inert and a real invocation after a here-string +# is still seen. Advisory-only: never blocks, so best-effort is proportionate. +if [[ "$TOOL_NAME" == "PowerShell" ]]; then + ps::blank_herestrings "$COMMAND" + COMMAND="$PS_BLANKED" +fi # Privacy-safe telemetry subject: `Bash:` with leading `sudo` / # env-assignment prefixes stripped and the token basenamed. Never the full @@ -82,7 +98,11 @@ bash_subject() { printf 'Bash:%s' "${tok##*/}" } -SUBJECT=$(bash_subject "$COMMAND") +if [[ "$TOOL_NAME" == "Bash" ]]; then + SUBJECT=$(bash_subject "$COMMAND") +else + SUBJECT="$TOOL_NAME" +fi # Emit one telemetry envelope per run. Advisory guards always report status # "ok" (they never block); the finding signal rides in `data.forms` — category @@ -96,8 +116,8 @@ emit_tel() { forms_json=$(printf '%s\n' "${FORMS[@]}" | jq -R . | jq -s . 2>/dev/null) || forms_json="[]" fi local data - data=$(jq -n --arg subject "$SUBJECT" --argjson forms "$forms_json" \ - '{tool:"Bash",subject:$subject,forms:$forms}' 2>/dev/null) || + data=$(jq -n --arg tool "$TOOL_NAME" --arg subject "$SUBJECT" --argjson forms "$forms_json" \ + '{tool:$tool,subject:$subject,forms:$forms}' 2>/dev/null) || data='{"tool":"Bash","subject":"","forms":[]}' hook::emit_telemetry "flag-commit-pr-skill-bypass" "PreToolUse" "ok" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh index 1ef88e912..bfe1d136c 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh @@ -188,4 +188,14 @@ else bad "telemetry: no envelope written on advisory fire" fi +# --- PowerShell tool coverage (issue #915) ------------------------------------ +# The advisory is matched on Bash|PowerShell. A direct `gh pr create` on the +# PowerShell tool still fires; the same text quarantined inside a here-string +# body is neutralized (blanked) and stays silent. +out=$(run_hook "$(pwsh_command_json 'gh pr create --title x --body y')" "$ENABLED_PROJECT") +assert_contains "PS: gh pr create fires" "$out" "gh pr create" + +out=$(run_hook "$(pwsh_command_json "$(printf '%s\n%s\n%s' "@'" "gh pr create in a message body" "'@ | git commit -F -")")" "$ENABLED_PROJECT") +assert_silent "PS: gh pr create inside a here-string body stays silent" "$out" + report diff --git a/plugins/guardrails/hooks/guardrails-test-helpers.sh b/plugins/guardrails/hooks/guardrails-test-helpers.sh index 75eb1c970..78411f72a 100644 --- a/plugins/guardrails/hooks/guardrails-test-helpers.sh +++ b/plugins/guardrails/hooks/guardrails-test-helpers.sh @@ -58,6 +58,11 @@ other_tool_json() { command_json() { jq -n --arg cmd "$1" '{tool_name:"Bash",tool_input:{command:$cmd}}' } +# PreToolUse payload for the opt-in PowerShell tool: same tool_input.command +# field as Bash, distinguished by tool_name so dispatch keys on the tool. +pwsh_command_json() { + jq -n --arg cmd "$1" '{tool_name:"PowerShell",tool_input:{command:$cmd}}' +} # make_sink -> path to an executable single-command stub sink running # (which reads the telemetry envelope on stdin). HOOK_TELEMETRY_SINK diff --git a/plugins/guardrails/hooks/hooks.json b/plugins/guardrails/hooks/hooks.json index 6f6effd45..fc92235df 100644 --- a/plugins/guardrails/hooks/hooks.json +++ b/plugins/guardrails/hooks/hooks.json @@ -17,7 +17,7 @@ ] }, { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", @@ -42,7 +42,7 @@ ] }, { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh new file mode 100644 index 000000000..c3cd2ebd0 --- /dev/null +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -0,0 +1,233 @@ +# shellcheck shell=bash +# PowerShell command classification for guardrails git/commit/write guards. +# +# WHY THIS EXISTS: the git/commit/write guards parse the tool command with a +# Bash-grammar-faithful tokenizer (hook-utils.sh). Claude Code's opt-in +# PowerShell tool (CLAUDE_CODE_USE_POWERSHELL_TOOL=1) surfaces its command in the +# SAME `tool_input.command` field but with PowerShell grammar, so a naive widen +# of the PreToolUse matcher to `Bash|PowerShell` would feed PowerShell text to a +# Bash tokenizer. This library bridges that gap for the CORE, fail-closed scope +# of issue #915; faithful parsing of the full PowerShell grammar (here-strings +# beyond the canonical commit form, backticks, `--%`, subexpressions) is the +# deferred follow-up A2b. +# +# STRATEGY (git/commit guards): reduce a PowerShell command to a form the Bash +# tokenizer handles faithfully, or fail closed. +# 1. Blank properly-delimited here-strings (@'...'@ / @"..."@) to an inert +# placeholder. This both neutralizes the one construct the canonical commit +# form relies on AND makes the canonical form parse naturally: the +# here-string-pipe equivalent of `git commit -F -` +# @' +# +# '@ | git commit -F - +# reduces to ` | git commit -F -`, which the Bash parser reads +# as a pipeline whose second segment is `git commit -F -` (stdin form) — +# allowed exactly as the Bash canonical form is. +# 2. On the here-string-blanked, quote-stripped text, detect the PowerShell +# constructs the Bash tokenizer cannot faithfully handle (backtick, +# `--%`, subexpression `$(`/`@(`, script-block/grouping `{`/`}`) and any +# unbalanced here-string. Quoted spans are stripped first so a construct +# that lives inside commit-message text does not count. +# 3. If such a construct is present AND the command is `git commit`/`git push` +# shaped, BLOCK fail-closed — the guard cannot confidently parse it. If it +# is not commit/push shaped, defer to A2b (allow; not this issue's proven +# bypass surface). Otherwise the reduced command is Bash-tokenizer-faithful +# and is handed to the existing parser. +# +# OVER-BLOCK, NEVER UNDER-BLOCK is the invariant for the blanker: an ambiguous +# here-string extent is treated as unbalanced (unsafe) rather than blanked, so a +# trailing `| git commit --no-verify` can never be swallowed into the inert +# placeholder and thereby escape detection. +# +# RESIDUAL (documented, deferred to A2b): backtick-escaped quotes and doubled +# `""` inside a double-quoted string diverge between Bash and PowerShell +# tokenization; here they only ever cause an over-block (fail-closed), never an +# under-block. Shell variable / command substitution is not evaluated (same +# residual the Bash guards carry). + +# Guard against double-sourcing. +[[ -n "${_GUARDRAILS_PS_COMMAND_LOADED:-}" ]] && return 0 +_GUARDRAILS_PS_COMMAND_LOADED=1 + +# Inert bareword substituted for a blanked here-string body. It occupies a single +# argv word in the reduced command so the Bash tokenizer treats a blanked +# here-string exactly as the option value or pipeline input it was. +readonly PS_HERESTRING_PLACEHOLDER="__GUARDRAILS_PS_HERESTRING__" + +# Set by ps::blank_herestrings. +PS_BLANKED="" +PS_HERESTRING_UNBALANCED=0 +# Set by ps::classify_git_command — the command the caller should parse. Read by +# the sourcing guard, not within this library. +# shellcheck disable=SC2034 +PS_SAFE_COMMAND="" + +# Blank properly-delimited PowerShell here-strings to PS_HERESTRING_PLACEHOLDER. +# PowerShell here-string rules (about_Quoting_Rules): the opener `@'`/`@"` is the +# last token on its line (followed by a newline); the closer `'@`/`"@` is at the +# start of a line (column zero). Text after the closer on the same line (the +# `| git commit -F -` of the canonical form) is preserved. +# +# Sets PS_BLANKED (the reduced command) and PS_HERESTRING_UNBALANCED (1 when an +# opener has no column-zero closer — the extent is ambiguous, so PS_BLANKED is +# left as the original command and the caller fails closed). +ps::blank_herestrings() { + local cmd="$1" + local line out="" pending="" in_hs=0 hs_quote="" first2 rest closer + PS_HERESTRING_UNBALANCED=0 + + while IFS= read -r line || [[ -n "$line" ]]; do + if ((in_hs)); then + first2="${line:0:2}" + closer="${hs_quote}@" # '@ or "@ + if [[ "$first2" == "$closer" ]]; then + # Column-zero closer. Keep the text after the two-char closer on the same + # logical line as the opener's prefix + placeholder. + rest="${line:2}" + out+="${pending}${rest}"$'\n' + pending="" + in_hs=0 + hs_quote="" + fi + # A body line (no column-zero closer) is dropped. + continue + fi + # An opener is `@'` or `@"` as the final two characters of the line. + if [[ "$line" == *"@'" || "$line" == *'@"' ]]; then + hs_quote="${line: -1}" # ' or " + pending="${line%??}${PS_HERESTRING_PLACEHOLDER}" + in_hs=1 + continue + fi + out+="${line}"$'\n' + done <<<"$cmd" + + if ((in_hs)); then + # Opener with no column-zero closer: ambiguous extent. Blanking to end could + # swallow a trailing command (e.g. `| git commit --no-verify`) into the inert + # placeholder, so surface the raw command and flag unbalanced instead. + PS_HERESTRING_UNBALANCED=1 + PS_BLANKED="$cmd" + return 0 + fi + PS_BLANKED="${out%$'\n'}" +} + +# Crude, SCAN-ONLY strip of single- and double-quoted spans, so that structural +# detection and commit/push shaping ignore characters inside message text. Never +# fed to a parser. Backtick-escaped quotes are not honored — a backtick anywhere +# already forces the fail-closed branch, so this crudeness cannot open a gap. +ps::blank_quoted_spans() { + local text="$1" + text=$(printf '%s' "$text" | sed "s/'[^']*'//g") + text=$(printf '%s' "$text" | sed -E 's/"[^"]*"//g') + printf '%s' "$text" +} + +# True (0) when the (quote-stripped) text carries a PowerShell construct the Bash +# tokenizer cannot faithfully handle. These are exactly the constructs deferred +# to A2b; their presence on a commit/push-shaped command forces a fail-closed +# block rather than a best-effort Bash parse. +ps::has_special_constructs() { + local scan="$1" + # The single-quoted needles are literal glob patterns, not expansions. + # shellcheck disable=SC2016 + case "$scan" in + *'`'*) return 0 ;; # backtick: escape / line continuation + *'--%'*) return 0 ;; # stop-parsing token + *'$('*) return 0 ;; # subexpression + *'@('*) return 0 ;; # array subexpression + *'{'* | *'}'*) return 0 ;; # script block / hashtable grouping + *) return 1 ;; + esac +} + +# True (0) when the (quote-stripped) text is `git commit`/`git push` shaped: a +# `git` (optionally `git.exe`) command word and a `commit` or `push` word. Coarse +# and deliberately generous — it only gates the fail-closed branch, so +# over-inclusiveness costs at most an over-block on a command that also carries an +# unparseable construct. +ps::is_commit_or_push_shaped() { + local lc="${1,,}" + [[ "$lc" =~ (^|[^[:alnum:]_.])git([.]exe)?([^[:alnum:]_]|$) ]] || return 1 + [[ "$lc" =~ (^|[^[:alnum:]_-])(commit|push)([^[:alnum:]_-]|$) ]] || return 1 + return 0 +} + +# Classify a git/commit-guard command for the resolved tool. Sets PS_SAFE_COMMAND +# (the command the caller should hand to its Bash parser) and returns: +# 0 proceed — parse PS_SAFE_COMMAND (== the original command for the Bash tool) +# 1 allow/skip — a non-commit PowerShell command with a construct deferred to +# A2b; the guard's concern is not confidently present, so do not block +# 2 block fail-closed — commit/push shaped but not confidently parseable +ps::classify_git_command() { + local tool="$1" cmd="$2" scan + PS_SAFE_COMMAND="$cmd" + [[ "$tool" == "PowerShell" ]] || return 0 + + ps::blank_herestrings "$cmd" + scan=$(ps::blank_quoted_spans "$PS_BLANKED") + if ((PS_HERESTRING_UNBALANCED)) || ps::has_special_constructs "$scan"; then + ps::is_commit_or_push_shaped "$scan" && return 2 + return 1 + fi + # Read by the sourcing guard, not within this library. + # shellcheck disable=SC2034 + PS_SAFE_COMMAND="$PS_BLANKED" + return 0 +} + +# Shell-agnostic block text for a PowerShell commit/push the guard cannot parse +# with confidence. Printed to stderr by the caller before it exits 2. +ps::print_unparseable_block_message() { + echo "BLOCKED: this PowerShell 'git commit'/'git push' cannot be parsed with confidence — blocked (fail-closed)." >&2 + echo "Use the canonical PowerShell commit form (a here-string piped to 'git commit -F -'):" >&2 + echo " @'" >&2 + echo " " >&2 + echo " '@ | git commit -F -" >&2 + echo "or run the commit via the Bash tool (the /commit skill's canonical form)." >&2 +} + +# True (0) when a PowerShell command authors file content in a way that bypasses +# the Write/Edit hook gate: a content-authoring cmdlet (Set-Content, Add-Content, +# Out-File, Tee-Object), or a stdout redirect (`>`/`>>`, not the `$null` discard) +# whose producer is a content emitter (echo / Write-Output / Write-Host or a bare +# string / here-string literal). Producer-scoped to match the Bash guard, which +# allows ` ... > out` (the producer is the tool, not a content author). +# +# SCOPE: this covers the write-GATE bypass only. Secret-pattern and hardcoded-path +# CONTENT scanning of PowerShell writes stays on the Write|Edit-matched guards; +# scanning PowerShell write content is deferred to A2b. +ps::write_bypass() { + local cmd="$1" scan lcs seg lc head + ps::blank_herestrings "$cmd" + scan=$(ps::blank_quoted_spans "$PS_BLANKED") + lcs="${scan,,}" + + # Content-authoring cmdlets are a write by nature. Detected on the quote-stripped + # text so a cmdlet named inside message text is inert. + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(set-content|add-content|out-file|tee-object)([[:space:]]|$) ]]; then + return 0 + fi + + # Producer-scoped redirect. Split the quote-stripped text into pipeline / + # statement segments; a stripped leading string literal leaves the segment + # starting at its `>`, which is itself the content-emitter signal. + local norm="${lcs//[|;&]/$'\n'}" + while IFS= read -r seg; do + seg="${seg#"${seg%%[![:space:]]*}"}" # ltrim + [[ "$seg" == *'>'* ]] || continue + # Exclude the `$null` discard (PowerShell's /dev/null). + [[ "$seg" =~ \>\>?[[:space:]]*\$null([[:space:]]|$) ]] && continue + head="${seg%%[[:space:]]*}" + case "$seg" in + '>'*) return 0 ;; # leading literal (string stripped away) was the producer + *) ;; + esac + case "$head" in + echo | write-output | write-host | "${PS_HERESTRING_PLACEHOLDER,,}") return 0 ;; + *) ;; + esac + done <<<"$norm" + return 1 +} From 83015870232060e84606527f87fc36945e029bd1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:26:31 -0400 Subject: [PATCH 02/10] fix(guardrails): fail closed on any git-shaped unparseable PowerShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block-dangerous-git owns destructive non-commit forms (reset --hard, clean -fd, checkout/restore), but the PowerShell classifier deferred (allowed) every non-commit/push command it could not parse. An unparseable git-shaped PowerShell command such as `git --% reset --hard` or a backtick-continued `git reset --hard` therefore ran unblocked — a fail-open in a security guard. Add a `git` danger-shape to ps::classify_git_command (via ps::is_git_shaped) and point block-dangerous-git at it, so the guard fails closed on ANY git-shaped PowerShell it cannot faithfully tokenize, not only commit/push. Non-git unparseable PowerShell stays this guard's non-concern (allowed). A dedicated ps::print_unparseable_git_block_message replaces the commit/push-worded message on this guard's block path. The commit/push guards keep the default shape and are unchanged. --- plugins/guardrails/CHANGELOG.md | 6 ++- .../guardrails/hooks/block-dangerous-git.sh | 15 +++---- .../hooks/block-dangerous-git.test.sh | 20 ++++++++++ .../guardrails/lib/powershell/ps-command.sh | 40 +++++++++++++++---- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 1fb4b8468..dbbc2ed9d 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -19,7 +19,11 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `-F -` form is, while a `git commit`/`git push`-shaped PowerShell command carrying a construct the guard cannot parse with confidence (backtick, `--%`, subexpression, script-block grouping, or an unbalanced here-string) is blocked - rather than waved through. + rather than waved through. `block-dangerous-git` also owns destructive + non-commit forms (`reset --hard`, `clean -fd`, `checkout`/`restore`), so its + fail-closed net is wider: it blocks ANY git-shaped PowerShell it cannot parse, + not only commit/push — an unparseable `git --% reset --hard` cannot slip + through. - **`block-hook-bypass` now covers the PowerShell file-write surface.** `Set-Content`, `Add-Content`, `Out-File`, `Tee-Object`, and content-producer `>`/`>>` redirects that bypass the Write/Edit hook gate are blocked on the diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index 3405d8654..e20f90d50 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -721,18 +721,19 @@ if ((${#COMMAND} > MAX_COMMAND_LEN)); then fi # Reduce a PowerShell command to a Bash-tokenizer-faithful form, or fail closed. -# For the Bash tool this is a no-op (COMMAND unchanged). The fail-closed branch -# fires only for `git commit`/`git push`-shaped PowerShell this guard cannot -# parse; other dangerous ops carrying an A2b-deferred PowerShell construct are -# deferred (allowed), not this issue's proven bypass surface. -ps::classify_git_command "$TOOL_NAME" "$COMMAND" +# For the Bash tool this is a no-op (COMMAND unchanged). This guard also owns +# destructive non-commit forms (reset/clean/checkout/restore), so it fails closed +# on ANY git-shaped PowerShell it cannot parse (shape `git`): an unparseable +# `git --% reset --hard` must not slip through. A non-git unparseable PowerShell +# command is not this guard's concern and is allowed. +ps::classify_git_command "$TOOL_NAME" "$COMMAND" git case $? in 2) - ps::print_unparseable_block_message + ps::print_unparseable_git_block_message emit_tel "blocked" "powershell-unparseable" exit 2 ;; -1) exit 0 ;; # non-commit/push PowerShell with an A2b-deferred construct +1) exit 0 ;; # non-git PowerShell with an A2b-deferred construct *) COMMAND="$PS_SAFE_COMMAND" ;; esac diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index de8d6dfdc..e628810fd 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -271,4 +271,24 @@ run_pwsh "PS: git status (allowed)" "git status" 0 run_pwsh "PS: backtick-continued force push (fail-closed block)" \ "$(printf 'git push `\n --force')" 2 +# This guard owns destructive non-commit forms (reset/clean/checkout/restore), so +# unlike the commit/push guards it cannot defer an unparseable NON-commit/push git +# command — it must fail closed on ANY git-shaped PowerShell it cannot parse. +run_pwsh "PS: git --% reset --hard (stop-parsing token, fail-closed block)" \ + "git --% reset --hard" 2 +run_pwsh "PS: git --% clean -fd (stop-parsing token, fail-closed block)" \ + "git --% clean -fd" 2 +run_pwsh "PS: backtick-continued git reset --hard (fail-closed block)" \ + "$(printf 'git `\n reset --hard')" 2 +# Single-quoted `$(...)` is deliberately literal PowerShell subexpression text +# (the construct under test), not a Bash expansion. +# shellcheck disable=SC2016 +run_pwsh "PS: git checkout via subexpression (fail-closed block)" \ + 'git checkout $(Get-Branch)' 2 +# Negative control: a non-git unparseable PowerShell command is not this guard's +# concern — no over-block past git. +# shellcheck disable=SC2016 +run_pwsh "PS: non-git unparseable command (allowed — not git-shaped)" \ + 'Remove-Item $(Get-Foo)' 0 + report diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index c3cd2ebd0..467353f6d 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -154,21 +154,39 @@ ps::is_commit_or_push_shaped() { return 0 } -# Classify a git/commit-guard command for the resolved tool. Sets PS_SAFE_COMMAND -# (the command the caller should hand to its Bash parser) and returns: +# True (0) when the (quote-stripped) text carries a `git` (optionally `git.exe`) +# command word. Coarser than commit/push shaping: block-dangerous-git owns +# destructive non-commit forms (reset/clean/checkout/restore), so it must fail +# closed on ANY git-shaped command it cannot parse — not only commit/push — lest +# an unparseable `git --% reset --hard` slip through. +ps::is_git_shaped() { + local lc="${1,,}" + [[ "$lc" =~ (^|[^[:alnum:]_.])git([.]exe)?([^[:alnum:]_]|$) ]] +} + +# Classify a git/commit-guard command for the resolved tool. The optional third +# argument selects the DANGER SHAPE that forces a fail-closed block on an +# unparseable command: `commit-push` (default — the commit/push guards) or `git` +# (block-dangerous-git, which also owns destructive non-commit forms and so fails +# closed on ANY git-shaped command it cannot parse). Sets PS_SAFE_COMMAND (the +# command the caller should hand to its Bash parser) and returns: # 0 proceed — parse PS_SAFE_COMMAND (== the original command for the Bash tool) -# 1 allow/skip — a non-commit PowerShell command with a construct deferred to -# A2b; the guard's concern is not confidently present, so do not block -# 2 block fail-closed — commit/push shaped but not confidently parseable +# 1 allow/skip — an unparseable PowerShell command that is NOT danger-shaped +# for this guard (a construct deferred to A2b); do not block +# 2 block fail-closed — danger-shaped but not confidently parseable ps::classify_git_command() { - local tool="$1" cmd="$2" scan + local tool="$1" cmd="$2" shape="${3:-commit-push}" scan PS_SAFE_COMMAND="$cmd" [[ "$tool" == "PowerShell" ]] || return 0 ps::blank_herestrings "$cmd" scan=$(ps::blank_quoted_spans "$PS_BLANKED") if ((PS_HERESTRING_UNBALANCED)) || ps::has_special_constructs "$scan"; then - ps::is_commit_or_push_shaped "$scan" && return 2 + if [[ "$shape" == "git" ]]; then + ps::is_git_shaped "$scan" && return 2 + else + ps::is_commit_or_push_shaped "$scan" && return 2 + fi return 1 fi # Read by the sourcing guard, not within this library. @@ -188,6 +206,14 @@ ps::print_unparseable_block_message() { echo "or run the commit via the Bash tool (the /commit skill's canonical form)." >&2 } +# Shell-agnostic block text for a PowerShell git command block-dangerous-git +# cannot parse with confidence. Printed to stderr by the caller before it exits 2. +ps::print_unparseable_git_block_message() { + echo "BLOCKED: this PowerShell 'git' command cannot be parsed with confidence — blocked (fail-closed)." >&2 + echo "A git command carrying a PowerShell construct the guard cannot faithfully tokenize (backtick, '--%', subexpression, script-block grouping, or an unbalanced here-string) could hide a destructive form (reset --hard, clean -fd, checkout/restore), so it is blocked rather than waved through." >&2 + echo "Run the command via the Bash tool, or rewrite it without the unparseable construct." >&2 +} + # True (0) when a PowerShell command authors file content in a way that bypasses # the Write/Edit hook gate: a content-authoring cmdlet (Set-Content, Add-Content, # Out-File, Tee-Object), or a stdout redirect (`>`/`>>`, not the `$null` discard) From 1f802291d8d8d15fa39cd4e95c841ce94c18d413 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:00:16 -0400 Subject: [PATCH 03/10] fix(guardrails): reconcile parity hardening with git-shaped fail-closed net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch hygiene: this branch had two parallel commits on the round-1 base (518fce76) — a laptop lane's "fail closed on any git-shaped unparseable PowerShell" (8301587023) and the tower's Bash-parity hardening. Fold them into one coherent branch so the PR is a clean, comparable single-branch implementation. - ps-command.sh: the tower's git-presence sink (ps::might_invoke_git) is the base — it already fails closed on ANY git-shaped unparseable PowerShell for every git guard, so the lane's separate `shape` parameter / ps::is_git_shaped is redundant and dropped. The lane's dedicated block message (ps::print_unparseable_git_block_message, which names the destructive reset/clean/checkout forms) is kept — a genuine improvement over the generic commit-form message for block-dangerous-git. - block-dangerous-git.sh: calls the classifier without the now-redundant shape arg and uses the dedicated message. - block-dangerous-git.test.sh: the lane's five PowerShell regression tests are kept verbatim (git --% reset --hard, git --% clean -fd, backtick-continued reset --hard, checkout via subexpression, and a non-git negative control) — all pass on the tower's sink (200 -> 205, no failures), the behavioral proof the git-presence sink subsumes the lane's git-shaped fix. - CHANGELOG: one unified 0.9.9 entry crediting both the parity hardening and the wider git-shaped fail-closed net, plus the Export-ModuleMember over-block note. All suites green: block-dangerous-git 205, block-no-verify 112, block-hook-bypass 141, block-noncanonical-commit 57, flag-commit-pr-skill-bypass 27 (0 failures). shellcheck/shfmt clean; sync-hook-utils and changelog-parity green. Co-Authored-By: Claude Opus 4.8 --- plugins/guardrails/CHANGELOG.md | 54 +++- .../guardrails/hooks/block-dangerous-git.sh | 13 +- .../hooks/block-hook-bypass.test.sh | 24 ++ .../guardrails/hooks/block-no-verify.test.sh | 35 +++ .../guardrails/lib/powershell/ps-command.sh | 268 +++++++++++++----- 5 files changed, 308 insertions(+), 86 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index dbbc2ed9d..7613c03da 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -16,21 +16,47 @@ All notable changes to the `guardrails` plugin are documented here. Format follo (`lib/powershell/ps-command.sh`) reduces a PowerShell command to a Bash-tokenizer-faithful form or fails closed: the canonical PowerShell commit form (a here-string piped to `git commit -F -`) is allowed exactly as the Bash - `-F -` form is, while a `git commit`/`git push`-shaped PowerShell command - carrying a construct the guard cannot parse with confidence (backtick, `--%`, - subexpression, script-block grouping, or an unbalanced here-string) is blocked - rather than waved through. `block-dangerous-git` also owns destructive - non-commit forms (`reset --hard`, `clean -fd`, `checkout`/`restore`), so its - fail-closed net is wider: it blocks ANY git-shaped PowerShell it cannot parse, - not only commit/push — an unparseable `git --% reset --hard` cannot slip - through. + `-F -` form is, while a PowerShell command carrying a construct the Bash + tokenizer cannot faithfully parse (backtick, `--%`, `(`/`)`/`{`/`}` grouping, + an unbalanced here-string, a dynamic invocation — `iex`/`invoke-expression` or a + call/dot-source of a string literal — or a process launcher / nested shell: + `Start-Process`/`saps`, `pwsh`/`powershell`/`cmd`) is refused unless it is + provably git-free. The refusal is decided by whether the command could reach git + at all — recovering backtick obfuscation (`` g`it com`mit `` → `git commit`), + reading quoted command words and launched argv, and treating an opaque run + string as possibly-git — never by trusting a negative `commit`/`push` shape + match on a scan the obfuscating construct has already mangled (the fail-open + class fixed in #740/#903). Because the sink keys on git-presence, + `block-dangerous-git` fails closed on ANY git-shaped unparseable PowerShell — not + only commit/push — so an obfuscated `git reset --hard` / `clean -fd` / + `checkout` cannot slip through, and its block message names those destructive + forms rather than the commit form. - **`block-hook-bypass` now covers the PowerShell file-write surface.** - `Set-Content`, `Add-Content`, `Out-File`, `Tee-Object`, and content-producer - `>`/`>>` redirects that bypass the Write/Edit hook gate are blocked on the - PowerShell tool, producer-scoped like the Bash detection (a tool's own output - redirect — e.g. `git diff > out.txt` — is still allowed). Scope: this closes - the write-GATE bypass; secret-pattern and hardcoded-path CONTENT scanning of - PowerShell writes remains on the `Write|Edit`-matched guards (deferred). + `Set-Content`, `Add-Content`, `Out-File`, `Tee-Object` (including the `ac` and + `tee` aliases and backtick-escaped names), `New-Item -Value` (alias `ni`), the + `Export-*` serialize-to-file family (alias `epcsv`), `[IO.File]::WriteAll*`/ + `AppendAll*` and StreamWriter, `iex`/`invoke-expression` (opaque run string, + failed closed), and content-producer `>`/`>>` redirects (echo/Write-Output/ + Write-Host, a string or here-string literal, or a `$variable` value) that bypass + the Write/Edit hook gate are blocked on the PowerShell tool. Producer-scoped like + the Bash detection (a tool's own output redirect — e.g. `git diff > out.txt` — is + still allowed; `New-Item -ItemType Directory` with no `-Value` is not a content + write). `sc` is matched only in its unambiguous Set-Content form (a `-Value`/ + `-Path`/`-LiteralPath`/`-Stream` parameter): it is Set-Content's alias in Windows + PowerShell 5.1 but sc.exe in PowerShell 7, so a genuine `sc query` service call + stays allowed. Scope: this closes the write-GATE bypass; secret-pattern and + hardcoded-path CONTENT scanning of PowerShell writes remains on the + `Write|Edit`-matched guards (deferred). +- **The PowerShell coverage bar is documented as Bash-parity, not airtight.** These + guards are accidental-destruction friction, not a boundary against deliberate + evasion — and the Bash guard they extend does not stop deliberate evasion either. + The PowerShell surface is held to what the Bash guard already sees through + (`sh -c`/`bash -c` → `pwsh`/`powershell -Command`; `nice`/`sudo`/`env` → + `Start-Process`), no higher. Beyond-parity vectors are shared Bash+PS residuals, + not covered: a command word supplied entirely by an unexpanded variable + (`& $tool commit`, `iex $var`), deep nested-shell / `cmd /c` quoting, .NET + reflection beyond the common `[IO.File]`/StreamWriter writes, and any shell + variable / command substitution. ### Changed diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index e20f90d50..b447d2ce0 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -721,12 +721,13 @@ if ((${#COMMAND} > MAX_COMMAND_LEN)); then fi # Reduce a PowerShell command to a Bash-tokenizer-faithful form, or fail closed. -# For the Bash tool this is a no-op (COMMAND unchanged). This guard also owns -# destructive non-commit forms (reset/clean/checkout/restore), so it fails closed -# on ANY git-shaped PowerShell it cannot parse (shape `git`): an unparseable -# `git --% reset --hard` must not slip through. A non-git unparseable PowerShell -# command is not this guard's concern and is allowed. -ps::classify_git_command "$TOOL_NAME" "$COMMAND" git +# For the Bash tool this is a no-op (COMMAND unchanged). The classifier's sink is +# git-presence-based (ps::might_invoke_git), so an unparseable PowerShell command +# that could reach git at all is blocked — this guard owns destructive non-commit +# forms (reset/clean/checkout/restore), and an unparseable `git --% reset --hard` +# must not slip through. A non-git unparseable PowerShell command is not this +# guard's concern and is allowed. +ps::classify_git_command "$TOOL_NAME" "$COMMAND" case $? in 2) ps::print_unparseable_git_block_message diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index c69cd3f66..8eb49c0d4 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -405,6 +405,30 @@ run_pwsh "PS: tool output > file (allowed — producer is the tool)" "git diff > run_pwsh "PS: redirect to \$null (allowed — discard)" "git log > \$null" 0 run_pwsh "PS: Set-Content mentioned in quoted arg (allowed)" "echo 'run Set-Content later'" 0 run_pwsh "PS: plain git status (allowed)" "git status" 0 +# Alias + backtick-obfuscation regressions (independent security review). +bt='`' +run_pwsh "PS: ac alias (Add-Content, blocked)" "ac -Path f.txt -Value x" 2 +run_pwsh "PS: tee alias (Tee-Object, blocked)" "x | tee -FilePath out.txt" 2 +run_pwsh "PS: backtick-escaped Set\`-Content (blocked)" "Set${bt}-Content f.txt x" 2 +# `sc` is sc.exe in PowerShell 7 (service controller), NOT Set-Content — allowed. +run_pwsh "PS: sc is sc.exe not Set-Content (allowed)" "sc query" 0 +# Expanded write surface (independent security review, round 2). +run_pwsh "PS: iex opaque run (blocked)" "iex 'Set-Content f.txt x'" 2 +run_pwsh "PS: New-Item -Value (blocked)" "New-Item -Path f -ItemType File -Value 'data'" 2 +run_pwsh "PS: New-Item -ItemType Directory, no -Value (allowed)" "New-Item -Path d -ItemType Directory" 0 +run_pwsh "PS: Export-Csv (blocked)" "\$d | Export-Csv f.csv" 2 +run_pwsh "PS: Export-Clixml (blocked)" "\$d | Export-Clixml f.xml" 2 +run_pwsh "PS: [IO.File]::WriteAllText (blocked)" "[IO.File]::WriteAllText('f','x')" 2 +run_pwsh "PS: StreamWriter (blocked)" "(New-Object IO.StreamWriter 'f').Write('x')" 2 +run_pwsh "PS: variable redirected to file (blocked)" "\$x > f.txt" 2 +# Write-cmdlet alias parity (round 3 review): ni (New-Item), epcsv (Export-Csv). +run_pwsh "PS: ni -Value alias (blocked)" "ni -Path f -ItemType File -Value 'data'" 2 +run_pwsh "PS: epcsv alias (Export-Csv, blocked)" "\$d | epcsv f.csv" 2 +# `sc` is Set-Content in Windows PowerShell 5.1; matched only in its Set-Content +# form (a -Value/-Path parameter). sc.exe (PS 7) service calls stay allowed. +run_pwsh "PS: sc -Path -Value (5.1 Set-Content form, blocked)" "sc -Path f.txt -Value 'x'" 2 +run_pwsh "PS: sc query (sc.exe service, allowed)" "sc query" 0 +run_pwsh "PS: sc start service (sc.exe, allowed)" "sc start W32Time" 0 # The block message is shell-agnostic (no 'Bash' assumption). psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index 2215e5f18..e94588a6a 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -234,4 +234,39 @@ run_pwsh "PS: brace-grouped commit --no-verify (fail-closed block)" \ "& { git commit --no-verify }" 2 run_pwsh "PS: LEFTHOOK=0 git commit (env bypass, blocked)" "LEFTHOOK=0 git commit -m x" 2 +# Obfuscation regressions (independent security review, sink-level fail-closed). +# A construct that defeats the Bash tokenizer must not let an obfuscated git +# invocation through — the sink blocks unless the command is provably git-free, +# rather than trusting a negative shape match on the mangled scan. +bt='`' +run_pwsh "PS: backtick inside subcommand (git com\`mit, blocked)" "git com${bt}mit --no-verify" 2 +run_pwsh "PS: backtick inside push (git pu\`sh --force, blocked)" "git pu${bt}sh --force" 2 +run_pwsh "PS: backtick inside git itself (g\`it com\`mit, blocked)" "g${bt}it com${bt}mit --no-verify" 2 +run_pwsh "PS: quoted subcommand + subexpression decoy (blocked)" "git 'commit' --no-verify \$(whoami)" 2 +run_pwsh "PS: quoted git command word (blocked via parser)" "& 'git' commit --no-verify" 2 +# Provably git-free PowerShell carrying an unparseable construct is NOT blocked +# by the git guards (no over-block of legitimate non-git PowerShell). +run_pwsh "PS: non-git scriptblock (allowed)" "Get-Process | Where-Object { \$_.CPU -gt 5 }" 0 +run_pwsh "PS: non-git subexpression (allowed)" "Write-Output \$(Get-Date)" 0 +# Dynamic-invocation regressions: iex / string-literal call run an opaque string, +# so a construct-free form must still route to the fail-closed sink (it otherwise +# reached the Bash parser, which sees `iex`, not git, and passed). +run_pwsh "PS: iex of a literal git command (blocked)" "iex 'git commit --no-verify'" 2 +run_pwsh "PS: invoke-expression of a literal (blocked)" "invoke-expression 'git push --force'" 2 +run_pwsh "PS: iex of a here-string (blocked)" \ + "$(printf 'iex @%s\ngit commit --no-verify\n%s@' "'" "'")" 2 +run_pwsh "PS: call of a string-literal command (blocked)" "& 'git commit --no-verify'" 2 +# A call/dot-source of a bare VARIABLE is the deferred variable-command-word form +# (same residual the Bash guards carry) — it takes the parser path, not the sink. +run_pwsh "PS: call of a bare variable (deferred residual — not blocked here)" "& \$sb" 0 +# Launcher / nested-shell parity with the Bash guard's launcher + `-c` see-through. +# Routed to the sink; blocked only when the launched argv / command names git. +run_pwsh "PS: Start-Process git -ArgumentList (blocked)" "Start-Process git -ArgumentList 'commit','--no-verify'" 2 +run_pwsh "PS: saps git (Start-Process alias, blocked)" "saps git -ArgumentList 'push','--force'" 2 +run_pwsh "PS: pwsh -Command git (nested shell, blocked)" "pwsh -Command 'git commit --no-verify'" 2 +run_pwsh "PS: powershell -Command git (blocked)" "powershell -Command 'git push --force'" 2 +run_pwsh "PS: cmd /c git (blocked)" "cmd /c git commit --no-verify" 2 +run_pwsh "PS: Start-Process notepad (no git, allowed)" "Start-Process notepad" 0 +run_pwsh "PS: pwsh -File script (no inline git, allowed)" "pwsh -File build.ps1" 0 + report diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 467353f6d..9c235a660 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -11,6 +11,18 @@ # beyond the canonical commit form, backticks, `--%`, subexpressions) is the # deferred follow-up A2b. # +# THE BAR IS BASH-PARITY, NOT AIRTIGHT. These guards are accidental-destruction +# friction, not a boundary against deliberate evasion — and the Bash guard this +# extends does not stop deliberate evasion either. The PowerShell surface is held +# to what the Bash guard already sees through, no higher: the `sh -c`/`bash -c` +# see-through has PS analogs in `pwsh`/`powershell -Command` and the nested-shell +# launchers; the `nice`/`sudo`/`env` launcher transparency has its analog in +# `Start-Process`. Vectors the Bash guard ALSO misses are shared Bash+PS residuals, +# documented, not bugs: a command word supplied entirely by an unexpanded variable +# (`& $tool commit`, `iex $var`); deep nested-shell / `cmd /c` quoting; .NET +# reflection beyond the common [IO.File]/StreamWriter writes; and any shell +# variable / command substitution (never evaluated, the same residual as Bash). +# # STRATEGY (git/commit guards): reduce a PowerShell command to a form the Bash # tokenizer handles faithfully, or fail closed. # 1. Blank properly-delimited here-strings (@'...'@ / @"..."@) to an inert @@ -24,26 +36,34 @@ # as a pipeline whose second segment is `git commit -F -` (stdin form) — # allowed exactly as the Bash canonical form is. # 2. On the here-string-blanked, quote-stripped text, detect the PowerShell -# constructs the Bash tokenizer cannot faithfully handle (backtick, -# `--%`, subexpression `$(`/`@(`, script-block/grouping `{`/`}`) and any -# unbalanced here-string. Quoted spans are stripped first so a construct -# that lives inside commit-message text does not count. -# 3. If such a construct is present AND the command is `git commit`/`git push` -# shaped, BLOCK fail-closed — the guard cannot confidently parse it. If it -# is not commit/push shaped, defer to A2b (allow; not this issue's proven -# bypass surface). Otherwise the reduced command is Bash-tokenizer-faithful -# and is handed to the existing parser. +# constructs the Bash tokenizer cannot faithfully handle (backtick, `--%`, +# and `(`/`)`/`{`/`}` grouping) and any unbalanced here-string. Quoted spans +# are stripped first so a construct that lives inside commit-message text +# does not count. +# 3. If such a construct is present, the command is not faithfully tokenizable, +# so it is BLOCKED fail-closed UNLESS it is provably git-free. Crucially the +# allow decision is NOT a negative `commit`/`push` shape match on the mangled +# scan — the very construct that defeats the tokenizer also mangles that scan +# (backtick splits `com`+`mit`, quote-stripping erases `'commit'`), so a +# negative match is not evidence of safety (the #740/#903 fail-open class). +# Instead ps::might_invoke_git asks the mangle-resistant question "could this +# reach git at all?" — backticks recovered, scan quote-INTACT, plus dynamic +# invocation (iex / call / dot-source) — and blocks unless the answer is no. +# Otherwise the reduced command is Bash-tokenizer-faithful and handed to the +# existing parser. # -# OVER-BLOCK, NEVER UNDER-BLOCK is the invariant for the blanker: an ambiguous +# OVER-BLOCK, NEVER UNDER-BLOCK is the invariant. For the blanker: an ambiguous # here-string extent is treated as unbalanced (unsafe) rather than blanked, so a # trailing `| git commit --no-verify` can never be swallowed into the inert -# placeholder and thereby escape detection. +# placeholder and escape detection. For the sink: an unparseable command that +# might reach git is blocked even at the cost of over-blocking an exotic non-git +# git-touching command (e.g. `git log | ForEach { … }`). # -# RESIDUAL (documented, deferred to A2b): backtick-escaped quotes and doubled -# `""` inside a double-quoted string diverge between Bash and PowerShell -# tokenization; here they only ever cause an over-block (fail-closed), never an -# under-block. Shell variable / command substitution is not evaluated (same -# residual the Bash guards carry). +# RESIDUAL (documented, deferred to A2b): a git invocation whose command word +# comes entirely from an unexpanded variable with NO structural construct present +# (`& $tool commit`, where `$tool` holds `git`) takes the parser path and is not +# caught — the same "variable / command substitution is not evaluated" residual +# the Bash guards carry. Faithful PowerShell tokenization is the A2b follow-up. # Guard against double-sourcing. [[ -n "${_GUARDRAILS_PS_COMMAND_LOADED:-}" ]] && return 0 @@ -125,9 +145,14 @@ ps::blank_quoted_spans() { } # True (0) when the (quote-stripped) text carries a PowerShell construct the Bash -# tokenizer cannot faithfully handle. These are exactly the constructs deferred -# to A2b; their presence on a commit/push-shaped command forces a fail-closed -# block rather than a best-effort Bash parse. +# tokenizer cannot faithfully handle: backtick (escape / line continuation, which +# the Bash tokenizer would read as command substitution and use to swallow +# adjacent tokens), `--%` (stop-parsing), and `(`/`)`/`{`/`}` grouping +# (subexpression, array subexpression, script block, hashtable — any of which can +# seat a git invocation where the Bash parser will not find it). Their presence +# routes the command to the fail-closed sink rather than a best-effort Bash parse. +# Quoted spans are stripped by the caller first, so a construct inside message +# text does not trip this — only structural constructs do. ps::has_special_constructs() { local scan="$1" # The single-quoted needles are literal glob patterns, not expansions. @@ -135,58 +160,106 @@ ps::has_special_constructs() { case "$scan" in *'`'*) return 0 ;; # backtick: escape / line continuation *'--%'*) return 0 ;; # stop-parsing token - *'$('*) return 0 ;; # subexpression - *'@('*) return 0 ;; # array subexpression + *'('* | *')'*) return 0 ;; # subexpression / array subexpression / grouping *'{'* | *'}'*) return 0 ;; # script block / hashtable grouping *) return 1 ;; esac } -# True (0) when the (quote-stripped) text is `git commit`/`git push` shaped: a -# `git` (optionally `git.exe`) command word and a `commit` or `push` word. Coarse -# and deliberately generous — it only gates the fail-closed branch, so -# over-inclusiveness costs at most an over-block on a command that also carries an -# unparseable construct. -ps::is_commit_or_push_shaped() { - local lc="${1,,}" - [[ "$lc" =~ (^|[^[:alnum:]_.])git([.]exe)?([^[:alnum:]_]|$) ]] || return 1 - [[ "$lc" =~ (^|[^[:alnum:]_-])(commit|push)([^[:alnum:]_-]|$) ]] || return 1 - return 0 +# True (0) when the text MIGHT invoke git and cannot be proven otherwise. This is +# the fail-closed sink's positive test: because the constructs that route here can +# obfuscate the command, we do not trust a negative `commit`/`push` shape match on +# a mangled scan (that is exactly the #740/#903 fail-open class). Instead we ask +# the weaker, mangle-resistant question "could this reach git at all?" and block +# unless the answer is provably no. +# +# Backticks are deleted first (PowerShell's escape char, so `g``it com``mit` +# recovers to `git commit`), and the scan is quote-INTACT so a quoted command word +# (`& 'git' commit`, `git 'commit'`) is still seen. A dynamic-invocation operator +# whose target cannot be resolved statically (`iex`/`invoke-expression`, or a call +# `&` / dot-source `.` of a variable or subexpression) is likewise treated as +# possibly-git. Over-inclusive by construction — it only gates the fail-closed +# branch, so a false positive costs at most an over-block on a command that also +# carries an unparseable construct. +ps::might_invoke_git() { + # `q` carries the two quote characters so neither appears literally inside the + # [[ =~ ]] test (which would derail shellcheck's parser). + local recovered="${1//\`/}" lc q="\"'" + lc="${recovered,,}" + [[ "$lc" =~ (^|[^[:alnum:]_.])git([.]exe)?([^[:alnum:]_]|$) ]] && return 0 + [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression)([^[:alnum:]_-]|$) ]] && return 0 + # Call / dot-source of a variable, subexpression, or string literal: + # `& $x …`, `& (…)`, `& 'git …'`, `. $x …` — the target runs as a command. + [[ "$lc" =~ (^|[[:space:]])[.\&][[:space:]]*[\$\($q] ]] && return 0 + return 1 +} + +# True (0) when the command uses a dynamic-invocation form that runs an arbitrary +# string as a command: `iex`/`invoke-expression` (of anything), or a call `&` / +# dot-source `.` of a STRING LITERAL (`& 'git commit …'`, `. "…"`). These defeat +# faithful Bash tokenization exactly as the structural constructs do — the run +# string is opaque to the tokenizer — so they must route to the fail-closed sink +# even when no bracket/backtick construct is present (the fail-open class the +# re-review found: a construct-free `iex '…'` otherwise reached the Bash parser, +# which sees command word `iex`, not git, and passed). A call/dot-source of a bare +# VARIABLE (`& $tool …`) is the genuinely-deferred variable-command-word residual +# and is deliberately NOT routed here. Operates on the quote-INTACT command +# (backticks recovered) so the string-literal forms stay visible. +ps::has_dynamic_invocation() { + # `q` carries the two quote characters so neither appears literally inside the + # [[ =~ ]] test (which would derail shellcheck's parser). + local recovered="${1//\`/}" lc q="\"'" + lc="${recovered,,}" + [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression)([^[:alnum:]_-]|$) ]] && return 0 + [[ "$recovered" =~ (^|[[:space:]])[.\&][[:space:]]*[$q] ]] && return 0 + return 1 } -# True (0) when the (quote-stripped) text carries a `git` (optionally `git.exe`) -# command word. Coarser than commit/push shaping: block-dangerous-git owns -# destructive non-commit forms (reset/clean/checkout/restore), so it must fail -# closed on ANY git-shaped command it cannot parse — not only commit/push — lest -# an unparseable `git --% reset --hard` slip through. -ps::is_git_shaped() { - local lc="${1,,}" - [[ "$lc" =~ (^|[^[:alnum:]_.])git([.]exe)?([^[:alnum:]_]|$) ]] +# True (0) when a process launcher / nested shell sits at a command position: +# Start-Process (alias saps) launches a program the same way the Bash guard sees +# through `nice`/`nohup`/`sudo`/`env`; pwsh/powershell/cmd run a nested command +# string, the parity analog of the Bash guard's `sh -c`/`bash -c` see-through. +# Routed to the sink so ps::might_invoke_git decides — it blocks only when the +# literal `git` is present in the launched argv / command string (`Start-Process +# git -ArgumentList …`, `pwsh -Command 'git …'`), and passes a launcher with no +# git (`Start-Process notepad`, `pwsh -File build.ps1`). This is Bash-PARITY, not +# an airtight boundary; deeper nested-shell escaping (and `cmd /c`'s own quoting) +# is a shared Bash+PS residual. +ps::has_launcher() { + local lc="${1//\`/}" + lc="${lc,,}" + [[ "$lc" =~ (^|[[:space:]\;\|\&\(])(start-process|saps|pwsh|powershell|cmd)([[:space:]]|$) ]] } -# Classify a git/commit-guard command for the resolved tool. The optional third -# argument selects the DANGER SHAPE that forces a fail-closed block on an -# unparseable command: `commit-push` (default — the commit/push guards) or `git` -# (block-dangerous-git, which also owns destructive non-commit forms and so fails -# closed on ANY git-shaped command it cannot parse). Sets PS_SAFE_COMMAND (the -# command the caller should hand to its Bash parser) and returns: +# Classify a git/commit-guard command for the resolved tool. Sets PS_SAFE_COMMAND +# (the command the caller should hand to its Bash parser) and returns: # 0 proceed — parse PS_SAFE_COMMAND (== the original command for the Bash tool) -# 1 allow/skip — an unparseable PowerShell command that is NOT danger-shaped -# for this guard (a construct deferred to A2b); do not block -# 2 block fail-closed — danger-shaped but not confidently parseable +# 1 allow/skip — a PowerShell command that carries an A2b-deferred construct but +# is PROVABLY git-free, so none of the git guards' concerns can be present +# 2 block fail-closed — carries a construct the Bash tokenizer cannot faithfully +# parse AND might reach git; refused by shape rather than guessed safe +# +# SINK DOCTRINE (the #740/#903 lesson): when the command is not faithfully +# Bash-tokenizable, do NOT resolve-then-trust-a-negative — the same construct that +# defeats the tokenizer also mangles any shape scan, so a negative `commit`/`push` +# match is not evidence of safety. Block unless the command is provably git-free. ps::classify_git_command() { - local tool="$1" cmd="$2" shape="${3:-commit-push}" scan + local tool="$1" cmd="$2" scan PS_SAFE_COMMAND="$cmd" [[ "$tool" == "PowerShell" ]] || return 0 ps::blank_herestrings "$cmd" scan=$(ps::blank_quoted_spans "$PS_BLANKED") - if ((PS_HERESTRING_UNBALANCED)) || ps::has_special_constructs "$scan"; then - if [[ "$shape" == "git" ]]; then - ps::is_git_shaped "$scan" && return 2 - else - ps::is_commit_or_push_shaped "$scan" && return 2 - fi + if ((PS_HERESTRING_UNBALANCED)) || + ps::has_special_constructs "$scan" || + ps::has_dynamic_invocation "$PS_BLANKED" || + ps::has_launcher "$PS_BLANKED"; then + # Not faithfully tokenizable. Fail closed unless provably git-free. The git + # probe runs on PS_BLANKED (quotes INTACT, backticks recovered inside the + # probe) so a quoted or backtick-obfuscated `git` is still seen; an unbalanced + # here-string leaves PS_BLANKED as the raw command so a trailing pipeline is + # scanned, not swallowed. + ps::might_invoke_git "$PS_BLANKED" && return 2 return 1 fi # Read by the sourcing guard, not within this library. @@ -195,11 +268,12 @@ ps::classify_git_command() { return 0 } -# Shell-agnostic block text for a PowerShell commit/push the guard cannot parse +# Shell-agnostic block text for a PowerShell git command the guard cannot parse # with confidence. Printed to stderr by the caller before it exits 2. ps::print_unparseable_block_message() { - echo "BLOCKED: this PowerShell 'git commit'/'git push' cannot be parsed with confidence — blocked (fail-closed)." >&2 - echo "Use the canonical PowerShell commit form (a here-string piped to 'git commit -F -'):" >&2 + echo "BLOCKED: this PowerShell git command cannot be parsed with confidence — blocked (fail-closed)." >&2 + echo "Remove the obfuscating construct (backtick, --%, subexpression, or {}/() grouping)." >&2 + echo "The canonical PowerShell commit form (a here-string piped to 'git commit -F -') is:" >&2 echo " @'" >&2 echo " " >&2 echo " '@ | git commit -F -" >&2 @@ -207,7 +281,9 @@ ps::print_unparseable_block_message() { } # Shell-agnostic block text for a PowerShell git command block-dangerous-git -# cannot parse with confidence. Printed to stderr by the caller before it exits 2. +# cannot parse with confidence. That guard also owns destructive non-commit forms, +# so its message names them rather than the commit form. Printed to stderr by the +# caller before it exits 2. ps::print_unparseable_git_block_message() { echo "BLOCKED: this PowerShell 'git' command cannot be parsed with confidence — blocked (fail-closed)." >&2 echo "A git command carrying a PowerShell construct the guard cannot faithfully tokenize (backtick, '--%', subexpression, script-block grouping, or an unbalanced here-string) could hide a destructive form (reset --hard, clean -fd, checkout/restore), so it is blocked rather than waved through." >&2 @@ -215,11 +291,23 @@ ps::print_unparseable_git_block_message() { } # True (0) when a PowerShell command authors file content in a way that bypasses -# the Write/Edit hook gate: a content-authoring cmdlet (Set-Content, Add-Content, -# Out-File, Tee-Object), or a stdout redirect (`>`/`>>`, not the `$null` discard) -# whose producer is a content emitter (echo / Write-Output / Write-Host or a bare -# string / here-string literal). Producer-scoped to match the Bash guard, which -# allows ` ... > out` (the producer is the tool, not a content author). +# the Write/Edit hook gate. Covered surface: +# - content-authoring cmdlets: Set-Content, Add-Content, Out-File, Tee-Object +# (and the `ac` / `tee` aliases; `sc` only in its Set-Content form, since it is +# sc.exe in PS 7); +# - New-Item (alias `ni`) with -Value; the Export-* serialize-to-file family +# (alias `epcsv`); +# - .NET file writes: [IO.File]::WriteAllText/AppendAllText/WriteAllLines and +# StreamWriter; +# - a stdout redirect (`>`/`>>`, not the `$null` discard) whose producer is a +# content emitter (echo / Write-Output / Write-Host, a bare string / +# here-string literal, or a `$variable` / subexpression value) — producer- +# scoped to match the Bash guard, which allows ` ... > out` (the +# producer is the tool, not a content author); +# - iex / invoke-expression, whose run string is opaque here — fail closed, +# mirroring the git guards' sink. +# Backticks are deleted before matching so an escape-obfuscated name (`Set``-Content`) +# resolves to its real form. # # SCOPE: this covers the write-GATE bypass only. Secret-pattern and hardcoded-path # CONTENT scanning of PowerShell writes stays on the Write|Edit-matched guards; @@ -228,11 +316,58 @@ ps::write_bypass() { local cmd="$1" scan lcs seg lc head ps::blank_herestrings "$cmd" scan=$(ps::blank_quoted_spans "$PS_BLANKED") + # Delete backticks before matching so a name obfuscated by PowerShell's escape + # char (`Set``-Content`) resolves to its real form. + scan="${scan//\`/}" lcs="${scan,,}" # Content-authoring cmdlets are a write by nature. Detected on the quote-stripped - # text so a cmdlet named inside message text is inert. - if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(set-content|add-content|out-file|tee-object)([[:space:]]|$) ]]; then + # text so a cmdlet named inside message text is inert. Aliases are matched too: + # `ac` (Add-Content) and `tee` (Tee-Object). Out-File has no built-in alias. + # `sc` is handled separately below — it is Set-Content's alias in Windows + # PowerShell 5.1 but sc.exe (the service controller) in PowerShell 7, so it is + # matched only in its unambiguous Set-Content form. + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(set-content|add-content|out-file|tee-object|ac|tee)([[:space:]]|$) ]]; then + return 0 + fi + # `sc` in its Set-Content form: only when a Set-Content-only parameter follows + # (`-Value`/`-Path`/`-LiteralPath`/`-LP`/`-Stream`). sc.exe (PS 7) takes bare + # subcommands (`sc query`, `sc start …`) and none of these dash-parameters, so + # this never fires on a genuine service-controller call — while a 5.1 + # `sc -Path f -Value x` (the Set-Content alias) is caught. The bare positional + # form (`sc f 'x'`) on 5.1 is a documented residual. + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])sc[[:space:]] ]] && + [[ "$lcs" =~ [[:space:]]-(va[a-z]*|path|literalpath|lp|stream)([[:space:]]|:) ]]; then + return 0 + fi + + # iex / invoke-expression authors content via the arbitrary string it runs — its + # payload is opaque here, so fail closed (mirrors the git guards' sink). + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(iex|invoke-expression)([[:space:]]|$) ]]; then + return 0 + fi + # New-Item (alias `ni`) authoring content via -Value. `-va` is the shortest + # unambiguous abbreviation (New-Item has no other -va* parameter); `-Value:x` + # attaches with a colon. Directory/empty-file creation with no -Value is not a + # content author. + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(new-item|ni)([[:space:]]) ]] && + [[ "$lcs" =~ [[:space:]]-va[a-z]*([[:space:]]|:) ]]; then + return 0 + fi + # Serialize-to-file cmdlets: the Export-* family (Export-Csv, Export-Clixml, …), + # including the `epcsv` (Export-Csv) alias. ConvertTo-*/format cmdlets piped into + # Out-File/Set-Content are already caught by those sinks above. The broad + # `export-*` match also catches the few non-file-writing members (notably + # Export-ModuleMember) — a safe-direction over-block, never an under-block, and + # tolerated friction: those are authored inside .psm1 module files, not run as + # ad hoc tool commands. + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(export-[a-z]+|epcsv)([[:space:]]|$) ]]; then + return 0 + fi + # .NET file-write APIs: [IO.File]::WriteAllText/AppendAllText/WriteAllLines/ + # WriteAllBytes and StreamWriter. + if [[ "$lcs" =~ io\.file\][^:]*::[[:space:]]*(writeall|appendall) ]] || + [[ "$lcs" =~ streamwriter ]]; then return 0 fi @@ -252,6 +387,7 @@ ps::write_bypass() { esac case "$head" in echo | write-output | write-host | "${PS_HERESTRING_PLACEHOLDER,,}") return 0 ;; + '$'*) return 0 ;; # a variable / subexpression value redirected to a file *) ;; esac done <<<"$norm" From c49a12073d8423544f90594f19241ce2cae59544 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:32:26 -0400 Subject: [PATCH 04/10] fix(guardrails): close within-parity PowerShell holes from review round 4 Bot review on the restacked #960 surfaced five within-parity gaps in covered constructs: .exe-suffixed launcher spellings and the `start` alias skipped the fail-closed launcher sink; the `write` alias of Write-Output was not a redirect producer; module-qualified writer spellings missed the cmdlet match; parenthesized redirect producers fell through the head-only check; and a call/dot-source of a quoted writer name was erased by quote-blanking before detection. All five fixed red-first in ps-command.sh; quoted arbitrary-program calls stay allowed (documented shared residual, Bash-parity bar per the operator ruling on #915). Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 12 +++++ .../hooks/block-dangerous-git.test.sh | 17 +++++-- .../hooks/block-hook-bypass.test.sh | 15 ++++++ .../guardrails/lib/powershell/ps-command.sh | 48 ++++++++++++++----- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 223e47355..1b17472dd 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -47,6 +47,18 @@ All notable changes to the `guardrails` plugin are documented here. Format follo stays allowed. Scope: this closes the write-GATE bypass; secret-pattern and hardcoded-path CONTENT scanning of PowerShell writes remains on the `Write|Edit`-matched guards (deferred). +- **Review round 4 (post-restack bot findings, all within-parity holes of covered + constructs):** the `.exe`-suffixed launcher spellings (`cmd.exe /c git …`, + `powershell.exe -Command …`) and the `start` alias of Start-Process now reach the + fail-closed launcher sink; the `write` alias of Write-Output counts as a redirect + producer; module-qualified writer spellings + (`Microsoft.PowerShell.Management\Set-Content`) match the writer cmdlets; a + parenthesized redirect producer (`('secret') > f`, `(Write-Output x) > f`) is + unwrapped and judged by what it produces (a grouped tool run stays allowed); and a + call/dot-source of a QUOTED writer name (`& 'Set-Content' …`, + `& 'Invoke-Expression' …`) is detected on the quote-intact text before blanking. A + quoted path to an arbitrary program (`& 'C:\tools\x.exe'`) stays allowed — the + same quoted-command-word residual the Bash guard carries. - **The PowerShell coverage bar is documented as Bash-parity, not airtight.** These guards are accidental-destruction friction, not a boundary against deliberate evasion — and the Bash guard they extend does not stop deliberate evasion either. diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 06ccd4335..d303dc411 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -334,7 +334,7 @@ run_pwsh "PS: backtick-continued force push (fail-closed block)" \ "$(printf 'git push `\n --force')" 2 # This guard owns destructive non-commit forms (reset/clean/checkout/restore), so -# unlike the commit/push guards it cannot defer an unparseable NON-commit/push git +# unlike the commit/push guards it cannot defer an unparsable NON-commit/push git # command — it must fail closed on ANY git-shaped PowerShell it cannot parse. run_pwsh "PS: git --% reset --hard (stop-parsing token, fail-closed block)" \ "git --% reset --hard" 2 @@ -347,10 +347,21 @@ run_pwsh "PS: backtick-continued git reset --hard (fail-closed block)" \ # shellcheck disable=SC2016 run_pwsh "PS: git checkout via subexpression (fail-closed block)" \ 'git checkout $(Get-Branch)' 2 -# Negative control: a non-git unparseable PowerShell command is not this guard's +# Negative control: a non-git unparsable PowerShell command is not this guard's # concern — no over-block past git. # shellcheck disable=SC2016 -run_pwsh "PS: non-git unparseable command (allowed — not git-shaped)" \ +run_pwsh "PS: non-git unparsable command (allowed — not git-shaped)" \ 'Remove-Item $(Get-Foo)' 0 +# Launcher-spelling parity (review round 4): the .exe-suffixed spellings of the +# covered launchers and the `start` alias of Start-Process are the same +# see-through surface — a spelling gap, not a new launcher class. +run_pwsh "PS: cmd.exe /c git reset --hard (fail-closed block)" \ + "cmd.exe /c git reset --hard" 2 +run_pwsh "PS: powershell.exe -Command git reset --hard (fail-closed block)" \ + "powershell.exe -Command 'git reset --hard'" 2 +run_pwsh "PS: start alias launches git (fail-closed block)" \ + "start git -ArgumentList 'reset --hard'" 2 +run_pwsh "PS: start alias, no git (allowed)" "start notepad" 0 + report diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 8eb49c0d4..d26a82aa3 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -429,6 +429,21 @@ run_pwsh "PS: epcsv alias (Export-Csv, blocked)" "\$d | epcsv f.csv" 2 run_pwsh "PS: sc -Path -Value (5.1 Set-Content form, blocked)" "sc -Path f.txt -Value 'x'" 2 run_pwsh "PS: sc query (sc.exe service, allowed)" "sc query" 0 run_pwsh "PS: sc start service (sc.exe, allowed)" "sc start W32Time" 0 +# Review round 4: producer-alias, module-qualified, grouped-producer, and +# quoted-writer-call parity. +run_pwsh "PS: write alias (Write-Output) > file (blocked)" "write secret > creds.txt" 2 +run_pwsh "PS: module-qualified Set-Content (blocked)" \ + "Microsoft.PowerShell.Management\\Set-Content -Path f.txt -Value x" 2 +run_pwsh "PS: parenthesized literal > file (blocked)" "('secret') > creds.txt" 2 +run_pwsh "PS: parenthesized Write-Output > file (blocked)" "(Write-Output secret) > creds.txt" 2 +run_pwsh "PS: parenthesized tool output > file (allowed — producer is the tool)" \ + "(git diff) > out.txt" 0 +run_pwsh "PS: & 'Set-Content' quoted writer call (blocked)" \ + "& 'Set-Content' -Path f.txt -Value x" 2 +run_pwsh "PS: & 'Invoke-Expression' quoted (blocked)" \ + "& 'Invoke-Expression' 'Set-Content f x'" 2 +run_pwsh "PS: & quoted non-writer program path (allowed)" \ + "& 'C:\\tools\\build.exe' arg" 0 # The block message is shell-agnostic (no 'Bash' assumption). psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 9c235a660..22a3569be 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -55,7 +55,7 @@ # OVER-BLOCK, NEVER UNDER-BLOCK is the invariant. For the blanker: an ambiguous # here-string extent is treated as unbalanced (unsafe) rather than blanked, so a # trailing `| git commit --no-verify` can never be swallowed into the inert -# placeholder and escape detection. For the sink: an unparseable command that +# placeholder and escape detection. For the sink: an unparsable command that # might reach git is blocked even at the cost of over-blocking an exotic non-git # git-touching command (e.g. `git log | ForEach { … }`). # @@ -180,7 +180,7 @@ ps::has_special_constructs() { # `&` / dot-source `.` of a variable or subexpression) is likewise treated as # possibly-git. Over-inclusive by construction — it only gates the fail-closed # branch, so a false positive costs at most an over-block on a command that also -# carries an unparseable construct. +# carries an unparsable construct. ps::might_invoke_git() { # `q` carries the two quote characters so neither appears literally inside the # [[ =~ ]] test (which would derail shellcheck's parser). @@ -228,7 +228,10 @@ ps::has_dynamic_invocation() { ps::has_launcher() { local lc="${1//\`/}" lc="${lc,,}" - [[ "$lc" =~ (^|[[:space:]\;\|\&\(])(start-process|saps|pwsh|powershell|cmd)([[:space:]]|$) ]] + # The .exe-suffixed spellings (cmd.exe, powershell.exe, pwsh.exe) and the + # `start` alias of Start-Process are the same launchers, not a new class — + # a spelling gap here would skip the sink entirely (review round 4). + [[ "$lc" =~ (^|[[:space:]\;\|\&\(])(start-process|saps|start|pwsh|powershell|cmd)(\.exe)?([[:space:]]|$) ]] } # Classify a git/commit-guard command for the resolved tool. Sets PS_SAFE_COMMAND @@ -270,7 +273,7 @@ ps::classify_git_command() { # Shell-agnostic block text for a PowerShell git command the guard cannot parse # with confidence. Printed to stderr by the caller before it exits 2. -ps::print_unparseable_block_message() { +ps::print_unparsable_block_message() { echo "BLOCKED: this PowerShell git command cannot be parsed with confidence — blocked (fail-closed)." >&2 echo "Remove the obfuscating construct (backtick, --%, subexpression, or {}/() grouping)." >&2 echo "The canonical PowerShell commit form (a here-string piped to 'git commit -F -') is:" >&2 @@ -284,10 +287,10 @@ ps::print_unparseable_block_message() { # cannot parse with confidence. That guard also owns destructive non-commit forms, # so its message names them rather than the commit form. Printed to stderr by the # caller before it exits 2. -ps::print_unparseable_git_block_message() { +ps::print_unparsable_git_block_message() { echo "BLOCKED: this PowerShell 'git' command cannot be parsed with confidence — blocked (fail-closed)." >&2 echo "A git command carrying a PowerShell construct the guard cannot faithfully tokenize (backtick, '--%', subexpression, script-block grouping, or an unbalanced here-string) could hide a destructive form (reset --hard, clean -fd, checkout/restore), so it is blocked rather than waved through." >&2 - echo "Run the command via the Bash tool, or rewrite it without the unparseable construct." >&2 + echo "Run the command via the Bash tool, or rewrite it without the unparsable construct." >&2 } # True (0) when a PowerShell command authors file content in a way that bypasses @@ -313,8 +316,21 @@ ps::print_unparseable_git_block_message() { # CONTENT scanning of PowerShell writes stays on the Write|Edit-matched guards; # scanning PowerShell write content is deferred to A2b. ps::write_bypass() { - local cmd="$1" scan lcs seg lc head + local cmd="$1" scan lcs seg lc head lcq q="\"'" ps::blank_herestrings "$cmd" + + # A call `&` / dot-source `.` of a QUOTED writer name runs that string as the + # command (about_Operators, call operator) — quote-blanking below would erase + # exactly the evidence, so detect it on the quote-INTACT text first. Only + # writer/iex names (optionally module-qualified) are matched: a quoted path to + # an arbitrary program (`& 'C:\Program Files\x.exe'`) stays allowed, the same + # quoted-command-word residual the Bash guard carries. + lcq="${PS_BLANKED//\`/}" + lcq="${lcq,,}" + if [[ "$lcq" =~ (^|[[:space:]])[.\&][[:space:]]*[$q]([a-z.]+\\)?(set-content|add-content|out-file|tee-object|ac|tee|iex|invoke-expression|new-item|ni|epcsv|export-[a-z]+) ]]; then + return 0 + fi + scan=$(ps::blank_quoted_spans "$PS_BLANKED") # Delete backticks before matching so a name obfuscated by PowerShell's escape # char (`Set``-Content`) resolves to its real form. @@ -327,7 +343,9 @@ ps::write_bypass() { # `sc` is handled separately below — it is Set-Content's alias in Windows # PowerShell 5.1 but sc.exe (the service controller) in PowerShell 7, so it is # matched only in its unambiguous Set-Content form. - if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(set-content|add-content|out-file|tee-object|ac|tee)([[:space:]]|$) ]]; then + # `\\` in the boundary class admits module-qualified spellings + # (`Microsoft.PowerShell.Management\Set-Content`) — same cmdlet, same write. + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(\\])(set-content|add-content|out-file|tee-object|ac|tee)([[:space:]]|$) ]]; then return 0 fi # `sc` in its Set-Content form: only when a Set-Content-only parameter follows @@ -343,14 +361,14 @@ ps::write_bypass() { # iex / invoke-expression authors content via the arbitrary string it runs — its # payload is opaque here, so fail closed (mirrors the git guards' sink). - if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(iex|invoke-expression)([[:space:]]|$) ]]; then + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(\\])(iex|invoke-expression)([[:space:]]|$) ]]; then return 0 fi # New-Item (alias `ni`) authoring content via -Value. `-va` is the shortest # unambiguous abbreviation (New-Item has no other -va* parameter); `-Value:x` # attaches with a colon. Directory/empty-file creation with no -Value is not a # content author. - if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(new-item|ni)([[:space:]]) ]] && + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(\\])(new-item|ni)([[:space:]]) ]] && [[ "$lcs" =~ [[:space:]]-va[a-z]*([[:space:]]|:) ]]; then return 0 fi @@ -361,7 +379,7 @@ ps::write_bypass() { # Export-ModuleMember) — a safe-direction over-block, never an under-block, and # tolerated friction: those are authored inside .psm1 module files, not run as # ad hoc tool commands. - if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(])(export-[a-z]+|epcsv)([[:space:]]|$) ]]; then + if [[ "$lcs" =~ (^|[[:space:]\;\|\&\(\\])(export-[a-z]+|epcsv)([[:space:]]|$) ]]; then return 0 fi # .NET file-write APIs: [IO.File]::WriteAllText/AppendAllText/WriteAllLines/ @@ -380,13 +398,19 @@ ps::write_bypass() { [[ "$seg" == *'>'* ]] || continue # Exclude the `$null` discard (PowerShell's /dev/null). [[ "$seg" =~ \>\>?[[:space:]]*\$null([[:space:]]|$) ]] && continue + # Unwrap grouping parens so a parenthesized producer is judged by what it + # produces: `('secret') > f` (quote-stripped to `() > f`) reduces to the + # leading-literal case, `(write-output x) > f` to its real head, and a + # grouped tool run (`(git diff) > f`) stays the tool-producer allow. + seg="${seg//[()]/}" + seg="${seg#"${seg%%[![:space:]]*}"}" # re-ltrim after unwrap head="${seg%%[[:space:]]*}" case "$seg" in '>'*) return 0 ;; # leading literal (string stripped away) was the producer *) ;; esac case "$head" in - echo | write-output | write-host | "${PS_HERESTRING_PLACEHOLDER,,}") return 0 ;; + echo | write | write-output | write-host | "${PS_HERESTRING_PLACEHOLDER,,}") return 0 ;; '$'*) return 0 ;; # a variable / subexpression value redirected to a file *) ;; esac From dd0f4a8ca91d4d700163a6cf33c3ae7fdd0cb135 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:40:11 -0400 Subject: [PATCH 05/10] chore(guardrails): drop tracker refs from code comments (comment-hygiene gate) The comment-hygiene CI lane (added on main after this branch was cut) rejects issue references in code comments. Reword the five test-section headers and the ps-command.sh scope note; the CHANGELOG keeps the full provenance. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/hooks/block-dangerous-git.test.sh | 2 +- plugins/guardrails/hooks/block-hook-bypass.test.sh | 2 +- plugins/guardrails/hooks/block-no-verify.test.sh | 2 +- .../guardrails/hooks/block-noncanonical-commit.test.sh | 2 +- .../guardrails/hooks/flag-commit-pr-skill-bypass.test.sh | 2 +- plugins/guardrails/lib/powershell/ps-command.sh | 8 ++++---- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index d303dc411..e7d181188 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -316,7 +316,7 @@ else bad "telemetry: no envelope written on block" fi -# --- PowerShell tool coverage (issue #915) ------------------------------------ +# --- PowerShell tool coverage ------------------------------------------------ # The guard is matched on Bash|PowerShell. PowerShell-simple dangerous ops are # caught; push-shaped PowerShell the guard cannot parse fails closed. run_pwsh() { diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index d26a82aa3..81e6a357d 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -385,7 +385,7 @@ else bad "telemetry: no envelope written on block" fi -# --- PowerShell tool coverage (issue #915) ------------------------------------ +# --- PowerShell tool coverage ------------------------------------------------ # The guard is matched on Bash|PowerShell. PowerShell file-write forms that # bypass the Write/Edit gate are blocked; content-producer scoping is preserved # (a tool's own output redirect is allowed, matching the Bash producer scope). diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index e94588a6a..d0400e0eb 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -208,7 +208,7 @@ else bad "telemetry: no envelope written on block" fi -# --- PowerShell tool coverage (issue #915) ------------------------------------ +# --- PowerShell tool coverage ------------------------------------------------ # The guard is matched on Bash|PowerShell. The proven bypass must be caught on # the PowerShell tool; the canonical PowerShell commit form must be allowed; and # commit/push-shaped PowerShell the guard cannot parse must fail closed. diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index edf8b6b52..79246b16e 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -277,7 +277,7 @@ out=$(bash "$HOOK" <<<"$(command_json "git commit -m 'feat: x'")" 2>&1) assert_contains "block message names -F -" "$out" '-F -' assert_contains "block message names the skill" "$out" '/commit' -# --- PowerShell tool coverage (issue #915) ------------------------------------ +# --- PowerShell tool coverage ------------------------------------------------ # The canonical PowerShell commit form (a here-string piped to `git commit -F -`) # must be allowed exactly as the Bash `-F -` form is; a `-m` PowerShell commit # must be blocked; commit-shaped PowerShell the guard cannot parse fails closed. diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh index bfe1d136c..51f3aa68e 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh @@ -188,7 +188,7 @@ else bad "telemetry: no envelope written on advisory fire" fi -# --- PowerShell tool coverage (issue #915) ------------------------------------ +# --- PowerShell tool coverage ------------------------------------------------ # The advisory is matched on Bash|PowerShell. A direct `gh pr create` on the # PowerShell tool still fires; the same text quarantined inside a here-string # body is neutralized (blanked) and stays silent. diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 22a3569be..3439e49a0 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -6,10 +6,10 @@ # PowerShell tool (CLAUDE_CODE_USE_POWERSHELL_TOOL=1) surfaces its command in the # SAME `tool_input.command` field but with PowerShell grammar, so a naive widen # of the PreToolUse matcher to `Bash|PowerShell` would feed PowerShell text to a -# Bash tokenizer. This library bridges that gap for the CORE, fail-closed scope -# of issue #915; faithful parsing of the full PowerShell grammar (here-strings -# beyond the canonical commit form, backticks, `--%`, subexpressions) is the -# deferred follow-up A2b. +# Bash tokenizer. This library bridges that gap with a CORE, fail-closed scope; +# faithful parsing of the full PowerShell grammar (here-strings beyond the +# canonical commit form, backticks, `--%`, subexpressions) is a deferred +# follow-up. # # THE BAR IS BASH-PARITY, NOT AIRTIGHT. These guards are accidental-destruction # friction, not a boundary against deliberate evasion — and the Bash guard this From 82f0e43d39f9c24487461786d9de88630990d7d9 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:54:16 -0400 Subject: [PATCH 06/10] chore(guardrails): spell unparsable per the typos gate The typos CI lane (crate-ci/typos) rejects "unparseable"; normalize the word across the branch's comments, messages, telemetry form token, and the two ps:: message-helper function names. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/hooks/block-dangerous-git.sh | 10 +++++----- plugins/guardrails/hooks/block-no-verify.sh | 4 ++-- plugins/guardrails/hooks/block-no-verify.test.sh | 2 +- plugins/guardrails/hooks/block-noncanonical-commit.sh | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index ee2c9aaf1..731311785 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -747,16 +747,16 @@ fi # Reduce a PowerShell command to a Bash-tokenizer-faithful form, or fail closed. # For the Bash tool this is a no-op (COMMAND unchanged). The classifier's sink is -# git-presence-based (ps::might_invoke_git), so an unparseable PowerShell command +# git-presence-based (ps::might_invoke_git), so an unparsable PowerShell command # that could reach git at all is blocked — this guard owns destructive non-commit -# forms (reset/clean/checkout/restore), and an unparseable `git --% reset --hard` -# must not slip through. A non-git unparseable PowerShell command is not this +# forms (reset/clean/checkout/restore), and an unparsable `git --% reset --hard` +# must not slip through. A non-git unparsable PowerShell command is not this # guard's concern and is allowed. ps::classify_git_command "$TOOL_NAME" "$COMMAND" case $? in 2) - ps::print_unparseable_git_block_message - emit_tel "blocked" "powershell-unparseable" + ps::print_unparsable_git_block_message + emit_tel "blocked" "powershell-unparsable" exit 2 ;; 1) exit 0 ;; # non-git PowerShell with an A2b-deferred construct diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index 58fab4989..300ce3719 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -231,8 +231,8 @@ fi ps::classify_git_command "$TOOL_NAME" "$COMMAND" case $? in 2) - ps::print_unparseable_block_message - emit_tel "blocked" "powershell-unparseable" + ps::print_unparsable_block_message + emit_tel "blocked" "powershell-unparsable" exit 2 ;; 1) exit 0 ;; # non-commit PowerShell with an A2b-deferred construct — not this guard's proven surface diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index d0400e0eb..0aff75e82 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -244,7 +244,7 @@ run_pwsh "PS: backtick inside push (git pu\`sh --force, blocked)" "git pu${bt}sh run_pwsh "PS: backtick inside git itself (g\`it com\`mit, blocked)" "g${bt}it com${bt}mit --no-verify" 2 run_pwsh "PS: quoted subcommand + subexpression decoy (blocked)" "git 'commit' --no-verify \$(whoami)" 2 run_pwsh "PS: quoted git command word (blocked via parser)" "& 'git' commit --no-verify" 2 -# Provably git-free PowerShell carrying an unparseable construct is NOT blocked +# Provably git-free PowerShell carrying an unparsable construct is NOT blocked # by the git guards (no over-block of legitimate non-git PowerShell). run_pwsh "PS: non-git scriptblock (allowed)" "Get-Process | Where-Object { \$_.CPU -gt 5 }" 0 run_pwsh "PS: non-git subexpression (allowed)" "Write-Output \$(Get-Date)" 0 diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index 81989f029..7fc02971e 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -361,8 +361,8 @@ check_segment() { ps::classify_git_command "$TOOL_NAME" "$COMMAND" case $? in 2) - ps::print_unparseable_block_message - emit_tel "blocked" "powershell-unparseable" + ps::print_unparsable_block_message + emit_tel "blocked" "powershell-unparsable" exit 2 ;; 1) exit 0 ;; # non-commit PowerShell with an A2b-deferred construct From 058e09dd7d9c97d861afb3ff530d63aaeaafabe3 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:15:25 -0400 Subject: [PATCH 07/10] fix(guardrails): fail closed on computed-expression launcher and writer shapes Round-5 bot review: a launcher with a computed program name (Start-Process ('g'+'it')), a call operator with a computed writer name (& ('Set-'+'Content')), and expression-literal redirect producers (36 > out.txt, [char]65 > out.txt) all evaluated expressions the guards cannot resolve and slipped through as provably-safe. Each shape now takes the fail-closed branch, mirroring the existing iex / call-of-variable posture; attached-digit stream redirects (2>err.txt) and tool-output redirects stay allowed. Red-first regressions in both suites. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 8 ++++++++ .../hooks/block-dangerous-git.test.sh | 8 ++++++++ .../guardrails/hooks/block-hook-bypass.test.sh | 7 +++++++ plugins/guardrails/lib/powershell/ps-command.sh | 17 +++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 1b17472dd..61070426a 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -59,6 +59,14 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `& 'Invoke-Expression' …`) is detected on the quote-intact text before blanking. A quoted path to an arbitrary program (`& 'C:\tools\x.exe'`) stays allowed — the same quoted-command-word residual the Bash guard carries. +- **Review round 5 (computed-expression shapes fail closed):** a launcher whose + program is a computed expression or variable (`Start-Process ('g'+'it') …`, + `saps $tool …`, optionally behind one named parameter) is treated as + possibly-git rather than provably git-free; a call/dot-source of a computed + target (`& ('Set-'+'Content') …`, `& $w …`) fails the write gate closed the + same way iex does; and an expression-literal redirect producer (`36 > out.txt`, + `[char]65 > out.txt` — spaced value writes, not attached-digit stream + redirects) counts as a content write. - **The PowerShell coverage bar is documented as Bash-parity, not airtight.** These guards are accidental-destruction friction, not a boundary against deliberate evasion — and the Bash guard they extend does not stop deliberate evasion either. diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index e7d181188..4e9541e17 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -363,5 +363,13 @@ run_pwsh "PS: powershell.exe -Command git reset --hard (fail-closed block)" \ run_pwsh "PS: start alias launches git (fail-closed block)" \ "start git -ArgumentList 'reset --hard'" 2 run_pwsh "PS: start alias, no git (allowed)" "start notepad" 0 +# A launcher whose program is a computed expression cannot be proven git-free. +run_pwsh "PS: Start-Process computed target (fail-closed block)" \ + "Start-Process ('g'+'it') -ArgumentList 'reset --hard'" 2 +run_pwsh "PS: Start-Process -FilePath computed target (fail-closed block)" \ + "Start-Process -FilePath ('g'+'it') -ArgumentList 'reset --hard'" 2 +# shellcheck disable=SC2016 +run_pwsh "PS: launcher with variable target (fail-closed block)" \ + 'saps $tool -ArgumentList "reset --hard"' 2 report diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 81e6a357d..9c4f1f430 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -444,6 +444,13 @@ run_pwsh "PS: & 'Invoke-Expression' quoted (blocked)" \ "& 'Invoke-Expression' 'Set-Content f x'" 2 run_pwsh "PS: & quoted non-writer program path (allowed)" \ "& 'C:\\tools\\build.exe' arg" 0 +# Review round 5: expression-valued producers and computed call targets. +run_pwsh "PS: numeric expression > file (blocked)" "36 > out.txt" 2 +run_pwsh "PS: cast expression > file (blocked)" "[char]65 > out.txt" 2 +run_pwsh "PS: & computed writer name (fail-closed block)" \ + "& ('Set-'+'Content') -Path f.txt -Value x" 2 +run_pwsh "PS: spaced numeric is a value write, tool redirect still allowed" \ + "git diff 2> err.txt" 0 # The block message is shell-agnostic (no 'Bash' assumption). psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 3439e49a0..4ca409892 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -191,6 +191,11 @@ ps::might_invoke_git() { # Call / dot-source of a variable, subexpression, or string literal: # `& $x …`, `& (…)`, `& 'git …'`, `. $x …` — the target runs as a command. [[ "$lc" =~ (^|[[:space:]])[.\&][[:space:]]*[\$\($q] ]] && return 0 + # A launcher whose program is a computed expression or variable — + # `Start-Process ('g'+'it') …`, `saps $tool …`, optionally behind one named + # parameter (`-FilePath (…)`) — may evaluate to git; it cannot be proven + # git-free, so it stays in the fail-closed branch (review round 5). + [[ "$lc" =~ (^|[[:space:]\;\|\&\(])(start-process|saps|start|pwsh|powershell|cmd)(\.exe)?[[:space:]]+(-[a-z]+[[:space:]]+)?[\(\$] ]] && return 0 return 1 } @@ -330,6 +335,13 @@ ps::write_bypass() { if [[ "$lcq" =~ (^|[[:space:]])[.\&][[:space:]]*[$q]([a-z.]+\\)?(set-content|add-content|out-file|tee-object|ac|tee|iex|invoke-expression|new-item|ni|epcsv|export-[a-z]+) ]]; then return 0 fi + # A call/dot-source of a COMPUTED target — `& ('Set-'+'Content') …`, `& $w …` + # — evaluates an expression into the command name; it cannot be proven + # non-writer, so it fails closed like iex (review round 5). Mirrors + # ps::might_invoke_git's treatment of the same shape on the git side. + if [[ "$lcq" =~ (^|[[:space:]])[.\&][[:space:]]*[\(\$] ]]; then + return 0 + fi scan=$(ps::blank_quoted_spans "$PS_BLANKED") # Delete backticks before matching so a name obfuscated by PowerShell's escape @@ -412,8 +424,13 @@ ps::write_bypass() { case "$head" in echo | write | write-output | write-host | "${PS_HERESTRING_PLACEHOLDER,,}") return 0 ;; '$'*) return 0 ;; # a variable / subexpression value redirected to a file + '['*) return 0 ;; # a cast/type expression value ([char]65 > f) *) ;; esac + # A bare numeric expression is a value write (`36 > out.txt` writes "36"). + # Only the SPACED form — an attached digit prefix (`2>err.txt`, `2>&1`) is a + # stream redirect whose producer is the preceding tool, not a value. + [[ "$head" =~ ^[0-9]+([.][0-9]+)?$ ]] && return 0 done <<<"$norm" return 1 } From 7d82a009950221195fce7d617b065f5cac44b301 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:50:21 -0400 Subject: [PATCH 08/10] fix(guardrails): close round-6 PowerShell classifier holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot round 6, all verified live: (1) a quoted string ending in the characters @' read as a here-string opener, swallowing following code lines into a phantom body (git/write lines unseen — P1); the opener test now strips paired quote spans first. (2) Backslash path-qualified git (C:\Git\cmd\git.exe reset --hard) tokenized through Bash escapes to a non-git word (P1); the reduced command now normalizes \ to / so basename matching works, keeping safe path-qualified calls allowed. (3)+(5) The call/dot-source probes (git side and both write-gate checks) only fired after whitespace, missing separator-adjacent forms (;& ('g'+'it') …). (4) Non-success stream producers (Write-Error 2>, Write-Warning 3>, verbose/debug/information) were not counted as redirect content writes. All red-first; suites 251/157 green. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 10 +++++ .../hooks/block-dangerous-git.test.sh | 14 +++++++ .../hooks/block-hook-bypass.test.sh | 7 ++++ .../guardrails/lib/powershell/ps-command.sh | 42 ++++++++++++++----- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 61070426a..0a048ad30 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -67,6 +67,16 @@ All notable changes to the `guardrails` plugin are documented here. Format follo same way iex does; and an expression-literal redirect producer (`36 > out.txt`, `[char]65 > out.txt` — spaced value writes, not attached-digit stream redirects) counts as a content write. +- **Review round 6:** a quoted string merely ending in the characters `@'`/`@"` + (`Write-Output '@'`) no longer reads as a here-string opener — paired quote + spans are stripped before the opener test, so following code lines cannot be + swallowed into a phantom body; backslash path separators normalize to forward + slashes in the reduced command so a path-qualified `C:\Git\cmd\git.exe reset + --hard` tokenizes to basename git (a safe `…\git.exe status` stays allowed); + the call/dot-source probes and both write-gate call checks accept a + statement/block separator boundary (`;& …`, `{& …}`), not only whitespace; + and every stream's producer cmdlet (`Write-Error … 2>`, `Write-Warning … 3>`, + verbose/debug/information) counts as a redirect content write. - **The PowerShell coverage bar is documented as Bash-parity, not airtight.** These guards are accidental-destruction friction, not a boundary against deliberate evasion — and the Bash guard they extend does not stop deliberate evasion either. diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 4e9541e17..00832cd04 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -372,4 +372,18 @@ run_pwsh "PS: Start-Process -FilePath computed target (fail-closed block)" \ run_pwsh "PS: launcher with variable target (fail-closed block)" \ 'saps $tool -ArgumentList "reset --hard"' 2 +# Review round 6: quoted-string '@' is not a here-string opener; backslash +# path-qualified git normalizes for the tokenizer; separator-adjacent call +# operators are git-capable. +run_pwsh "PS: quoted '@' does not open a here-string (git line not swallowed)" \ + "$(printf "Write-Output '@'\ngit reset --hard\n'@'")" 2 +run_pwsh "PS: backslash path-qualified git.exe (blocked)" \ + 'C:\Git\cmd\git.exe reset --hard' 2 +run_pwsh "PS: relative .\\git.exe (blocked)" \ + '.\git.exe reset --hard' 2 +run_pwsh "PS: backslash path-qualified git.exe, safe op (allowed)" \ + 'C:\Git\cmd\git.exe status' 0 +run_pwsh "PS: semicolon-adjacent computed call (fail-closed block)" \ + "Write-Host ok;& ('g'+'it') reset --hard" 2 + report diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 9c4f1f430..52f336bd7 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -451,6 +451,13 @@ run_pwsh "PS: & computed writer name (fail-closed block)" \ "& ('Set-'+'Content') -Path f.txt -Value x" 2 run_pwsh "PS: spaced numeric is a value write, tool redirect still allowed" \ "git diff 2> err.txt" 0 +# Review round 6: non-success stream producers and separator-adjacent calls. +run_pwsh "PS: Write-Error 2> file (blocked)" "Write-Error secret 2> creds.txt" 2 +run_pwsh "PS: Write-Warning 3> file (blocked)" "Write-Warning secret 3> creds.txt" 2 +run_pwsh "PS: semicolon-adjacent & 'Set-Content' (blocked)" \ + "Write-Host ok;& 'Set-Content' -Path f.txt -Value x" 2 +run_pwsh "PS: quoted '@' not a here-string opener (write line not swallowed)" \ + "$(printf "Write-Output '@'\nSet-Content -Path f.txt -Value x\n'@'")" 2 # The block message is shell-agnostic (no 'Bash' assumption). psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 4ca409892..4855d9989 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -93,7 +93,7 @@ PS_SAFE_COMMAND="" # left as the original command and the caller fails closed). ps::blank_herestrings() { local cmd="$1" - local line out="" pending="" in_hs=0 hs_quote="" first2 rest closer + local line out="" pending="" in_hs=0 hs_quote="" first2 rest closer opener_scan PS_HERESTRING_UNBALANCED=0 while IFS= read -r line || [[ -n "$line" ]]; do @@ -112,8 +112,14 @@ ps::blank_herestrings() { # A body line (no column-zero closer) is dropped. continue fi - # An opener is `@'` or `@"` as the final two characters of the line. - if [[ "$line" == *"@'" || "$line" == *'@"' ]]; then + # An opener is `@'` or `@"` as the final two characters of the line — as a + # TOKEN, not as the tail of an ordinary quoted string (`Write-Output '@'` + # ends in the characters @' but is a plain string; treating it as an opener + # would swallow following code lines into a phantom here-string body). + # Distinguish by stripping PAIRED quote spans first: a real opener's quote + # is unpaired, so its `@'` survives, while `'@'` / `'foo@'` disappear. + opener_scan=$(printf '%s' "$line" | sed "s/'[^']*'//g" | sed -E 's/"([^"\\]|\\.)*"//g') + if [[ "$opener_scan" == *"@'" || "$opener_scan" == *'@"' ]]; then hs_quote="${line: -1}" # ' or " pending="${line%??}${PS_HERESTRING_PLACEHOLDER}" in_hs=1 @@ -190,7 +196,9 @@ ps::might_invoke_git() { [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression)([^[:alnum:]_-]|$) ]] && return 0 # Call / dot-source of a variable, subexpression, or string literal: # `& $x …`, `& (…)`, `& 'git …'`, `. $x …` — the target runs as a command. - [[ "$lc" =~ (^|[[:space:]])[.\&][[:space:]]*[\$\($q] ]] && return 0 + # The call operator is also valid immediately after a statement/block + # separator (`;& …`, `{& …}`, `|& …`), not only after whitespace. + [[ "$lc" =~ (^|[[:space:]\;\{\}\(\|\&])[.\&][[:space:]]*[\$\($q] ]] && return 0 # A launcher whose program is a computed expression or variable — # `Start-Process ('g'+'it') …`, `saps $tool …`, optionally behind one named # parameter (`-FilePath (…)`) — may evaluate to git; it cannot be proven @@ -216,7 +224,7 @@ ps::has_dynamic_invocation() { local recovered="${1//\`/}" lc q="\"'" lc="${recovered,,}" [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression)([^[:alnum:]_-]|$) ]] && return 0 - [[ "$recovered" =~ (^|[[:space:]])[.\&][[:space:]]*[$q] ]] && return 0 + [[ "$recovered" =~ (^|[[:space:]\;\{\}\(\|\&])[.\&][[:space:]]*[$q] ]] && return 0 return 1 } @@ -270,9 +278,16 @@ ps::classify_git_command() { ps::might_invoke_git "$PS_BLANKED" && return 2 return 1 fi + # Backslash is a PATH SEPARATOR in PowerShell (its escape char is the + # backtick, which already routes to the sink above), but the Bash tokenizer + # this reduced command is handed to consumes `\` as an escape — so a + # path-qualified `C:\Git\cmd\git.exe reset --hard` would tokenize to a word + # whose basename never matches git. Normalize to forward slashes so + # hook::git_is_bin sees the real basename (and a safe `…\git.exe status` + # stays allowed rather than blanket-blocked). # Read by the sourcing guard, not within this library. # shellcheck disable=SC2034 - PS_SAFE_COMMAND="$PS_BLANKED" + PS_SAFE_COMMAND="${PS_BLANKED//\\//}" return 0 } @@ -332,14 +347,16 @@ ps::write_bypass() { # quoted-command-word residual the Bash guard carries. lcq="${PS_BLANKED//\`/}" lcq="${lcq,,}" - if [[ "$lcq" =~ (^|[[:space:]])[.\&][[:space:]]*[$q]([a-z.]+\\)?(set-content|add-content|out-file|tee-object|ac|tee|iex|invoke-expression|new-item|ni|epcsv|export-[a-z]+) ]]; then + if [[ "$lcq" =~ (^|[[:space:]\;\{\}\(\|\&])[.\&][[:space:]]*[$q]([a-z.]+\\)?(set-content|add-content|out-file|tee-object|ac|tee|iex|invoke-expression|new-item|ni|epcsv|export-[a-z]+) ]]; then return 0 fi # A call/dot-source of a COMPUTED target — `& ('Set-'+'Content') …`, `& $w …` # — evaluates an expression into the command name; it cannot be proven # non-writer, so it fails closed like iex (review round 5). Mirrors - # ps::might_invoke_git's treatment of the same shape on the git side. - if [[ "$lcq" =~ (^|[[:space:]])[.\&][[:space:]]*[\(\$] ]]; then + # ps::might_invoke_git's treatment of the same shape on the git side. Both + # this and the quoted-writer check above accept a statement/block separator + # boundary (`;& …`), not only whitespace (review round 6). + if [[ "$lcq" =~ (^|[[:space:]\;\{\}\(\|\&])[.\&][[:space:]]*[\(\$] ]]; then return 0 fi @@ -422,7 +439,12 @@ ps::write_bypass() { *) ;; esac case "$head" in - echo | write | write-output | write-host | "${PS_HERESTRING_PLACEHOLDER,,}") return 0 ;; + # Producer cmdlets for EVERY stream — a non-success stream redirected to a + # file (`Write-Error secret 2> creds.txt`, `Write-Warning x 3> f`) writes + # that stream's content exactly as a success-stream redirect does. + echo | write | write-output | write-host | write-error | write-warning | \ + write-verbose | write-debug | write-information | \ + "${PS_HERESTRING_PLACEHOLDER,,}") return 0 ;; '$'*) return 0 ;; # a variable / subexpression value redirected to a file '['*) return 0 ;; # a cast/type expression value ([char]65 > f) *) ;; From 72ee510f525de6de9cc24abef52fe334dc310388 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:21:10 -0400 Subject: [PATCH 09/10] =?UTF-8?q?fix(guardrails):=20round-7=20classifier?= =?UTF-8?q?=20fixes=20=E2=80=94=20fd-dup=20plumbing,=20script-block=20prod?= =?UTF-8?q?ucers,=20POSIX=20git.exe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) The round-5 numeric-producer check over-blocked tool captures: the & inside 2>&1 split a phantom "1 > file" segment; fd-dup merge redirects now strip before segmenting. (2) Invoked script blocks (& { Write-Output secret } > f) unwrap like parenthesized producers; grouped tool runs stay allowed. (3) CI (Linux) exposed that git.exe basenames only matched on the msys branch of hook::git_is_bin — the PS reduction now normalizes .exe-suffixed git spellings so the path-qualified regressions hold on a POSIX-run hook too. All red-first; 162/251/90 green. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 8 ++++++ .../hooks/block-hook-bypass.test.sh | 10 ++++++++ .../guardrails/lib/powershell/ps-command.sh | 25 ++++++++++++++----- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 0a048ad30..0fd10c3ec 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -77,6 +77,14 @@ All notable changes to the `guardrails` plugin are documented here. Format follo statement/block separator boundary (`;& …`, `{& …}`), not only whitespace; and every stream's producer cmdlet (`Write-Error … 2>`, `Write-Warning … 3>`, verbose/debug/information) counts as a redirect content write. +- **Review round 7:** fd-dup merge redirects (`2>&1`, `*>&1`) strip before + segment splitting, so a tool capture (`git status 2>&1 > out.txt`) is no + longer cut into a phantom numeric segment and wrongly blocked (over-block + regression from round 5); invoked script blocks unwrap like parenthesized + producers (`& { Write-Output secret } > f` blocks, `& { git diff } > f` + stays allowed); and `.exe`-suffixed git spellings normalize in the reduced + command so the POSIX hook matches the basename too (`C:\Git\cmd\git.exe + reset --hard` blocks on a Linux-run hook, not only under msys). - **The PowerShell coverage bar is documented as Bash-parity, not airtight.** These guards are accidental-destruction friction, not a boundary against deliberate evasion — and the Bash guard they extend does not stop deliberate evasion either. diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 52f336bd7..4c47124a2 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -459,6 +459,16 @@ run_pwsh "PS: semicolon-adjacent & 'Set-Content' (blocked)" \ run_pwsh "PS: quoted '@' not a here-string opener (write line not swallowed)" \ "$(printf "Write-Output '@'\nSet-Content -Path f.txt -Value x\n'@'")" 2 +# Review round 7: fd-dup merge redirects are plumbing, not producers; invoked +# script blocks are unwrapped like parenthesized producers. +run_pwsh "PS: tool capture with 2>&1 > file (allowed)" "git status 2>&1 > out.txt" 0 +run_pwsh "PS: Get-ChildItem 2>&1 > file (allowed)" "Get-ChildItem 2>&1 > out.txt" 0 +run_pwsh "PS: echo with 2>&1 > file (still a producer, blocked)" "echo x 2>&1 > f.txt" 2 +run_pwsh "PS: & { Write-Output secret } > file (blocked)" \ + "& { Write-Output secret } > creds.txt" 2 +run_pwsh "PS: & { git diff } > file (tool producer, allowed)" \ + "& { git diff } > out.txt" 0 + # The block message is shell-agnostic (no 'Bash' assumption). psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) assert_contains "PS write block names Write/Edit" "$psout" "Write or Edit tool" diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 4855d9989..4f40e81e0 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -284,10 +284,15 @@ ps::classify_git_command() { # path-qualified `C:\Git\cmd\git.exe reset --hard` would tokenize to a word # whose basename never matches git. Normalize to forward slashes so # hook::git_is_bin sees the real basename (and a safe `…\git.exe status` - # stays allowed rather than blanket-blocked). + # stays allowed rather than blanket-blocked). The `.exe` suffix (any case) + # normalizes away here too: a PowerShell command carries Windows spellings + # regardless of which OS the HOOK runs on, and hook::git_is_bin strips + # `.exe` only on its msys/cygwin branch. + local reduced="${PS_BLANKED//\\//}" + reduced=$(printf '%s' "$reduced" | sed -E 's/[Gg][Ii][Tt]\.[Ee][Xx][Ee]/git/g') # Read by the sourcing guard, not within this library. # shellcheck disable=SC2034 - PS_SAFE_COMMAND="${PS_BLANKED//\\//}" + PS_SAFE_COMMAND="$reduced" return 0 } @@ -421,17 +426,25 @@ ps::write_bypass() { # Producer-scoped redirect. Split the quote-stripped text into pipeline / # statement segments; a stripped leading string literal leaves the segment # starting at its `>`, which is itself the content-emitter signal. + # fd-dup merge redirects (`2>&1`, `*>&1`) are plumbing, not producers — strip + # them BEFORE splitting, or the `&` inside `2>&1` cuts a phantom `1 > file` + # segment that the numeric-producer test would wrongly block + # (`git status 2>&1 > out.txt` is a tool capture, not a content write). + lcs=$(printf '%s' "$lcs" | sed -E 's/[0-9*]*>&[0-9]+//g') local norm="${lcs//[|;&]/$'\n'}" while IFS= read -r seg; do seg="${seg#"${seg%%[![:space:]]*}"}" # ltrim [[ "$seg" == *'>'* ]] || continue # Exclude the `$null` discard (PowerShell's /dev/null). [[ "$seg" =~ \>\>?[[:space:]]*\$null([[:space:]]|$) ]] && continue - # Unwrap grouping parens so a parenthesized producer is judged by what it - # produces: `('secret') > f` (quote-stripped to `() > f`) reduces to the - # leading-literal case, `(write-output x) > f` to its real head, and a - # grouped tool run (`(git diff) > f`) stays the tool-producer allow. + # Unwrap grouping parens AND script-block braces so a grouped producer is + # judged by what it produces: `('secret') > f` (quote-stripped to `() > f`) + # reduces to the leading-literal case, `(write-output x) > f` and + # `& { write-output x } > f` to their real heads, and a grouped tool run + # (`(git diff) > f`, `& { git diff } > f`) stays the tool-producer allow. seg="${seg//[()]/}" + seg="${seg//\{/}" + seg="${seg//\}/}" seg="${seg#"${seg%%[![:space:]]*}"}" # re-ltrim after unwrap head="${seg%%[[:space:]]*}" case "$seg" in From 8674de09aae7ab186d5bde7f94db9b881d5c93a5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:34:59 -0400 Subject: [PATCH 10/10] fix(guardrails): module-qualified redirect producers compare by basename Round-8 bot finding: Microsoft.PowerShell.Utility\Write-Output secret > f.txt fell through the producer head check because the module prefix defeated the exact-name case match. The head now compares its cmdlet basename. Red-first regressions for the success- and error-stream forms. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 3 +++ plugins/guardrails/hooks/block-hook-bypass.test.sh | 6 ++++++ plugins/guardrails/lib/powershell/ps-command.sh | 3 +++ 3 files changed, 12 insertions(+) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 0fd10c3ec..77b731df1 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -85,6 +85,9 @@ All notable changes to the `guardrails` plugin are documented here. Format follo stays allowed); and `.exe`-suffixed git spellings normalize in the reduced command so the POSIX hook matches the basename too (`C:\Git\cmd\git.exe reset --hard` blocks on a Linux-run hook, not only under msys). +- **Review round 8:** a module-qualified redirect producer + (`Microsoft.PowerShell.Utility\Write-Output secret > f.txt`) compares by + cmdlet basename, closing the last spelling gap in the producer head check. - **The PowerShell coverage bar is documented as Bash-parity, not airtight.** These guards are accidental-destruction friction, not a boundary against deliberate evasion — and the Bash guard they extend does not stop deliberate evasion either. diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 4c47124a2..d9dd1ae38 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -469,6 +469,12 @@ run_pwsh "PS: & { Write-Output secret } > file (blocked)" \ run_pwsh "PS: & { git diff } > file (tool producer, allowed)" \ "& { git diff } > out.txt" 0 +# Review round 8: module-qualified producer heads. +run_pwsh "PS: module-qualified Write-Output > file (blocked)" \ + "Microsoft.PowerShell.Utility\\Write-Output secret > f.txt" 2 +run_pwsh "PS: module-qualified Write-Error 2> file (blocked)" \ + "Microsoft.PowerShell.Utility\\Write-Error secret 2> f.txt" 2 + # The block message is shell-agnostic (no 'Bash' assumption). psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) assert_contains "PS write block names Write/Edit" "$psout" "Write or Edit tool" diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 4f40e81e0..ca087d8d1 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -447,6 +447,9 @@ ps::write_bypass() { seg="${seg//\}/}" seg="${seg#"${seg%%[![:space:]]*}"}" # re-ltrim after unwrap head="${seg%%[[:space:]]*}" + # A module-qualified producer (`Microsoft.PowerShell.Utility\Write-Output`) + # is the same cmdlet — compare its basename. + head="${head##*\\}" case "$seg" in '>'*) return 0 ;; # leading literal (string stripped away) was the producer *) ;;