Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 136 additions & 24 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -964,8 +964,46 @@ hook::wrapper_chdir_record() {
fi
}

# Compose the effective repository directory for a git invocation: <base> with
# each bare `-C <dir>` 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
Expand Down Expand Up @@ -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]}"
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <desc> <expected-rc> <argv...> — 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 ]]
2 changes: 1 addition & 1 deletion plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> 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
Expand Down
Loading
Loading