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..63211c4d2 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,100 @@ 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. +- **The alias re-expansion memo keys on the effective base, so a cached verdict cannot be reused + across repositories (#2124).** Caught in review of this change, and a defect this change itself + introduced: making the lease verdict a function of the base means the base has to be part of any + key that memoizes that verdict, and `HOOK_ALIAS_MEMO` keyed only on kind, seen-set and command + text. One Bash command invoking the SAME `!` alias text twice — first under a SHA-1 `git -C`, + where a 40-hex expectation is a real object id and is correctly allowed, then under a SHA-256 + `git -C`, where the identical word is a movable ref name — had its second analysis skipped as + already seen, and the guard exited 0. Verified against this branch's own pre-fix head rather than + `origin/main`, which has no base-dependent verdict to cache wrongly: the buggy tree runs the width + probe ONCE (`40`) and allows; the fixed tree runs it twice (`40`, then `64`) and blocks. The + other cache, `repo_oid_width`, was checked for the same class and is already base-keyed — its key + is the replayed option list, which now leads with the base — confirmed empirically, not by + inspection. `block-noncanonical-commit` keys its memo on the base for exactly this reason. + + The collision was unconditional rather than occasional: the `!` branch empties `HOOK_ALIAS_SEEN` + *before* the key is built, so the old key reduced to kind + a constant + the reparse text, and two + reparses of identical alias text collided at any depth, through `;` and `&&` alike. It could only + ever be a bypass, never a false block — a memo hit skips analysis, skipping can only turn DENY + into ALLOW, and the guard exits at the first blocking segment so nothing follows a DENY. + + **Cost, measured.** Keying on the base means the memo dedups less, so analyses now scale with the + number of DISTINCT bases in one command instead of collapsing to one. Counted from the `bash -x` + trace, distinct bases → analyses (width probes): old 1→1 (2), 4→1 (2), 16→1 (2), 32→1 (2); new + 1→1 (2), 4→4 (8), 16→16 (32), 32→32 (64). Linear, and that collapse to 1 was the defect, not an + optimization worth keeping. `HOOK_ALIAS_WORK_MAX` (128) still bounds it and exhausting it fails + CLOSED, so the weakened dedup costs work, never safety. A fixture pins 16 distinct bases as + allowed-and-bounded, and the same walk with a SHA-256 base appended as still blocked. + + Two things the reviewer flagged as reasoned-not-run are now run. The memo does not survive a hook + invocation — it is a shell variable in a process that exits, and the sha1-then-sha256 pair split + across two separate invocations gives 0 then 2. The git-alias branch shares the memo under a + different tag and is covered by construction, since the base is keyed inside + `alias_reexpand_admit` rather than at the call sites; no live case is constructible there, because + a git alias splices words into the same argv and cannot relocate the base. +- **`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). This is + the LARGER of the two holes and it was not lease-specific: an independent adversary confirmed + `block-no-verify` allowed `git commit --no-verify` and `block-dangerous-git` allowed + `git reset --hard` behind the same `env -S` form. `hook::git_resolve_index` is the shared resolver, + so the hole was shared — `hook-utils.sh` lives in 17 places (`lib/` plus 16 plugin copies) and + every one of them was stale. Synced from `lib/hook-utils.sh`, so all 17 carry the fix. + +### 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. +- The known-gap docblock also now records that the gap's PRIMARY symptom is a false BLOCK, not a + bypass: with a shell `cd` the probe measures a base that is often not a repository, answers width + 0, and fails closed — so `cd && git push --force-with-lease=main: + origin main`, the exact form the block message prescribes, is denied from a non-repository session + root. Fail-closed is right for an unresolvable base; the note exists so the next person to narrow + the gap treats the false block as the symptom to measure. +- A second residual is now documented rather than left implicit: git EXPORTS an explicit + `--git-dir` / `--work-tree` into a `!` shell-alias body, so the body inherits a repository the + composed directory does not name and its lease is judged against the base. Reproduced against + BOTH `origin/main` and this change — pre-existing, of the same family, and closing it means + replaying inherited globals rather than a directory, which is a larger mechanism than the base + chain adopted here. + ## [0.23.1] ### Fixed diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index 92f4740b5..f76859dd2 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,34 @@ 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. +# +# And it bites in the OTHER direction far more often than as a bypass: when the +# base is not a repository at all, the probe answers 0 and the guard fails closed, +# so `cd && git push --force-with-lease=main: +# origin main` — the very form the block messages above prescribe — is DENIED +# from a session root that is not itself a repository. Fail-closed is the right +# default for an unresolvable base, but the cost is a guard that can refuse +# correct usage it just recommended, which is how a guard teaches people to route +# around it. Anyone narrowing this gap should treat the false block as the +# primary symptom, not the bypass. +# +# 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 +314,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 +361,67 @@ 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. +# +# Composing ONLY `-C` is deliberate and mirrors git, not an oversight beside +# collect_git_locating_opts, which also replays `--git-dir` / `--work-tree` / +# `--namespace`: only `-C` moves a `!` body. Measured — `git -C -c +# alias.wd='!pwd' wd` prints ``'s top level, while `git --git-dir= +# -c alias.wd='!pwd' wd` does NOT relocate at all. The two functions answer +# different questions (which DIRECTORY the body runs in vs which REPOSITORY the +# probe addresses), so the option sets legitimately differ. +# +# Known gap, pre-existing and NOT closed here: only `-C` is composed. git also +# EXPORTS an explicit `--git-dir` / `--work-tree` into a `!` body's environment +# (verified on git 2.54.0: `git --git-dir=/.git -c alias.y='!git +# rev-parse --show-object-format' y` prints sha256 from a SHA-1 directory, and +# the body sees GIT_DIR set), so a body inherits a repository this directory does +# not name and its lease is judged against the base instead. Reproduced against +# BOTH origin/main and this change — it is a residual of the same family as +# #2124, not something introduced by reading the payload cwd, and closing it +# means replaying the inherited globals rather than a directory, which is a +# larger mechanism than the base chain adopted here. +# 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 @@ -383,14 +501,25 @@ is_exclude_pathspec() { # stalls stops guarding. Every recursion is admitted through this one gate, which # applies two bounds: # -# MEMO — a verdict is a pure function of (analysis state, argv); every other input -# is invocation-constant (the payload's command, the repository's config and object -# format). A block is a process-wide `exit 2`, so a state reached a SECOND time +# MEMO — a verdict is a pure function of (analysis state, EFFECTIVE BASE, argv); +# every other input is invocation-constant (the payload's command, the repository's +# config). A block is a process-wide `exit 2`, so a state reached a SECOND time # while this process still runs provably did not block the first time and cannot # decide differently now. Skipping the repeat is exact rather than a coverage # trade, and it is what collapses the common blowup — both spellings of a hop # expanding to the same thing — to one path per hop. # +# The base is in that tuple and not merely alongside it. The object format was +# once invocation-constant, which is why an earlier wording listed it as such — +# it is not, now that the width is measured from the payload cwd and a `!` shell +# alias relocates the base mid-parse. One reparse STRING reached under two bases +# is TWO analyses: `git -C -c alias.y='!git push +# --force-with-lease=main:<40-hex> …' y; git -C -c alias.y='' y` +# allows the first (a real object id there), and a key blind to the base would +# then skip the second and let a movable ref name through. Keying on the base is +# what keeps the skip exact, and it is the same reason block-noncanonical-commit +# keys on it. +# # BUDGET — memoization alone cannot bound a chain whose two spellings DIFFER: the # splice carries each path's own trailing text forward, so every argv is distinct # and the 2^depth walk survives (10 hops of `-c alias.aN='a(N+1) --xN' @@ -418,7 +547,13 @@ HOOK_ALIAS_WORK_MAX=128 alias_reexpand_admit() { local kind="$1" key q w shift - key="$kind"$'\n'"${#HOOK_ALIAS_SEEN[@]}"$'\n' + # The effective base belongs in the key: one reparse STRING reached in two + # different repositories is two different analyses, and collapsing them would + # skip the second — which is a bypass whenever the two repositories disagree on + # hash width. Keyed here rather than at the call sites so EVERY recursion is + # covered, including the git-alias splice, whose argv is equally base-dependent. + printf -v q '%q' "${HOOK_EFFECTIVE_BASE-}" + key="$kind"$'\n'"$q"$'\n'"${#HOOK_ALIAS_SEEN[@]}"$'\n' for w in ${HOOK_ALIAS_SEEN[@]+"${HOOK_ALIAS_SEEN[@]}"}; do printf -v q '%q' "$w" key+="$q"$'\n' @@ -491,7 +626,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 +660,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 +675,33 @@ 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 runs in the RELOCATED REPOSITORY — precisely, git + # chdirs a `!` body to the work tree's TOP LEVEL whenever it can compute + # a prefix, so the body's directory is the top level rather than the + # composed one (measured: `alias.wd='!pwd'` invoked from `/sub` + # prints ``). The distinction does not change the answer here and + # is stated so the reasoning is not load-bearing on a false premise: an + # object format is a property of the REPOSITORY, and the composed + # directory and its top level are the same repository, so both probe + # identically. Carry the composed directory 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=() + # `:2` skips the base's own `-C ` pair, which + # collect_git_locating_opts ALWAYS leads with. effective_dir supplies + # that same value from its HOOK_EFFECTIVE_BASE default, so passing it + # again would apply the base twice. The offset therefore encodes an + # invariant of collect_git_locating_opts, not of this call: if that + # function ever grows a second leading synthetic pair, or stops leading + # with the base, this line breaks silently and the wrapper dirs are + # read starting one pair too late. + 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 +716,7 @@ check_segment() { fi done HOOK_ALIAS_SEEN=(${saved_seen[@]+"${saved_seen[@]}"}) + HOOK_EFFECTIVE_BASE="$saved_base" fi case "$sub" in @@ -1111,6 +1270,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..4ae15b066 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