diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/lib/hook-utils.sh
+++ b/lib/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C
` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh
index 2da7439c0a..8e2de55921 100755
--- a/lib/hook-utils.test.sh
+++ b/lib/hook-utils.test.sh
@@ -1520,6 +1520,73 @@ 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
+# --- the -S splice resumes INSIDE env's option parsing (#1814) ----------------
+# GNU env processes the split words as a continuation of its own argument list,
+# so a leading option in the operand is env's option. The old restart re-entered
+# the OUTER command scan, which has no env grammar: the option failed the
+# command lookup and the resolver returned 1 — every guard no-opped on
+# `env -S '-i git commit -m x'`.
+resolve_dirs_are "an option leading the -S operand is env's, not the command" \
+ "" env -S '-i git commit -m x'
+resolve_dirs_are "a chdir spelled inside -S is reported" \
+ "other" env -S '-C other git commit'
+resolve_dirs_are "a chdir inside an attached -S operand is reported" \
+ "other" env '-S-C other git commit'
+resolve_dirs_are "a chdir before -S survives the splice" "a" env -C a -S 'git commit'
+# One env keeps --chdir in a single slot, so a chdir inside the operand is
+# last-wins against one recorded before the -S, not cumulative.
+resolve_dirs_are "a chdir inside -S is last-wins over one before it" \
+ "b" env -C a -S '-C b git commit'
+# A nested -S strictly shrinks the remaining operand text, so the resumed scan
+# terminates and still reaches the command.
+resolve_dirs_are "a nested -S still resolves the command" "" env -S "-S 'git commit'"
+resolve_dirs_are "a NAME=value word inside -S ends option parsing" \
+ "" env -S 'FOO=1 git commit'
+
+# --- sudo clusters peel; shapes outside the verified grammar refuse (#1811) ---
+# resolve_rc_is — for the refusal contract:
+# rc 2 means the wrapper prefix cannot be decomposed with confidence AND
+# something git-shaped follows, so blocking callers must fail closed.
+resolve_rc_is() {
+ local desc="$1" want="$2"
+ shift 2
+ hook::git_resolve_index "$@"
+ local rc=$?
+ if ((rc == want)); then
+ ok "$desc"
+ else
+ fail "$desc: expected rc=$want, got rc=$rc"
+ fi
+}
+
+# sudo clusters short options, so `-bD dir` carries the chdir in a cluster no
+# exact -D match sees — losing it aimed the guard at the wrong repository.
+resolve_dirs_are "sudo -bD DIR (clustered) reports the chdir" "other" sudo -bD other git commit
+resolve_dirs_are "sudo -bDother (clustered, attached) reports the chdir" "other" sudo -bDother git commit
+resolve_dirs_are "sudo -Dother (attached) reports the chdir" "other" sudo -Dother git commit
+# Value-taking shorts consume their word attached or separated, clustered or not.
+resolve_rc_is "sudo -u USER still resolves git" 0 sudo -u root git commit
+resolve_rc_is "sudo -uroot (attached value) still resolves git" 0 sudo -uroot git commit
+resolve_rc_is "sudo -bu USER (cluster ending in a value-taker) still resolves git" 0 sudo -bu root git commit
+resolve_rc_is "sudo -- ends option parsing" 0 sudo -- git commit
+resolve_rc_is "sudo -E (valueless) still resolves git" 0 sudo -E git commit
+# Shapes the grammar cannot classify REFUSE when something git-shaped follows:
+# -i relocates to the target user's home (no operand names it), -h is
+# optional-argument (help vs host), unknown options may consume the next word.
+resolve_rc_is "sudo -i refuses: relocates to an unresolvable home" 2 sudo -i git commit -m x
+resolve_rc_is "sudo --login refuses like -i" 2 sudo --login git commit
+resolve_rc_is "sudo -h refuses: optional-argument ambiguity" 2 sudo -h host git commit
+resolve_rc_is "sudo unknown option refuses when git follows" 2 sudo -Z git commit
+resolve_rc_is "sudo cluster hiding -i refuses" 2 sudo -bi git commit
+# The refusal scan is a substring match on purpose: a quoted operand carrying a
+# git command is not word-shaped like git, and missing it is the bypass.
+resolve_rc_is "a quoted git operand behind a refused prefix still refuses" \
+ 2 sudo -i 'git commit -m x'
+# With nothing git-shaped downstream sudo cannot exec git from these words, so
+# an unparseable prefix on a git-free command stays a plain not-git answer.
+resolve_rc_is "sudo unknown option with no git downstream is not-git" 1 sudo -Z ls -la
+resolve_rc_is "sudo -i with no command is not-git" 1 sudo -i
+
echo
echo "PASS=$PASS FAIL=$FAIL"
[[ $FAIL -eq 0 ]]
diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json
index 3bc8b9bcac..62d51c77de 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.7.7",
+ "version": "0.7.8",
"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 de1fb03f68..163abe2629 100644
--- a/plugins/actionlint/CHANGELOG.md
+++ b/plugins/actionlint/CHANGELOG.md
@@ -3,6 +3,22 @@
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.7.8]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.7.7]
### Fixed
diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/actionlint/hooks/hook-utils.sh
+++ b/plugins/actionlint/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json
index 18c0a833bf..39b95192dd 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.12.2",
+ "version": "0.12.3",
"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 ab93005788..c1821f39d9 100644
--- a/plugins/autonomy/CHANGELOG.md
+++ b/plugins/autonomy/CHANGELOG.md
@@ -6,6 +6,22 @@ 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.12.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.12.2]
### Fixed
diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/autonomy/hooks/hook-utils.sh
+++ b/plugins/autonomy/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json
index b616a13bc2..143340fe0f 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.6.10",
+ "version": "0.6.12",
"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 d393eb26cb..81c50fa55f 100644
--- a/plugins/bash-format/CHANGELOG.md
+++ b/plugins/bash-format/CHANGELOG.md
@@ -3,6 +3,22 @@
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.6.12]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.6.10]
### Fixed
diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/bash-format/hooks/hook-utils.sh
+++ b/plugins/bash-format/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json
index 322898998a..acd5e59bcd 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.5.8",
+ "version": "0.5.9",
"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 6e980dcf89..2066a88178 100644
--- a/plugins/biome-format/CHANGELOG.md
+++ b/plugins/biome-format/CHANGELOG.md
@@ -3,6 +3,22 @@
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.5.9]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.5.8]
### Fixed
diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/biome-format/hooks/hook-utils.sh
+++ b/plugins/biome-format/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json
index 0c3ce06104..d24bc4d419 100644
--- a/plugins/claude-ops/.claude-plugin/plugin.json
+++ b/plugins/claude-ops/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-ops",
- "version": "0.27.1",
+ "version": "0.27.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",
diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md
index f8aba69a21..5925c0482a 100644
--- a/plugins/claude-ops/CHANGELOG.md
+++ b/plugins/claude-ops/CHANGELOG.md
@@ -3,6 +3,22 @@
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.27.2]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.27.1]
### Changed
diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/claude-ops/hooks/hook-utils.sh
+++ b/plugins/claude-ops/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json
index df15c1d88b..d91afe7bf4 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.4.5",
+ "version": "0.4.6",
"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 inject continuation guidance once per transition into a worse zone (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 d352c6a8b3..bfceb999c2 100644
--- a/plugins/context-guard/CHANGELOG.md
+++ b/plugins/context-guard/CHANGELOG.md
@@ -5,6 +5,22 @@ 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.4.6]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.4.5]
### Fixed
diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100755
--- a/plugins/context-guard/hooks/hook-utils.sh
+++ b/plugins/context-guard/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json
index bcd44ba947..4b00228fa2 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.5.9",
+ "version": "0.5.10",
"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 fa3f7ca341..ddf6d859bf 100644
--- a/plugins/desktop-notification/CHANGELOG.md
+++ b/plugins/desktop-notification/CHANGELOG.md
@@ -3,6 +3,22 @@
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.5.10]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.5.9]
### Fixed
diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/desktop-notification/hooks/hook-utils.sh
+++ b/plugins/desktop-notification/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json
index 9a58394913..291660d8f0 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.5.8",
+ "version": "0.5.9",
"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 86c7021c92..a870b0deb0 100644
--- a/plugins/eol-normalizer/CHANGELOG.md
+++ b/plugins/eol-normalizer/CHANGELOG.md
@@ -3,6 +3,22 @@
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.5.9]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.5.8]
### Fixed
diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/eol-normalizer/hooks/hook-utils.sh
+++ b/plugins/eol-normalizer/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json
index dff630eafe..57eec48b2a 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.2.8",
+ "version": "0.2.9",
"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 287b9ed7b0..28255280a8 100644
--- a/plugins/go-format/CHANGELOG.md
+++ b/plugins/go-format/CHANGELOG.md
@@ -3,6 +3,22 @@
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.2.9]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.2.8]
### Fixed
diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/go-format/hooks/hook-utils.sh
+++ b/plugins/go-format/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json
index 6c39d22e82..69ac931099 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.19.0",
+ "version": "0.19.1",
"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, 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) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md
index 15671b8e8b..96e37c5059 100644
--- a/plugins/guardrails/CHANGELOG.md
+++ b/plugins/guardrails/CHANGELOG.md
@@ -3,6 +3,50 @@
All notable changes to the `guardrails` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.19.1]
+
+### Fixed
+
+- **The `-S` restart re-entered the resolver outside env's option parsing, so any `-S` operand
+ beginning with an option bypassed every git guard (#1814).** `hook::git_resolve_index` handled
+ `env -S`/`--split-string` by splicing the split words into the scan and restarting the OUTER
+ command loop, which has no env grammar: a leading option in the operand failed the command lookup
+ and the resolver answered "no git here" — `env -S '-i git commit -m x'` passed the commit gate
+ with no alias or nested repo required, and `env -S '-C dir git …'` was a sixth chdir spelling no
+ guard read. The splice now resumes INSIDE env's option parsing, as GNU env itself processes the
+ split words: a leading `-i`/`-u`/`-C` is env's option, a chdir spelled inside the operand lands in
+ the same last-wins slot as one recorded before the `-S`, and a nested `-S` terminates because each
+ splice strictly shrinks the remaining operand text. Both bypass rows were verified to fail against
+ the unfixed resolver and pass fixed.
+
+- **sudo option clusters lost the wrapper chdir, and sudo shapes outside the verified grammar now
+ fail CLOSED instead of open (#1811).** The sudo branch read only unclustered `-D`/`--chdir`
+ spellings, so `sudo -bD git …` dropped the relocation AND left the scan pointing at the
+ directory operand — the git token never resolved and the guard no-opped. The branch now classifies
+ every option token against the upstream sudo(8) grammar (sudo 1.9 generation; provenance recorded
+ at the peel site): valueless shorts peel off clusters the way the env branch peels env's,
+ value-taking shorts consume an attached or following word, and `-D`/`--chdir` records the chdir.
+ Everything OUTSIDE that grammar — `-h` (optional-argument: help vs host), `-i`/`--login`
+ (relocates to the target user's home no operand names), unknown shorts/longs, clusters that do not
+ peel clean — makes the resolver REFUSE via a new return code 2 when something git-shaped
+ (case-insensitive substring, deliberately over-approximate so a quoted operand cannot hide)
+ remains downstream. `block-noncanonical-commit`, `block-dangerous-git`, and `block-no-verify` each
+ treat rc 2 as a block themselves — per-hook kill switches mean no guard may delegate its
+ fail-closed posture — while `block-convention-violation` skips, per its existing rule that
+ unparseable FORM is the mechanic guard's concern. A future sudo option therefore degrades to a
+ false positive, never a bypass.
+
+- **`block-convention-violation` passed its whole argv to `effective_dir`, so a wrapper's operands
+ hid a persisted alias (#1810).** In `env -u -C git x -F - <<'EOF'…`, env's `-u` consumes `-C` and
+ git runs in the payload cwd — but the whole-argv scan read the bare tokens `-C git`, resolved the
+ nested decoy `./git`, found no `alias.x` there, and the violating subject shipped. Both call sites
+ (the persisted-alias lookup and the sequencer probe) now receive only git's own globals
+ `[gi, sub_idx)` with the wrapper's real chdir composed from `HOOK_GIT_RESOLVED_WRAPPER_DIRS`,
+ exactly as the sibling mechanic guard does — and the path-composition rule both hooks previously
+ carried as private copies (which is how this one drifted) is collapsed into a single shared
+ `hook::git_effective_dir`. The `env -u -C git x` bypass row and a genuine-relocation twin
+ (`env -C other git y`, both violating and conforming) are pinned in the contract test.
+
## [0.19.0]
### Changed
diff --git a/plugins/guardrails/hooks/block-convention-violation.sh b/plugins/guardrails/hooks/block-convention-violation.sh
index f4fcb967d1..7424713805 100755
--- a/plugins/guardrails/hooks/block-convention-violation.sh
+++ b/plugins/guardrails/hooks/block-convention-violation.sh
@@ -182,22 +182,17 @@ block_title() {
# Same repo-dir/sequencer helpers as block-noncanonical-commit — an in-progress
# sequencer commit carries a prepared message and is never content-gated.
+# The composition itself is hook::git_effective_dir — the ONE shared rule, so
+# this gate and the sibling mechanic guard cannot drift apart again (this
+# gate's private copy is exactly how it was left behind by the sibling's
+# slice-boundary fix). CALLERS MUST PASS GIT'S OWN GLOBALS ONLY — the slice
+# `[gi, sub_idx)` plus the wrapper chdirs already spelled as leading `-C`
+# words — never the whole argv: a wrapper's options are not git's globals
+# (`env -u -C git …` gives git no `-C` at all), and anything after the
+# subcommand is that subcommand's argument or alias-appended text.
# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain
effective_dir() {
- local 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"
+ hook::git_effective_dir "${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}" "$@"
}
# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain
@@ -266,11 +261,24 @@ check_segment() {
[[ -n "$SUBJECT_ERE" ]] || return 0
+ # Any nonzero rc skips: rc 1 is not-git, and rc 2 (the resolver's refusal of
+ # an unparseable wrapper prefix) is the MECHANIC guard's fail-closed concern
+ # — it blocks the call outright, so this content gate just skips, exactly as
+ # it does for the --config-env shape refusal below.
hook::git_resolve_index "$@" || return 0
gi=$HOOK_GIT_RESOLVED_GI
w=("${HOOK_GIT_RESOLVED_WORDS[@]}")
nseg=${#w[@]}
+ # A wrapper's chdir happens before git starts, so it composes ahead of git's
+ # own globals — spelled as leading `-C` words so the one composition rule in
+ # hook::git_effective_dir covers both (mirrors the sibling mechanic guard).
+ local -a wrapper_cd=()
+ local wdir seg_dir=""
+ for wdir in ${HOOK_GIT_RESOLVED_WRAPPER_DIRS[@]+"${HOOK_GIT_RESOLVED_WRAPPER_DIRS[@]}"}; do
+ wrapper_cd+=(-C "$wdir")
+ done
+
hook::git_resolve_subcommand "$gi" "${w[@]}" || return 0
sub=$HOOK_GIT_SUB
sub_idx=$HOOK_GIT_SUB_IDX
@@ -306,8 +314,13 @@ check_segment() {
done
fi
if ((inline_alias_handled == 0)) && [[ "$sub" != "commit" ]]; then
+ # Git's own globals only — `[gi, sub_idx)` behind the wrapper's chdir.
+ # The whole argv read a wrapper's operands (and the subcommand's trailing
+ # arguments) as git's `-C`: `env -u -C git git x …` resolved `./git` and
+ # missed the alias persisted where git actually runs.
local pexp
- pexp=$(git -C "$(effective_dir "${w[@]}")" config --get "alias.$sub" 2>/dev/null)
+ [[ -n "$seg_dir" ]] || seg_dir="$(effective_dir ${wrapper_cd[@]+"${wrapper_cd[@]}"} "${w[@]:gi:sub_idx-gi}")"
+ pexp=$(git -C "$seg_dir" config --get "alias.$sub" 2>/dev/null)
if [[ -n "$pexp" ]]; then
if [[ "$pexp" == '!'* ]]; then
local preparse pa
@@ -349,7 +362,8 @@ check_segment() {
((stdin_form)) || return 0
((exempt)) && return 0
- sequencer_in_progress "$(effective_dir "${w[@]}")" && return 0
+ [[ -n "$seg_dir" ]] || seg_dir="$(effective_dir ${wrapper_cd[@]+"${wrapper_cd[@]}"} "${w[@]:gi:sub_idx-gi}")"
+ sequencer_in_progress "$seg_dir" && return 0
local subj=""
if [[ "$TOOL_NAME" == "PowerShell" ]]; then
diff --git a/plugins/guardrails/hooks/block-convention-violation.test.sh b/plugins/guardrails/hooks/block-convention-violation.test.sh
index 1f553b76d7..f8b52fcb41 100755
--- a/plugins/guardrails/hooks/block-convention-violation.test.sh
+++ b/plugins/guardrails/hooks/block-convention-violation.test.sh
@@ -124,6 +124,32 @@ run "configured alias commit: violating subject blocked" "$r" \
run "configured alias commit: conforming subject allowed" "$r" \
$'git qc -F - --cleanup=verbatim <<\'EOF\'\nABC-5: fine\nEOF' 0
+# --- a WRAPPER's options are not git's globals (#1810) -------------------------
+# effective_dir used to receive the WHOLE argv, so a wrapper's operands (and a
+# subcommand's trailing arguments) were read as git's `-C`. In
+# `env -u -C git x …`, GNU env's `-u` consumes `-C` as the variable to unset and
+# `git` is the command — git never leaves the payload cwd — but the whole-argv
+# scan read the bare tokens `-C git`, resolved the nested decoy `./git`, found
+# no `alias.x` there, and the violating subject shipped.
+r="$(newrepo "$TICKET")"
+git -C "$r" config alias.x 'commit -F -'
+git init -q -b main "$r/git" # decoy a wrongly-sliced parser resolves; carries NO alias.x
+run "persisted alias: violating subject blocked (baseline)" "$r" \
+ $'git x -F - --cleanup=verbatim <<\'EOF\'\njunk subject\nEOF' 2
+run "persisted alias: conforming subject allowed (baseline)" "$r" \
+ $'git x -F - --cleanup=verbatim <<\'EOF\'\nABC-1: conforming\nEOF' 0
+run "wrapper operand no longer read as git's -C (env -u -C git x)" "$r" \
+ $'env -u -C git x -F - --cleanup=verbatim <<\'EOF\'\njunk subject\nEOF' 2
+# The narrowing must NOT stop honouring a relocation the wrapper really
+# performs: `env -C other git y` runs git in `other`, so `other`'s alias is the
+# one that commits — composed from HOOK_GIT_RESOLVED_WRAPPER_DIRS.
+git init -q -b main "$r/other"
+git -C "$r/other" config alias.y 'commit -F -'
+run "wrapper chdir still followed: relocated alias gated" "$r" \
+ $'env -C other git y -F - --cleanup=verbatim <<\'EOF\'\njunk subject\nEOF' 2
+run "wrapper chdir still followed: relocated alias, conforming subject allowed" "$r" \
+ $'env -C other git y -F - --cleanup=verbatim <<\'EOF\'\nABC-2: conforming\nEOF' 0
+
# --- kill switch ---------------------------------------------------------------
r="$(newrepo "$TICKET")"
json=$(jq -n --arg c "$BAD_COMMIT" --arg d "$r" \
diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh
index f488223f74..f591b737c7 100755
--- a/plugins/guardrails/hooks/block-dangerous-git.sh
+++ b/plugins/guardrails/hooks/block-dangerous-git.sh
@@ -434,7 +434,20 @@ check_segment() {
return 0
fi
- hook::git_resolve_index "$@" || return 0
+ # rc 2 is the resolver's REFUSAL — a wrapper prefix outside its verified
+ # grammar with something git-shaped downstream. The command position is
+ # unknowable there, so a guarded operation may be hiding behind it: fail
+ # closed rather than fold it into the not-git no-op. Each blocking guard
+ # holds this posture itself (per-hook kill switches mean none may delegate
+ # it to a sibling).
+ hook::git_resolve_index "$@"
+ case $? in
+ 0) ;;
+ 2) block "unparseable-wrapper" \
+ "BLOCKED: this command's wrapper prefix (e.g. a sudo option the parser cannot classify) hides the command position, and something git-shaped follows it — failing closed." \
+ "Run git directly (or under a wrapper spelling the guard can read), or set the guardrails block_dangerous_git_enabled option to false to bypass." ;;
+ *) return 0 ;;
+ esac
gi=$HOOK_GIT_RESOLVED_GI
# env -S splicing may have rewritten the argv — match on the resolved words.
w=("${HOOK_GIT_RESOLVED_WORDS[@]}")
diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh
index 300ce3719c..5e3898459c 100755
--- a/plugins/guardrails/hooks/block-no-verify.sh
+++ b/plugins/guardrails/hooks/block-no-verify.sh
@@ -138,7 +138,20 @@ check_segment() {
return 0
fi
- hook::git_resolve_index "${w[@]}" || return 0
+ # rc 2 is the resolver's REFUSAL — a wrapper prefix outside its verified
+ # grammar with something git-shaped downstream. The command position is
+ # unknowable there, so a bypass flag may be hiding behind it: fail closed
+ # rather than fold it into the not-git no-op. Each blocking guard holds this
+ # posture itself (per-hook kill switches mean none may delegate it to a
+ # sibling).
+ hook::git_resolve_index "${w[@]}"
+ case $? in
+ 0) ;;
+ 2) block "unparseable-wrapper" \
+ "BLOCKED: this command's wrapper prefix (e.g. a sudo option the parser cannot classify) hides the command position, and something git-shaped follows it — failing closed." \
+ "Run git directly (or under a wrapper spelling the guard can read), or set the guardrails block_no_verify_enabled option to false to bypass." ;;
+ *) return 0 ;;
+ esac
gi=$HOOK_GIT_RESOLVED_GI
# env -S splicing may have rewritten the argv — match on the resolved words.
w=("${HOOK_GIT_RESOLVED_WORDS[@]}")
diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh
index 5507c80c9f..e17f86b80b 100755
--- a/plugins/guardrails/hooks/block-noncanonical-commit.sh
+++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh
@@ -387,22 +387,12 @@ collect_locating_globals() {
# HOOK_GIT_RESOLVED_WRAPPER_DIRS, and the caller passes them here as leading `-C`
# words so they compose ahead of git's own globals, in that order. Dropping them
# reads the payload cwd's aliases while git runs the relocated repository's.
+# The composition itself is hook::git_effective_dir — the ONE shared rule, so
+# this hook and the convention gate cannot drift apart again; only the base
+# resolution is this hook's own (HOOK_EFFECTIVE_BASE tracks the `!` alias walk).
# 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"
+ hook::git_effective_dir "${HOOK_EFFECTIVE_BASE:-${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}}" "$@"
}
# Is a merge / rebase / cherry-pick / revert in progress? Those commits carry a
@@ -545,7 +535,22 @@ check_segment() {
return 0
fi
- hook::git_resolve_index "$@" || return 0
+ # rc 2 is the resolver's REFUSAL — a wrapper prefix outside its verified
+ # grammar with something git-shaped downstream. The command position is
+ # unknowable there, so a commit may be hiding behind it: fail closed rather
+ # than fold it into the not-git no-op. Each blocking guard holds this posture
+ # itself (per-hook kill switches mean none may delegate it to a sibling).
+ hook::git_resolve_index "$@"
+ case $? in
+ 0) ;;
+ 2)
+ echo "BLOCKED: this command's wrapper prefix (e.g. a sudo option the parser cannot classify) hides the command position, and something git-shaped follows it — failing closed." >&2
+ echo "Run git directly (or under a wrapper spelling the guard can read), or set the guardrails block_noncanonical_commit_enabled option to false to bypass." >&2
+ emit_tel "blocked" "unparseable-wrapper"
+ exit 2
+ ;;
+ *) return 0 ;;
+ esac
gi=$HOOK_GIT_RESOLVED_GI
w=("${HOOK_GIT_RESOLVED_WORDS[@]}")
nseg=${#w[@]}
diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh
index 23d6087bc3..ed5ed6bc78 100755
--- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh
+++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh
@@ -647,6 +647,52 @@ if [[ -d "$WRAP/outer/other/.git" ]]; then
"env -0 -C other git a" 2
wrapper_cd_case "env -vi0C (three-flag cluster) still yields the chdir" \
"env -vi0C other git a" 2
+
+ # --- the -S splice resumes INSIDE env's option parsing (#1814) --------------
+ # GNU env processes -S's split words as a continuation of its own argument
+ # list, so a leading option in the operand is env's option. The old restart
+ # re-entered the outer command scan, which has no env grammar: the option
+ # failed the command lookup and the resolver answered "no git here" — a bare
+ # `-i` in front of the command was enough to pass ANY guarded git invocation.
+ wrapper_cd_case "an option leading the -S operand no longer hides the command" \
+ "env -S '-i git commit -m x'" 2
+ # The sixth chdir spelling from #1785's ledger: env applies -C inside the
+ # split string exactly as outside, so the alias lookup must follow it.
+ wrapper_cd_case "a chdir spelled INSIDE the -S operand moves git" \
+ "env -S '-C other git a'" 2
+ # Canonical twin: the rc-0 path through the resumed parse must not over-block.
+ wrapper_cd_case "a canonical commit behind an env option inside -S stays allowed" \
+ "env -S '-i git commit -F -'" 0
+
+ # --- sudo clusters peel; unparseable sudo shapes fail CLOSED (#1811) --------
+ # sudo clusters short options, so `-bD dir` carries the chdir in a cluster no
+ # exact -D match saw — the chdir was lost AND the scan stopped short of git,
+ # so the guard no-opped entirely.
+ wrapper_cd_case "sudo -bD DIR (clustered) still moves the alias lookup" \
+ "sudo -bD other git a" 2
+ wrapper_cd_case "sudo -bDother (clustered, attached) still moves the alias lookup" \
+ "sudo -bDother git a" 2
+ # Value-taking shorts keep consuming their word: git still resolves, and the
+ # payload cwd's aliases still decide.
+ wrapper_cd_case "sudo -u USER still resolves git (non-canonical alias blocks)" \
+ "sudo -u root git k" 2
+ wrapper_cd_case "sudo -u USER still resolves git (canonical alias allowed)" \
+ "sudo -u root git a" 0
+ # Shapes outside the verified sudo grammar REFUSE when something git-shaped
+ # follows: -i relocates to the target user's home (which no operand names),
+ # -h is optional-argument (help vs host), unknown options may consume the
+ # next word. The refusal is form-blind — even a canonical `-F -` commit
+ # blocks, because the command position itself is unknowable.
+ wrapper_cd_case "sudo -i fails closed on a following git command" \
+ "sudo -i git commit -m x" 2
+ wrapper_cd_case "sudo -i fails closed even on a canonical form" \
+ "sudo -i git commit -F -" 2
+ wrapper_cd_case "sudo -h fails closed on a following git command" \
+ "sudo -h host git commit -F -" 2
+ # No git-shaped word downstream: sudo cannot exec git from those words, so a
+ # git-free command under an unparseable prefix stays allowed.
+ wrapper_cd_case "sudo unknown option with no git downstream stays allowed" \
+ "sudo -Z ls -la" 0
fi
# --- a wrapper's chdir composes AHEAD of git's own -C, in that order ----------
diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/guardrails/hooks/hook-utils.sh
+++ b/plugins/guardrails/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json
index eee9c9e824..6a1f6235f2 100644
--- a/plugins/markdown-format/.claude-plugin/plugin.json
+++ b/plugins/markdown-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "markdown-format",
- "version": "0.9.0",
+ "version": "0.9.1",
"description": "Auto-format and lint Markdown on edit via markdownlint-cli2 — only in repos that carry their own markdownlint config.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md
index 8d5329b3aa..93cc6fc388 100644
--- a/plugins/markdown-format/CHANGELOG.md
+++ b/plugins/markdown-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `markdown-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.9.1]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.9.0]
### Changed
diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/markdown-format/hooks/hook-utils.sh
+++ b/plugins/markdown-format/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json
index 1ae67c754d..38c3f06313 100644
--- a/plugins/powershell-format/.claude-plugin/plugin.json
+++ b/plugins/powershell-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "powershell-format",
- "version": "0.6.5",
+ "version": "0.6.6",
"description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md
index 1c2a9a934e..e260e61a33 100644
--- a/plugins/powershell-format/CHANGELOG.md
+++ b/plugins/powershell-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `powershell-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.6.6]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.6.5]
### Fixed
diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/powershell-format/hooks/hook-utils.sh
+++ b/plugins/powershell-format/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json
index 77b8e5280a..e9709d10b2 100644
--- a/plugins/rate-limit-guard/.claude-plugin/plugin.json
+++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "rate-limit-guard",
- "version": "0.4.0",
+ "version": "0.4.2",
"description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md
index 038205afb6..89ef3c03d3 100644
--- a/plugins/rate-limit-guard/CHANGELOG.md
+++ b/plugins/rate-limit-guard/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `rate-limit-guard` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.4.2]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.4.0]
### Fixed
diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100755
--- a/plugins/rate-limit-guard/hooks/hook-utils.sh
+++ b/plugins/rate-limit-guard/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json
index d474a81c27..006f33a79f 100644
--- a/plugins/ruff-format/.claude-plugin/plugin.json
+++ b/plugins/ruff-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "ruff-format",
- "version": "0.5.8",
+ "version": "0.5.9",
"description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo — using the consuming repo's own Ruff config.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md
index 515345f38a..1838c60352 100644
--- a/plugins/ruff-format/CHANGELOG.md
+++ b/plugins/ruff-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `ruff-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.5.9]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.5.8]
### Fixed
diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/ruff-format/hooks/hook-utils.sh
+++ b/plugins/ruff-format/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json
index dc73c3ab21..e7480b2cbd 100644
--- a/plugins/source-control/.claude-plugin/plugin.json
+++ b/plugins/source-control/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "source-control",
- "version": "0.45.1",
+ "version": "0.46.1",
"description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only — with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md
index 3143bc24fb..c688111fe5 100644
--- a/plugins/source-control/CHANGELOG.md
+++ b/plugins/source-control/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `source-control` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.46.1]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.45.1]
### Fixed
diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/source-control/hooks/hook-utils.sh
+++ b/plugins/source-control/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue
diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json
index 41bff9551e..e4196f3370 100644
--- a/plugins/typos-format/.claude-plugin/plugin.json
+++ b/plugins/typos-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "typos-format",
- "version": "0.5.0",
+ "version": "0.5.1",
"description": "Spell-check on edit via typos-cli, unconditionally — report-only by default, honoring the consuming repo's own typos configuration when one is present.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md
index 0270373afe..6046aa4fae 100644
--- a/plugins/typos-format/CHANGELOG.md
+++ b/plugins/typos-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `typos-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.5.1]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: wrapper prefixes are parsed by their own grammar or refused, never
+ guessed (#1814, #1811, #1810).** `hook::git_resolve_index` now resumes inside env's option
+ parsing after an `-S`/`--split-string` splice (an option leading the operand —
+ `env -S '-i git …'` — no longer hides the command from every git guard, and a chdir spelled
+ inside the operand is reported), peels sudo's short-option clusters against the verified sudo(8)
+ grammar (`sudo -bD git …` keeps its chdir), and refuses — new return code 2, which
+ blocking callers treat as fail-closed — any sudo shape outside that grammar (`-h`,
+ `-i`/`--login`, unknown options) when something git-shaped follows. A new shared
+ `hook::git_effective_dir` carries the git guards' single path-composition rule. This plugin
+ does not consume the resolver; the sync keeps its copy byte-identical with the source. Synced
+ from `lib/hook-utils.sh`.
+
## [0.5.0]
### Changed
diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh
index 15abc44bc0..724b951772 100644
--- a/plugins/typos-format/hooks/hook-utils.sh
+++ b/plugins/typos-format/hooks/hook-utils.sh
@@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}
+# Compose the effective repository directory for a git invocation: with
+# each bare `-C ` in the following argv applied in order — an absolute
+# operand (POSIX or drive-lettered) replaces the base, a relative one joins onto
+# it. This is THE path-composition rule for the git guards' directory
+# resolution; the guards' own `effective_dir` wrappers supply their base and
+# delegate here, so the rule cannot drift between hooks (it did once: the
+# convention gate's private copy was left behind by the slice-boundary fix).
+# The result is a LITERAL composed path handed straight to `git -C` — git
+# applies its own path semantics; callers never normalize it.
+# Callers must pass only git's own globals (plus wrapper chdirs already spelled
+# as leading `-C` words) — see the boundary docblock at the guards' wrappers.
+hook::git_effective_dir() {
+ local base="$1"
+ shift
+ local 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"
+}
+
# Locate a real `git` executable at the segment's command position (after
-# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# env-var prefixes and known wrappers). Return contract:
+# 0 — git found; results in the globals below
+# 1 — no git at the command position (callers treat the segment as not-git)
+# 2 — REFUSED: a wrapper prefix outside the verified grammar with something
+# git-shaped downstream (see the sudo branch's fail-closed backstop).
+# The command position is unknowable, so a git invocation may be hiding
+# behind it — BLOCKING callers must fail closed on this code, never
+# fold it into the return-1 no-op path.
+# Results go in
# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
# must match on the rewritten words, so the index alone is not enough.
# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
@@ -1033,27 +1071,44 @@ 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 INSIDE env's own option parsing (`continue` to this
+ # inner loop, never `continue 2` to the outer command scan): GNU env
+ # processes the split words as a continuation of its own argument
+ # list, so a leading `-i`/`-C`/`-u` in the operand is still env's
+ # option — the outer scan has no env grammar and read it as a failed
+ # command lookup, which no-opped every guard (`env -S '-i git commit
+ # -m x'` passed unchecked). The splice drops every word before `i`,
+ # this `env` included, so a chdir already recorded for it is not
+ # re-walked and stays recorded (env performs that chdir whether or
+ # not -S rewrites the command), and env_ci keeps the slot so a chdir
+ # spelled inside the operand stays last-wins within this one env.
+ # Termination without a depth guard: each splice consumes the -S and
+ # its operand and can only insert words derived from that operand, so
+ # a nested -S strictly shrinks the remaining text.
+ # The rewrite keeps a synthetic leading `env` word (resuming at
+ # i=1): callers RECONSTRUCT invocations from the rewritten words
+ # (`w[0:sub_idx]` around an alias splice), and without the wrapper
+ # word a spliced env option would sit bare at the command position
+ # of the re-parse — unresolvable, so the recursive check silently
+ # skipped exactly the expansions this resolver exists to surface.
+ # Semantically exact: the spliced words ARE env's continued argv.
-S | --split-string)
local sval=""
((i + 1 < n)) && sval="${w[i + 1]}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-S* | --split-string=*)
local sval="${etok#-S}"
sval="${sval#--split-string=}"
hook::env_s_split "$sval"
- w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ w=(env ${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
n=${#w[@]}
- i=0
- continue 2
+ i=1
+ continue
;;
-C | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record env_ci "${w[i + 1]}"
@@ -1103,31 +1158,88 @@ hook::git_resolve_index() {
;;
sudo)
# sudo's own chdir is -D/--chdir (its -C is close-from, -R is --chroot).
- # Only the unclustered spellings are read here; sudo's valueless short set
- # is large and release-dependent, so peeling a cluster the way the env
- # branch does would be guesswork rather than grammar.
- # Known gap, fail-open: a clustered `sudo -bD dir git …` loses the chdir,
- # and `-i` relocates to the target user's home without naming a directory
- # at all.
+ # The grammar here is read EXACTLY, never approximated with a generic
+ # dash-skip, because a misread option is a fail-open in one direction or
+ # the other: treating a value-taking option as valueless leaves its value
+ # at the command position (git never resolves, the guard no-ops), and the
+ # reverse swallows the command word. Option classes verified against the
+ # upstream sudo(8) manual (sudo 1.9 generation, sudo.ws/docs/man/sudo.man):
+ # valueless shorts -A -B -b -E -e -H -K -k -l -N -n -P -S -s -v -V
+ # (peeled off clusters the way the env branch peels
+ # env's, so `sudo -bD dir` reaches its chdir)
+ # value-taking -C -g -p -R -r -T -t -U -u (plus -D, handled as the
+ # chdir), attached or as the following word
+ # Anything OUTSIDE that verified grammar fails CLOSED via the backstop in
+ # the `*)` arm rather than guessing:
+ # -h optional-argument (bare = help, with operand = --host) —
+ # unclassifiable by peeling; either guess is a bypass in one
+ # direction or a swallowed command word in the other
+ # -i/--login relocates to the target user's home, which no operand
+ # names, so no chdir can be recorded
+ # unknown shorts/longs, clusters that do not peel clean — a future
+ # sudo option degrades to a false positive, never a bypass
((i++))
- local sudo_ci=-1
+ local sudo_ci=-1 stok sk
while ((i < n)) && [[ "${w[i]}" == -* ]]; do
- case "${w[i]}" in
+ stok="${w[i]}"
+ if [[ "$stok" == -[!-]* ]]; then
+ # Peel the valueless shorts off a cluster so a value-taking tail
+ # (`-bD dir`, `-bu root`) reaches its own branch; peel into a scratch
+ # copy, leaving HOOK_GIT_RESOLVED_WORDS as the caller matches on it.
+ while [[ "$stok" =~ ^-[ABbEeHKklNnPSsvV](.+)$ ]]; do stok="-${BASH_REMATCH[1]}"; done
+ fi
+ case "$stok" in
+ --)
+ ((i++))
+ break
+ ;;
+ -[ABbEeHKklNnPSsvV])
+ ((i++))
+ ;;
-D | --chdir)
((i + 1 < n)) && hook::wrapper_chdir_record sudo_ci "${w[i + 1]}"
((i += 2))
;;
--chdir=*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#--chdir=}"
+ hook::wrapper_chdir_record sudo_ci "${stok#--chdir=}"
((i++))
;;
-D*)
- hook::wrapper_chdir_record sudo_ci "${w[i]#-D}"
+ hook::wrapper_chdir_record sudo_ci "${stok#-D}"
((i++))
;;
- -u | -g | -h | -p | -C | -R | -T | --user | --group) ((i += 2)) ;;
- -*) ((i++)) ;;
- *) ((i++)) ;;
+ -[CgpRrTtUu])
+ ((i += 2))
+ ;;
+ -[CgpRrTtUu]*)
+ ((i++)) # attached value (`-uroot`)
+ ;;
+ --close-from | --group | --prompt | --chroot | --role | --type | --command-timeout | --other-user | --user)
+ ((i += 2))
+ ;;
+ --close-from=* | --group=* | --prompt=* | --chroot=* | --role=* | --type=* | --command-timeout=* | --other-user=* | --user=* | --preserve-env=*)
+ ((i++))
+ ;;
+ --askpass | --background | --bell | --preserve-env | --edit | --set-home | --remove-timestamp | --reset-timestamp | --list | --no-update | --non-interactive | --preserve-groups | --stdin | --shell | --version | --validate | --help)
+ ((i++))
+ ;;
+ *)
+ # Fail-closed backstop: this prefix cannot be decomposed with
+ # confidence, so the command position — and any relocation — is
+ # unknowable. If anything git-shaped remains downstream, REFUSE
+ # (return 2; callers must block) rather than no-op the guard. The
+ # scan is a case-insensitive `git` substring on purpose: it must
+ # catch a quoted operand (`sudo -i 'git commit -m x'`) that
+ # hook::git_is_bin's word-shape test would miss, and an
+ # over-approximate refusal is a false positive while an exact one
+ # here would be a bypass. With nothing git-shaped downstream sudo
+ # cannot exec git from these words (up to the documented
+ # static-matcher residual), so plain return 1 stands.
+ for ((sk = i + 1; sk < n; sk++)); do
+ [[ "${w[sk],,}" == *git* ]] && return 2
+ done
+ return 1
+ ;;
esac
done
continue