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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "guardrails",
"version": "0.10.2",
"version": "0.10.3",
"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",
Expand Down
103 changes: 103 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,109 @@
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.10.3]

### 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 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 unparsable 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` (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).
- **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.
- **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.
- **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.
- **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).
- **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.
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

- **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.10.2]

### Changed
Expand Down
34 changes: 31 additions & 3 deletions plugins/guardrails/hooks/block-dangerous-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -64,13 +73,14 @@ hook::require_jq "PreToolUse" "guardrails-block-dangerous-git" "$INPUT"

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
Expand All @@ -79,8 +89,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:-}"
}

Expand Down Expand Up @@ -735,6 +745,24 @@ 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 classifier's sink is
# 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 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_unparsable_git_block_message
emit_tel "blocked" "powershell-unparsable"
exit 2
;;
1) exit 0 ;; # non-git PowerShell with an A2b-deferred construct
*) COMMAND="$PS_SAFE_COMMAND" ;;
esac

hook::bash_parse_segments "$COMMAND" check_segment

emit_tel "ok" ""
Expand Down
70 changes: 70 additions & 0 deletions plugins/guardrails/hooks/block-dangerous-git.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -316,4 +316,74 @@ else
bad "telemetry: no envelope written on block"
fi

# --- 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() {
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

# This guard owns destructive non-commit forms (reset/clean/checkout/restore), so
# 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
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 unparsable PowerShell command is not this guard's
# concern — no over-block past git.
# shellcheck disable=SC2016
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
# 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

# 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
35 changes: 31 additions & 4 deletions plugins/guardrails/hooks/block-hook-bypass.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -62,6 +70,7 @@ hook::require_jq "PreToolUse" "guardrails-block-hook-bypass" "$INPUT"

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:<first-token>` with leading `sudo` /
# env-assignment prefixes stripped and the token basenamed. Never the full
Expand All @@ -78,7 +87,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
Expand All @@ -87,8 +100,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:-}"
}

Expand Down Expand Up @@ -414,11 +427,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
Expand Down
Loading