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