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