From 3f0896487433e3503713b87784056d8ce71afcbc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:49:35 -0400 Subject: [PATCH 1/6] fix(guardrails): measure the lease width in the repository the push runs in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `block-dangerous-git` accepts a `--force-with-lease=:` whose `` is a full-width object id, because git cannot resolve one to something newer at push time. The width is the local repository's, and hex of the OTHER width is an ordinary movable ref name there — so the probe has to measure the repository the push actually runs in. It measured the HOOK PROCESS's directory. Claude Code launches hooks from the session root and runs the Bash tool wherever the session stands, so a payload cwd in a SHA-256 repository with the hook process in a SHA-1 one cleared a 40-hex lease that git resolves as a ref name where the push lands. No wrapper and no `cd` were needed; a plain `git push` sufficed. The payload's `.cwd` is now read and replayed as a LEADING `-C` ahead of any wrapper chdir, composing under git's own rules exactly as the wrapper replay already did. The base chain is `HOOK_EFFECTIVE_BASE` -> `HOOK_CWD` -> `CLAUDE_PROJECT_DIR` -> `.`, adopted from `block-noncanonical-commit` rather than invented a second time, and a `!` shell alias relocates the base for its reparse the same way — git launches that body in the relocated repository. `env -S` / `--split-string` was a second route to the same harm, and to worse. `-S` exists so a shebang line can pass OPTIONS to env, so the split words are env's own arguments; `hook::git_resolve_index` spliced them in but resumed at the COMMAND dispatcher, which read a leading option in the split string as the command name and abandoned the segment. `env -S '-C git push --force'` resolved to no git at all. Parsing now resumes inside env's option loop, which also keeps env's single chdir slot last-wins across the splice. Behaviour change, intended: a RELATIVE `-C` / `--git-dir` / `--work-tree` / `--namespace` now resolves against the directory the tool call runs in rather than the hook process's. An absolute one is unaffected. The `repo_oid_width` known-gap docblock is restated at its real width; the old wording listed three conjuncts for a gap that then required none of them. Co-Authored-By: Claude Opus 5 (1M context) --- lib/hook-utils.sh | 30 +++- lib/hook-utils.test.sh | 20 +++ plugins/actionlint/.claude-plugin/plugin.json | 2 +- plugins/actionlint/CHANGELOG.md | 14 ++ plugins/actionlint/hooks/hook-utils.sh | 30 +++- plugins/autonomy/.claude-plugin/plugin.json | 2 +- plugins/autonomy/CHANGELOG.md | 14 ++ plugins/autonomy/hooks/hook-utils.sh | 30 +++- .../bash-format/.claude-plugin/plugin.json | 2 +- plugins/bash-format/CHANGELOG.md | 14 ++ plugins/bash-format/hooks/hook-utils.sh | 30 +++- .../biome-format/.claude-plugin/plugin.json | 2 +- plugins/biome-format/CHANGELOG.md | 14 ++ plugins/biome-format/hooks/hook-utils.sh | 30 +++- plugins/claude-ops/.claude-plugin/plugin.json | 10 +- plugins/claude-ops/CHANGELOG.md | 14 ++ plugins/claude-ops/hooks/hook-utils.sh | 30 +++- .../context-guard/.claude-plugin/plugin.json | 2 +- plugins/context-guard/CHANGELOG.md | 14 ++ plugins/context-guard/hooks/hook-utils.sh | 30 +++- .../.claude-plugin/plugin.json | 2 +- plugins/desktop-notification/CHANGELOG.md | 14 ++ .../desktop-notification/hooks/hook-utils.sh | 30 +++- .../eol-normalizer/.claude-plugin/plugin.json | 2 +- plugins/eol-normalizer/CHANGELOG.md | 14 ++ plugins/eol-normalizer/hooks/hook-utils.sh | 30 +++- plugins/go-format/.claude-plugin/plugin.json | 2 +- plugins/go-format/CHANGELOG.md | 14 ++ plugins/go-format/hooks/hook-utils.sh | 30 +++- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 44 ++++++ .../guardrails/hooks/block-dangerous-git.sh | 145 +++++++++++++++--- .../hooks/block-dangerous-git.test.sh | 101 +++++++++++- plugins/guardrails/hooks/hook-utils.sh | 30 +++- .../.claude-plugin/plugin.json | 2 +- plugins/markdown-format/CHANGELOG.md | 14 ++ plugins/markdown-format/hooks/hook-utils.sh | 30 +++- .../.claude-plugin/plugin.json | 2 +- plugins/powershell-format/CHANGELOG.md | 14 ++ plugins/powershell-format/hooks/hook-utils.sh | 30 +++- .../.claude-plugin/plugin.json | 2 +- plugins/rate-limit-guard/CHANGELOG.md | 14 ++ plugins/rate-limit-guard/hooks/hook-utils.sh | 30 +++- .../ruff-format/.claude-plugin/plugin.json | 2 +- plugins/ruff-format/CHANGELOG.md | 14 ++ plugins/ruff-format/hooks/hook-utils.sh | 30 +++- .../source-control/.claude-plugin/plugin.json | 2 +- plugins/source-control/CHANGELOG.md | 14 ++ plugins/source-control/hooks/hook-utils.sh | 30 +++- .../typos-format/.claude-plugin/plugin.json | 2 +- plugins/typos-format/CHANGELOG.md | 14 ++ plugins/typos-format/hooks/hook-utils.sh | 30 +++- 52 files changed, 927 insertions(+), 143 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index dad12db68..805f68da6 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 5c4f1c247..1f6caeb79 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -2048,6 +2048,26 @@ resolve_dirs_are "sudo --chdir=DIR reports the chdir" "other" sudo --chdir=other resolve_dirs_are "sudo -C fd is not a chdir" "" sudo -C 3 git commit # Nested wrappers each contribute, in execution order, for the caller to compose. resolve_dirs_are "nested wrappers report both chdirs in order" "a|b" env -C a sudo -D b git commit +# `-S` exists so a shebang line can pass OPTIONS to env (`#!/usr/bin/env -S -i +# prog`), so the split words are env's own arguments and parsing must resume +# inside env's option loop. Resuming at the command dispatcher read a leading +# option in the split string as the COMMAND NAME and abandoned the segment +# entirely — the resolver reported no git, and every guard skipped the command. +resolve_dirs_are "env -S splices a chdir that belongs to env" "other" env -S '-C other git commit' +resolve_dirs_are "env --split-string= splices a chdir that belongs to env" "other" env --split-string='-C other git commit' +resolve_dirs_are "env -S with an attached operand splices the chdir" "other" env "-S-C other git commit" +resolve_dirs_are "env -S with no leading option still resolves git" "" env -S 'git commit' +# One env, one chdir slot: a -C inside the split string is last-wins against an +# earlier one outside it, not cumulative. +resolve_dirs_are "env -C first -S '-C second …' is last-wins in the one slot" "second" env -C first -S '-C second git commit' +# A valueless clustered option inside the split string must not swallow the chdir. +resolve_dirs_are "env -S '-v -C DIR git …' keeps the chdir" "other" env -S '-v -C other git commit' +# Termination: a self-referential -S consumes itself rather than looping. +if hook::git_resolve_index env -S '-S -S'; then + fail "env -S '-S -S' should resolve no git, resolved at $HOOK_GIT_RESOLVED_GI" +else + ok "a self-referential env -S terminates and resolves no git" +fi # --- resolve_read_slice: shell fixed-point division --------------------------- # The slice is produced by shell arithmetic rather than an awk spawn, and its diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index 6fccf6e68..12701e70b 100644 --- a/plugins/actionlint/.claude-plugin/plugin.json +++ b/plugins/actionlint/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "actionlint", - "version": "0.8.1", + "version": "0.8.2", "description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.", "author": { "name": "Melodic Software", diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index 8fcf285f7..133809ebb 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.8.1] ### Fixed diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 295ed1393..76cef3c4b 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.14.1", + "version": "0.14.2", "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index 37f91b59f..7b99bb46a 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to the `autonomy` plugin are documented here. Format follows Versions 0.1.0–0.7.0 predate this file (introduced with 0.7.1); their history lives in the merged work-package PRs (#333, #343, #356, #372, #377, #600, #676). +## [0.14.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.14.1] ### Fixed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index 536bfbcfa..e1638ec4f 100644 --- a/plugins/bash-format/.claude-plugin/plugin.json +++ b/plugins/bash-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "bash-format", - "version": "0.7.1", + "version": "0.7.2", "description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.", "author": { "name": "Melodic Software", diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index 80e8f5db8..970ae13da 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.7.1] ### Fixed diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 408e4d47d..ed0a0fb4b 100644 --- a/plugins/biome-format/.claude-plugin/plugin.json +++ b/plugins/biome-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "biome-format", - "version": "0.6.1", + "version": "0.6.2", "description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 2d9c8c7e3..f44086f90 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.6.1] ### Fixed diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index b15f7b984..32eedc98a 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.28.1", - "description": "Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", + "version": "0.28.2", + "description": "Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action — an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" @@ -37,7 +37,7 @@ "skill_usage_scope": { "type": "string", "title": "Skill-usage log scope", - "description": "Where the skill-usage store lives. Valid values: \"repo\" (default \u2014 project tree under the repo root, kept out of git status via a machine-local .git/info/exclude entry), \"user\" (the skill_usage_dir subpath under $HOME, one cross-repo store; rows carry a project field), \"data-dir\" (${CLAUDE_PLUGIN_DATA}/skill-usage/, plugin-owned and update-safe). The manifest schema has no enum type, so this validates in prose; any other value is treated as \"repo\" with a one-time advisory.", + "description": "Where the skill-usage store lives. Valid values: \"repo\" (default — project tree under the repo root, kept out of git status via a machine-local .git/info/exclude entry), \"user\" (the skill_usage_dir subpath under $HOME, one cross-repo store; rows carry a project field), \"data-dir\" (${CLAUDE_PLUGIN_DATA}/skill-usage/, plugin-owned and update-safe). The manifest schema has no enum type, so this validates in prose; any other value is treated as \"repo\" with a one-time advisory.", "default": "repo" }, "skill_usage_git_exclude": { @@ -49,7 +49,7 @@ "install_new": { "type": "string", "title": "New-plugin install policy for the plugins skill's sync action", - "description": "Controls what `sync` does with catalog plugins that aren't installed yet. Valid values: \"ask\" (default \u2014 offer them in one batched multi-select prompt), \"all\" (install every one automatically), \"none\" (report only, never install). The manifest schema has no enum type, so this validates in prose, not JSON Schema; any other value is treated as \"ask\".", + "description": "Controls what `sync` does with catalog plugins that aren't installed yet. Valid values: \"ask\" (default — offer them in one batched multi-select prompt), \"all\" (install every one automatically), \"none\" (report only, never install). The manifest schema has no enum type, so this validates in prose, not JSON Schema; any other value is treated as \"ask\".", "default": "ask" }, "api_error_audit_enabled": { @@ -103,7 +103,7 @@ "stdin_read_timeout": { "type": "number", "title": "Hook stdin read timeout (seconds)", - "description": "Idle bound on reading the hook payload from stdin \u2014 how long the pipe may go silent before the hook gives up and fails open", + "description": "Idle bound on reading the hook payload from stdin — how long the pipe may go silent before the hook gives up and fails open", "default": 2, "min": 1 } diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 2dad1e25d..a27245973 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.28.1] ### Fixed diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index b63006407..ddc4f0dd7 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "context-guard", - "version": "0.6.1", + "version": "0.6.2", "description": "Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels — the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index a5aaee54e..a37e1809b 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.6.1] ### Fixed diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index dad12db68..805f68da6 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 10e5465e2..ab71e24ff 100644 --- a/plugins/desktop-notification/.claude-plugin/plugin.json +++ b/plugins/desktop-notification/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "desktop-notification", - "version": "0.6.1", + "version": "0.6.2", "description": "Alert you when Claude Code needs input — an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.", "author": { "name": "Melodic Software", diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index 19936e724..8e889dd15 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.6.1] ### Fixed diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 9ba9b14b7..50e8b2837 100644 --- a/plugins/eol-normalizer/.claude-plugin/plugin.json +++ b/plugins/eol-normalizer/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "eol-normalizer", - "version": "0.6.1", + "version": "0.6.2", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit — symmetric CRLF/LF driven by git check-attr, advisory and never blocking.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index 2f2247de7..5212e710e 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.6.1] ### Fixed diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index d1de41b5e..049cc8153 100644 --- a/plugins/go-format/.claude-plugin/plugin.json +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "go-format", - "version": "0.3.1", + "version": "0.3.2", "description": "Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files.", "author": { "name": "Melodic Software", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 078fc5bda..a2f09d4ed 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.2] + +### Fixed + +- **Shared `hook-utils.sh`: `env -S` / `--split-string` no longer hides a whole command from the + git guards (#2124).** `-S` exists so a shebang line can pass OPTIONS to env + (`#!/usr/bin/env -S -i prog`), so the words it splits out are env's own arguments. The resolver + spliced them back into the scan but resumed at the COMMAND dispatcher, which read a leading + option in the split string as the command NAME and gave up — `env -S '-C git push --force'` + resolved to no git at all, so every guard built on `hook::git_resolve_index` skipped the command + unexamined. Parsing now resumes inside env's own option loop. That also keeps env's single chdir + slot last-wins across the splice, so `env -C a -S '-C b git …'` reports `b`, matching GNU env. + Synced from `lib/hook-utils.sh`. + ## [0.3.1] ### Fixed diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index dad12db68..805f68da6 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -1147,10 +1147,28 @@ hook::git_resolve_index() { # -S/--split-string re-splits its operand into argv (GNU env), so a # quoted 'git commit --no-verify' would otherwise hide from the # resolver as one non-git word. Splice the split words back into the - # scan and restart at the command position. The splice drops every - # word before `i`, this `env` included, so a chdir already recorded for - # it is not re-walked and stays recorded — which is right, because env - # performs that chdir whether or not -S rewrites the command. + # scan and resume. The splice drops every word before `i`, this `env` + # included, so a chdir already recorded for it is not re-walked and + # stays recorded — which is right, because env performs that chdir + # whether or not -S rewrites the command. + # + # Resume INSIDE env's own option loop (`continue`, not `continue 2`), + # because the split words are env's OWN arguments: `-S` exists so a + # shebang line can carry env options, and GNU documents exactly that + # (`#!/usr/bin/env -S -i some-program`). Restarting at the command + # dispatcher instead read a leading option in the split string as the + # COMMAND NAME and abandoned the whole segment — `env -S '-C git + # push --force'` resolved to no git at all, so every guard skipped a + # real force-push, and `env -S '-C git push + # --force-with-lease=main:<40-hex>'` skipped a lease against a movable + # ref name. Staying in this loop also keeps `env_ci` in scope, so + # `env -C a -S '-C b git …'` is last-wins in the one slot GNU env + # keeps, exactly as an unspliced `env -C a -C b` already is. + # + # Termination: each splice consumes the `-S` word and its operand and + # substitutes only the operand's own words, so the argv's byte count + # strictly decreases — a self-referential `env -S '-S -S'` runs out + # rather than looping. -S | --split-string) local sval="" ((i + 1 < n)) && sval="${w[i + 1]}" @@ -1158,7 +1176,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") n=${#w[@]} i=0 - continue 2 + continue ;; -S* | --split-string=*) local sval="${etok#-S}" @@ -1167,7 +1185,7 @@ hook::git_resolve_index() { w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") n=${#w[@]} i=0 - continue 2 + continue ;; -C | --chdir) ((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}" diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index aa188ec7c..1abff997f 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.23.1", + "version": "0.24.0", "description": "Twelve 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, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 8718677d7..1ac08c4e5 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,50 @@ 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.24.0] + +### Fixed + +- **`block-dangerous-git` no longer clears an unsafe `--force-with-lease` by measuring the wrong + repository (#2124).** The lease check accepts a `=:` whose `` is a + full-width object id, because git cannot resolve one to something newer at push time. The width + is the local repository's, and the guard probed the HOOK PROCESS's directory to learn it. Claude + Code launches hooks from the session root and runs the Bash tool wherever the session stands, so + the two differ routinely — and a payload `cwd` in a SHA-256 repository with the hook process in a + SHA-1 one read a 40-hex lease as an immutable object id while git resolves it as a movable REF + NAME where the push actually runs. That is precisely the hole `--force-with-lease` exists to + close, and it needed no wrapper and no `cd`: a plain `git push` was enough. The payload's `.cwd` + is now read and replayed as a LEADING `-C` ahead of any wrapper chdir, so it composes under git's + own rules exactly as the wrapper replay already did. The base-resolution chain is + `HOOK_EFFECTIVE_BASE` → `HOOK_CWD` → `CLAUDE_PROJECT_DIR` → `.`, adopted verbatim from + `block-noncanonical-commit` rather than invented a second time; a `!` shell alias relocates the + base for its reparse and it is save/restored around each one, since git launches that body in the + relocated repository. +- **`env -S` / `--split-string` no longer hides a whole command from the git guards (#2124).** `-S` + exists so a shebang line can pass OPTIONS to env (`#!/usr/bin/env -S -i prog`), so the words it + splits out are env's own arguments. `hook::git_resolve_index` spliced them back into the scan but + resumed at the COMMAND dispatcher, which read a leading option in the split string as the command + NAME and gave up — `env -S '-C git push --force-with-lease=main:<40-hex>'` and even + a bare `env -S '-v git push --force'` resolved to no git at all, so the guard never examined + them. Parsing now resumes inside env's own option loop, which also keeps env's single chdir slot + last-wins across the splice (`env -C a -S '-C b git …'` reports `b`, as GNU env behaves). Synced + from `lib/hook-utils.sh`, so every carrying plugin gets it. + +### Changed + +- **A RELATIVE `--git-dir` / `--work-tree` / `--namespace` / `-C` in a guarded command now resolves + against the directory the TOOL CALL runs in, not the hook process's.** This falls out of the + leading-`-C` base above and is the correct origin — a relative path written in a tool call means + relative to where that call runs — but it is a behaviour change and is called out here so it is + not read as a regression. An ABSOLUTE one is unaffected. +- `repo_oid_width`'s known-gap docblock is restated at its real width. It described the residual as + needing "a SHA-256 repository, a lease pinned to a full-width hex word that is also a ref name + there, and a compound `cd` into it" — three conjuncts, when at the time the payload cwd was not + read at all and neither the wrapper nor the `cd` was required. Reading `.cwd` closes that route; + what remains is any SHELL relocation the static parser does not evaluate (`cd … && git push`, a + subshell, `pushd`), and the comment now says so plainly. A documented gap that reads narrower + than it is, is how this one survived review. + ## [0.23.1] ### Fixed diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index 92f4740b5..040baafe7 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -88,16 +88,26 @@ INPUT=$(hook::buffer_stdin) || { # (additionalContext), once per session — see docs/conventions/hook-observability/. hook::require_jq "PreToolUse" "guardrails-block-dangerous-git" "$INPUT" -# Both payload fields in ONE jq process (hook::jq_fields), not two. A jq spawn is -# ~140 ms of fork() emulation on Windows Git Bash and this guard runs on every -# Bash/PowerShell call. Failure semantics are unchanged: a missing jq or an +# All three payload fields in ONE jq process (hook::jq_fields), not three. A jq +# spawn is ~140 ms of fork() emulation on Windows Git Bash and this guard runs on +# every Bash/PowerShell call. Failure semantics are unchanged: a missing jq or an # unparsable payload yields rc 1 here, which exits 0 exactly as the empty-COMMAND # skip below did — hook::require_jq above has already made the degraded state # visible once per session. -hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# +# `.cwd` is the directory the TOOL CALL runs in, which is not the hook process's +# own: Claude Code launches hooks from the session root while the Bash tool runs +# the command wherever the session stands. Reading only the hook process's +# directory measured the wrong repository's hash format whenever the two differed +# — a payload cwd in a SHA-256 repository with the hook process in a SHA-1 one +# cleared a 40-hex lease that is a movable REF NAME where the push actually runs. +# block-noncanonical-commit has read this field since it shipped; this guard did +# not, and the same chain is adopted here rather than a second mechanism. +hook::jq_fields "$INPUT" '.tool_input.command' '.cwd' '.tool_name' || exit 0 COMMAND="${HOOK_JQ_FIELDS[0]}" [[ -n "$COMMAND" ]] || exit 0 -TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" +HOOK_CWD="${HOOK_JQ_FIELDS[1]}" +TOOL_NAME="${HOOK_JQ_FIELDS[2]:-Bash}" # 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 @@ -206,13 +216,19 @@ is_lease_opt() { abbrev_match "force-with-lease" "${1%%=*}" 7; } # lease against whatever it points at. Accepting the union would let either # shape through in the repository where it is a name. # -# Width of the repository THIS PUSH will run in, which is not always the hook's -# own directory: git's repository-locating global options (`-C`, `--git-dir`, -# `--work-tree`, `--namespace`) redirect it, and a `git -C push` -# issued from a SHA-1 directory must be judged by the target's format. Those -# options are replayed verbatim onto the probe rather than modelled, so git -# resolves the repository by its own rules — including several `-C` values, -# which git applies cumulatively. +# Width of the repository THIS PUSH will run in, which is not the hook process's +# own directory. Two things move it, and BOTH are replayed onto the probe: +# +# * The payload's `.cwd` — where the tool call runs. The hook process's +# directory is the session root, so the two differ routinely, and probing the +# hook's own is simply measuring a different repository. +# * git's repository-locating global options (`-C`, `--git-dir`, `--work-tree`, +# `--namespace`) and any wrapper chdir ahead of them: a `git -C +# push` issued from a SHA-1 directory must be judged by the target's format. +# +# Those options are replayed verbatim onto the probe rather than modelled, so git +# resolves the repository by its own rules — including several `-C` values, which +# git applies cumulatively. # # `-C` takes the value as a SEPARATE word: git rejects an attached `-C` # with its usage message (verified, git 2.54.0). The walk therefore mirrors @@ -223,12 +239,22 @@ is_lease_opt() { abbrev_match "force-with-lease" "${1%%=*}" 7; } # cannot resolve (an unexpanded `$VAR` reaches the probe literally and simply # fails). That fails closed. # -# Known gap: a compound `cd && git push …` pushes from a directory -# no option names, so the probe cannot see it. Resolving the cd target would -# mean evaluating arbitrary shell word expansion, which this guard deliberately -# does not do (static matching over the literal command string only). The -# residual case needs a SHA-256 repository, a lease pinned to a full-width hex -# word that is also a ref name there, and a compound cd into it. +# Known gap, stated at its real width: a SHELL relocation the static parser does +# not evaluate — `cd && git push …`, `(cd && git push …)`, +# `sh -c 'cd && git push …'`, `pushd` — pushes from a directory no +# option and no payload field names, so the probe cannot see it. Resolving the cd +# target would mean evaluating arbitrary shell word expansion, which this guard +# deliberately does not do (static matching over the literal command string only). +# +# The residual needs only that shell relocation into a repository whose hash +# format differs from the base's, plus a lease pinned to a full-width hex word +# that is a ref name at the destination. It needs no wrapper. An earlier wording +# here listed a "compound cd" as one of three conjuncts and read as far narrower +# than the gap was: at the time the payload cwd was not read at all, so NO cd and +# NO wrapper were required either — a plain `git push` from a session directory +# the hook process did not share was already enough (#2124). Reading `.cwd` +# closed that; the understatement is corrected here so the remaining gap is not +# re-measured from a description that undersells it. # # Resolved at most once per option set and only on the rare path that sees a hex # expectation — the guard shells out nowhere else. The result is assigned by a @@ -276,13 +302,32 @@ lease_expect_is_immutable() { # compose onto it: it is replayed as LEADING `-C` words, which git applies # cumulatively in argv order, and the composition then falls out of git's own # rules rather than being modelled here. +# +# The payload cwd is replayed the same way and sits AHEAD of the wrapper dirs, +# reproducing execution order end to end: the tool call starts in `.cwd`, a +# wrapper chdirs from there, and git's own globals apply last. Measured against +# real SHA-1/SHA-256 fixtures, a leading base composes exactly like the wrapper +# replay already shipping — `git -C -C ` rebases onto the base +# from ANY process directory, and a later absolute `-C` wins outright — so this +# introduces no new path semantics, only a first term. +# +# A `cd` is deliberately NOT used for the base: `cd` would move the hook process +# and leak across the recursive alias walk, while a leading `-C` is per-probe and +# composes under git's own rules. +# +# Collateral, and intended: a RELATIVE `--git-dir` / `--work-tree` / `--namespace` +# now rebases onto that base instead of onto the hook process's directory. That is +# the correct resolution — a relative path in the tool call means relative to +# where the tool call runs — and it is a behaviour change only in the sense that +# the previous answer was measured from the wrong origin. An ABSOLUTE one is +# unaffected. # shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain collect_git_locating_opts() { local gi="$1" sub_idx="$2" shift 2 local -a w=("$@") local j=$((gi + 1)) wdir - git_locating_opts=() + git_locating_opts=(-C "${HOOK_EFFECTIVE_BASE:-${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}}") for wdir in ${HOOK_GIT_RESOLVED_WRAPPER_DIRS[@]+"${HOOK_GIT_RESOLVED_WRAPPER_DIRS[@]}"}; do git_locating_opts+=(-C "$wdir") done @@ -304,6 +349,48 @@ collect_git_locating_opts() { done } +# Directory a segment's git actually runs in: the base with every `-C` in an +# already-collected option set composed onto it, left to right — an absolute +# value replaces, a relative one joins. Same rule and same shape as +# block-noncanonical-commit's effective_dir, so the two guards answer alike. +# +# Only `!` shell-alias reparsing needs this. git launches a `!` body as a fresh +# command in the relocated repository, and the reparse builds a NEW segment frame +# whose own locating options start empty — so without carrying the relocation +# forward as the reparse's base, the body's `git push` would be probed against the +# payload cwd while git runs it somewhere else. That is the same misprobe this +# whole change closes, one recursion level down. +# +# TEXTUAL join only, never `realpath`/`cd -P`: block-noncanonical-commit records +# that resolving symlinks is a bypass in both directions (lexical `x/..` is wrong +# under a POSIX symlink; physical resolution is wrong on Win32, where git itself +# is lexical). Handing the composed spelling to `git -C` lets git apply its own +# path semantics. +# +# It deliberately does NOT reproduce that guard's `alias_launch_dir` — the fork +# that asks git for `--show-toplevel`. That function exists to canonicalize a +# directory into a repository IDENTITY for a cycle key. Nothing here needs an +# identity: the only question asked downstream is which repository's hash format +# applies, and every directory inside one repository answers that identically, so +# the composed spelling is sufficient and costs no subprocess. +# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain +effective_dir() { + local base="${HOOK_EFFECTIVE_BASE:-${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}}" i n=$# arg + local -a a=("$@") + for ((i = 0; i < n; i++)); do + arg="${a[i]}" + if [[ "$arg" == "-C" ]] && ((i + 1 < n)); then + if [[ "${a[i + 1]}" == /* || "${a[i + 1]}" =~ ^[A-Za-z]:[\/] ]]; then + base="${a[i + 1]}" + else + base="$base/${a[i + 1]}" + fi + ((i++)) + fi + done + printf '%s' "$base" +} + # Has an earlier lease spelling in this same command already claimed ? # git's apply_cas() walks the --force-with-lease entries in command-line order # and RETURNS on the first whose refname matches the ref being updated, so a @@ -491,7 +578,7 @@ check_segment() { # and finite distinct alias keys guarantee termination. Terminating is not the # same as tractable — the walk branches per hop, and alias_reexpand_admit is what # keeps its cost proportional to the chain's length. - local exp reparse a alias_rc s seen_hit=0 + local exp reparse a alias_rc s seen_hit=0 saved_base="" local -a expw=() saved_seen=() nextw=() hook::git_alias_expansion "$sub" alias_rc=$? @@ -525,6 +612,7 @@ check_segment() { # the recursion so sibling segments and unwound hops start clean. # shellcheck disable=SC2154 # HOOK_GIT_ALIAS_EXPS is set by hook::git_alias_expansion saved_seen=(${HOOK_ALIAS_SEEN[@]+"${HOOK_ALIAS_SEEN[@]}"}) + saved_base="${HOOK_EFFECTIVE_BASE-}" HOOK_ALIAS_SEEN+=("$sub") for exp in ${HOOK_GIT_ALIAS_EXPS[@]+"${HOOK_GIT_ALIAS_EXPS[@]}"}; do [[ -n "$exp" ]] || continue @@ -539,11 +627,19 @@ check_segment() { # not stopped. Termination stays text-bounded — this guard resolves # only inline aliases, and every definition reachable from the reparse # is a strict substring of the parent segment's text. + # + # That new process also starts in THIS segment's relocated directory, so + # the body's own `-C` composes onto it and a body with none inherits it + # outright. Carry it as the reparse's base — dropping it probes the + # payload cwd while git pushes from the relocated repository, which is + # this guard's misprobe one recursion level down. reparse="${exp#!}" for a in "${w[@]:sub_idx+1}"; do reparse+=" $(printf '%q' "$a")"; done HOOK_ALIAS_SEEN=() + HOOK_EFFECTIVE_BASE="$(effective_dir ${git_locating_opts[@]+"${git_locating_opts[@]:2}"})" alias_reexpand_admit shell "$reparse" && hook::bash_parse_segments "$reparse" check_segment + HOOK_EFFECTIVE_BASE="$saved_base" HOOK_ALIAS_SEEN=(${saved_seen[@]+"${saved_seen[@]}"} "$sub") else # Git alias: its expansion is dequoted with shell quoting rules @@ -558,6 +654,7 @@ check_segment() { fi done HOOK_ALIAS_SEEN=(${saved_seen[@]+"${saved_seen[@]}"}) + HOOK_EFFECTIVE_BASE="$saved_base" fi case "$sub" in @@ -1111,6 +1208,14 @@ esac # line runs check_segment once per top-level segment, each starting from empty. HOOK_ALIAS_SEEN=() +# Directory the tool call runs in, and therefore the base every width probe is +# measured from. A `!` shell alias relocates it mid-parse, so it is save/restored +# around each reparse (see check_segment) rather than read fresh from the payload +# each time. Same chain as block-noncanonical-commit: the payload cwd, then +# CLAUDE_PROJECT_DIR, then `.` — the last of which reproduces the pre-#2124 +# behaviour for a payload that carries no cwd at all. +HOOK_EFFECTIVE_BASE="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}" + # The alias-traversal bounds (alias_reexpand_admit). Both are invocation-wide and # deliberately NOT save/restored: a state analyzed anywhere is analyzed, and the # budget bounds the whole command's work rather than one path's. diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 1d7f28734..f6aa420d3 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -32,12 +32,50 @@ git init -q --object-format=sha1 "$REPO_SHA1" || git init -q --object-format=sha256 "$REPO_SHA256" || bad "fixture: could not create the SHA-256 repository (git 2.29+ required)" +# A PreToolUse payload carries `cwd` — the directory the TOOL CALL runs in, which +# is not the hook process's own. The width probe is measured from it (#2124), so +# every case has to state it; the shared command_json builder omits the field. +command_json_cwd() { + MSYS_NO_PATHCONV=1 jq -n --arg c "$1" --arg d "$2" \ + '{tool_name:"Bash",tool_input:{command:$c},cwd:$d}' +} + # run_in