diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index e635f57f8..fa6f12d15 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -623,6 +623,196 @@ else fi rm -rf "$DATA17" "$FAKEBIN17" +# --- git config value kinds + effective resolution --------------------------- +# The option walk must tag each collected -c/--config/--config-env value with its +# origin, and the effective-value projection must resolve a --config-env entry +# (an env-var NAME) to the variable's value the way git does at runtime. +join_a() { + local IFS='|' + echo "$*" +} + +hook::git_resolve_subcommand 0 git -c foo.bar=baz --config-env=alias.c=AVAR c +if [[ "$(join_a "${HOOK_GIT_CONFIG_VALUES[@]}")" == "foo.bar=baz|alias.c=AVAR" && +"$(join_a "${HOOK_GIT_CONFIG_VALUE_KINDS[@]}")" == "inline|env" ]]; then + ok "git config: =-forms tag inline vs env" +else + fail "git config kinds (=-forms): values=($(join_a "${HOOK_GIT_CONFIG_VALUES[@]}")) kinds=($(join_a "${HOOK_GIT_CONFIG_VALUE_KINDS[@]}"))" +fi + +hook::git_resolve_subcommand 0 git -c a.b=c --config-env alias.d=AVAR d +if [[ "$(join_a "${HOOK_GIT_CONFIG_VALUE_KINDS[@]}")" == "inline|env" ]]; then + ok "git config: two-word forms tag inline vs env" +else + fail "git config kinds (two-word): kinds=($(join_a "${HOOK_GIT_CONFIG_VALUE_KINDS[@]}"))" +fi + +# --- git_alias_expansion: structural classification of the invoked alias ------- +# The guard RESOLVES inline (-c/--config) aliases but REFUSES an env (--config-env) alias +# for the invoked subcommand by shape — its expansion is an env var never read. git reads +# two spellings as the alias (`alias.` and `alias..command`); the classifier +# keeps the LAST value WITHIN each spelling and fails closed on their union — env in +# either -> rc 2; else rc 0 exposing every present spelling's expansion in +# HOOK_GIT_ALIAS_EXPS (joined with '|' below, in plain-then-command order). + +hook::git_resolve_subcommand 0 git -c alias.rh='reset --hard' rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 0)) && [[ "$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")" == "reset --hard" ]]; then + ok "git alias: inline -c alias returns the literal expansion (rc 0)" +else + fail "git alias (inline): rc=$rc exps=[$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")]" +fi + +hook::git_resolve_subcommand 0 git --config-env=alias.rh=AVAR rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 2)); then + ok "git alias: --config-env alias for the invoked sub is refused by shape (rc 2)" +else + fail "git alias (env refuse): rc=$rc" +fi + +hook::git_resolve_subcommand 0 git -c foo.bar=baz rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 1)); then + ok "git alias: no alias for the invoked sub returns 1" +else + fail "git alias (none): rc=$rc" +fi + +# A --config-env that sets a NON-alias key never triggers refusal (the legitimate use). +hook::git_resolve_subcommand 0 git --config-env=core.pager=PAGERVAR status +hook::git_alias_expansion status +rc=$? +if ((rc == 1)); then + ok "git alias: --config-env non-alias key is not refused (resolvable/allowed)" +else + fail "git alias (non-alias key): rc=$rc" +fi + +# A --config-env alias for a subcommand OTHER than the invoked one is not refused. +hook::git_resolve_subcommand 0 git --config-env=alias.foo=AVAR status +hook::git_alias_expansion status +rc=$? +if ((rc == 1)); then + ok "git alias: --config-env alias for an uninvoked subcommand is not refused" +else + fail "git alias (uninvoked alias): rc=$rc" +fi + +# LAST value WITHIN a spelling wins: env after inline for the same key -> refuse. +hook::git_resolve_subcommand 0 git -c alias.rh=status --config-env=alias.rh=AVAR rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 2)); then + ok "git alias: env value last-wins over an inline decoy -> refuse" +else + fail "git alias (env last): rc=$rc" +fi + +# LAST value WITHIN a spelling wins: inline after env for the same key -> resolve inline. +hook::git_resolve_subcommand 0 git --config-env=alias.rh=AVAR -c alias.rh=status rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 0)) && [[ "$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")" == "status" ]]; then + ok "git alias: inline value last-wins over an earlier env -> resolve (allowed)" +else + fail "git alias (inline last): rc=$rc exps=[$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")]" +fi + +# git config names are case-insensitive: the key match folds case. +hook::git_resolve_subcommand 0 git --config-env=alias.RH=AVAR rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 2)); then + ok "git alias: --config-env key match folds case" +else + fail "git alias (case fold): rc=$rc" +fi + +# The `alias..command` subkey is an alias definition too (git reads it), classified +# like the plain form: inline resolves, --config-env shape refuses. +hook::git_resolve_subcommand 0 git -c alias.rh.command='reset --hard' rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 0)) && [[ "$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")" == "reset --hard" ]]; then + ok "git alias: inline -c .command subkey returns the literal expansion (rc 0)" +else + fail "git alias (inline .command): rc=$rc exps=[$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")]" +fi + +hook::git_resolve_subcommand 0 git --config-env=alias.rh.command=AVAR rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 2)); then + ok "git alias: --config-env .command subkey for the invoked sub is refused by shape (rc 2)" +else + fail "git alias (env .command): rc=$rc" +fi + +# A non-`command` alias subkey is NOT an alias definition to git — must not be classified. +hook::git_resolve_subcommand 0 git -c alias.rh.nope=status rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 1)); then + ok "git alias: a non-command alias subkey is not treated as an alias (rc 1)" +else + fail "git alias (.nope control): rc=$rc" +fi + +# LAST value WITHIN the .command spelling wins (independently of the plain spelling). +hook::git_resolve_subcommand 0 git -c alias.rh.command='reset --hard' -c alias.rh.command=status rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 0)) && [[ "$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")" == "status" ]]; then + ok "git alias: last value within the .command spelling wins" +else + fail "git alias (.command within last): rc=$rc exps=[$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")]" +fi + +# MAX-DANGER UNION: which spelling git runs when both are set is version-dependent, so the +# classifier exposes BOTH expansions and never lets one spelling mask the other. A benign +# value in either spelling must NOT collapse the sibling's expansion out of the result. +hook::git_resolve_subcommand 0 git -c alias.rh='reset --hard' -c alias.rh.command=status rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 0)) && [[ "$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")" == "reset --hard|status" ]]; then + ok "git alias: dangerous plain + benign .command exposes both expansions (union)" +else + fail "git alias (union plain+cmd): rc=$rc exps=[$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")]" +fi + +hook::git_resolve_subcommand 0 git -c alias.rh=status -c alias.rh.command='reset --hard' rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 0)) && [[ "$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")" == "status|reset --hard" ]]; then + ok "git alias: benign plain + dangerous .command exposes both expansions (union)" +else + fail "git alias (union cmd danger): rc=$rc exps=[$(join_a "${HOOK_GIT_ALIAS_EXPS[@]}")]" +fi + +# Union on the ENV dimension: an env spelling refuses even when the sibling inline spelling +# is benign — the benign inline must not mask the unreadable env sibling. +hook::git_resolve_subcommand 0 git --config-env=alias.rh=AVAR -c alias.rh.command=status rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 2)); then + ok "git alias: env plain spelling refuses despite a benign inline .command sibling" +else + fail "git alias (env plain masked): rc=$rc" +fi + +hook::git_resolve_subcommand 0 git --config-env=alias.rh.command=AVAR -c alias.rh=status rh +hook::git_alias_expansion rh +rc=$? +if ((rc == 2)); then + ok "git alias: env .command spelling refuses despite a benign inline plain sibling" +else + fail "git alias (env .command masked): rc=$rc" +fi + 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 240f997da..222e40004 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.5.0", + "version": "0.5.1", "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 bf13d0e10..b3fe7633e 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,16 @@ 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.5.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.5.0] ### Added diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index 52a4fbf47..bd234f801 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.0", + "version": "0.6.1", "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 c790c9500..9b612e4db 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,16 @@ 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.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.6.0] ### Added diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 836a3ccbc..5944a25fa 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.0", + "version": "0.5.1", "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 4da35954e..3304b1d4f 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,16 @@ 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.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.5.0] ### Added diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index ea919d994..6f59ac239 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.18.1", + "version": "0.18.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 and a repo-pull + marketplace-refresh launch step), 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 7965fc491..b6c6a6228 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,16 @@ 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.18.2] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.18.1] ### Changed diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 07748f378..cc08d7ced 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.0", + "version": "0.5.1", "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 56b514baa..3b817c119 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,16 @@ 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.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.5.0] ### Added diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index c2fd85b50..188b90b89 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.0", + "version": "0.5.1", "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 d5010d095..03634a6c8 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,16 @@ 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.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.5.0] ### Added diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index a0bb84f1f..78cd5a57e 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.0", + "version": "0.2.1", "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 6b8091e2d..240ec9dd9 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,16 @@ 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.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.2.0] ### Added diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 6b94f08ef..6d1dfcc1a 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.10.1", + "version": "0.10.2", "description": "Eight 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, (advisory) hallucinated CLI flags, (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 358a018db..e15772c1b 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,60 @@ 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.10.2] + +### Changed + +- **`--config-env` git aliases for a guarded subcommand are now refused by SHAPE, not + resolved (`#740`).** `--config-env==` names an environment variable that + holds the alias expansion; that value can be fed from an ambient variable, an inline or + `env` command-line prefix, an `export` (including `set -a`, an `export NAME` promotion, + or an assignment-prefixed `export`), or a nested `bash -c` / `!`-alias in any enclosing + wrapper. Every attempt to resolve the value — to decide whether `git ` runs a + guarded operation — reopened a fail-open as reviewers found new propagation paths. Since + the `--config-env=alias.=` option and the `` it defines always sit in + the same git invocation, the guards no longer read the value at all: an alias for the + INVOKED subcommand whose last definition on the command line is `--config-env` is blocked + structurally (`hook::git_alias_expansion`). Nobody legitimately defines a commit or reset + alias this way on a guarded invocation — the canonical form is a gitconfig alias or the + plain subcommand — so the shape alone is sufficient, and the whole env-resolution attack + surface is removed rather than backstopped. +- **Inline `-c`/`--config` aliases are unchanged.** Their expansion is literally present + and bounded, so both guards resolve and re-check it as before — last value wins, + case-insensitive key match, and `!` shell-alias / git-alias expansions re-parsed one + level deep. +- **The `alias..command` subkey is now classified as an alias definition too + (`#740`).** git reads both `alias.` and its `alias..command` subkey as the + alias for `` (`git -c alias.rh.command='reset --hard' rh` runs it); the classifier + previously matched only the plain spelling, so a dangerous alias smuggled through + `.command` — via `-c` or `--config-env` — was treated as a non-alias and ran unchecked. + Both spellings are now detected. Because which spelling git runs when both are set is + git-version-dependent, the classifier does NOT mirror git's cross-spelling precedence; it + fails closed on the MAX-DANGER UNION — the last value WITHIN each spelling decides that + spelling, then the guard refuses if EITHER is `--config-env`-shaped and re-checks EVERY + inline spelling, blocking if any resolves to a guarded operation and allowing only when + both spellings are benign. On a git where a benign later `.command` genuinely overrides a + dangerous plain alias this over-blocks, which is fail-safe. +- **Removed the env-value-resolution machinery** that existed only to read a `--config-env` + value and the environment feeding it: `hook::snapshot_env` / `HOOK_ENV_SNAPSHOT`, + `hook::git_effective_config_values` / `HOOK_GIT_CONFIG_UNRESOLVED`, + `hook::git_reparse_shell_alias` with its shell-alias env inheritance + (`HOOK_GIT_ENV_INHERITED`), `hook::shell_track_persistent_env` / `HOOK_SHELL_VARS`, and + `HOOK_GIT_ENV_ASSIGNMENTS`. `hook::git_resolve_index` walks env-assignment prefixes only + to locate the git token, never to collect their values. +- **Behavior change for `--config-env` aliases.** A `--config-env` alias for the invoked + subcommand now blocks even when the named variable holds a harmless value — the value is + never consulted. Still allowed (decidable safe without reading a value): a `--config-env` + that sets a NON-alias key, one that defines an alias for a subcommand that is not + invoked, and one whose LAST value for the key is an inline `-c`/`--config`. +- **The shape refusal fires at every alias-recursion depth (`#740`).** A wrapping inline + alias whose expansion is itself a `--config-env` alias for the invoked subcommand + (`git -c alias.rh='--config-env=alias.foo=AV foo' rh`, which git runs) previously slipped + through: the refusal was gated behind `HOOK_NO_ALIAS`, which suppressed it at recursion + depth ≥ 2. The value-blind `--config-env` shape refusal is now ungated so it fires at + every depth; only the one-level inline-alias re-expansion remains bounded by + `HOOK_NO_ALIAS`. + ## [0.10.1] ### Fixed diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index 916c39b54..0812bee6f 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -220,16 +220,39 @@ check_segment() { # An inline alias runs its expansion (`git -c alias.rh='reset --hard' rh` # discards — verified), so re-check the expanded command: a shell alias # (leading !) re-parses as a full shell command; a git alias splices its - # words in place of the alias name. One level only — git does not expand - # the first word of an expansion as another alias — enforced through - # HOOK_NO_ALIAS, which dynamic scoping carries into the recursive call. - if ((${HOOK_NO_ALIAS:-0} == 0)); then - local cv exp reparse a - local -a cfgv=() expw=() - cfgv=(${HOOK_GIT_CONFIG_VALUES[@]+"${HOOK_GIT_CONFIG_VALUES[@]}"}) - for cv in ${cfgv[@]+"${cfgv[@]}"}; do - [[ "$cv" == "alias.${sub}="* ]] || continue - exp="${cv#*=}" + # words in place of the alias name. A --config-env alias for the invoked + # subcommand is refused by SHAPE — its expansion lives in an env var this guard + # never reads (hook::git_alias_expansion). + # + # The SHAPE refusal must fire at EVERY recursion depth: a wrapping inline alias + # can expand to `--config-env=alias.=` defining the invoked sub + # (`git -c alias.rh='--config-env=alias.foo=AV foo' rh`), which git runs. It is + # value-blind, cheap, and terminal, so it is NOT gated by HOOK_NO_ALIAS. Only the + # INLINE-alias re-expansion is one-level (HOOK_NO_ALIAS bounds the recursion — + # git does not re-expand the first word of an expansion as another alias). + local exp reparse a alias_rc + local -a expw=() + hook::git_alias_expansion "$sub" + alias_rc=$? + if ((alias_rc == 2)); then + # Structural fail-closed: the invoked subcommand is an alias whose LAST definition + # (here or in a wrapping alias's expansion) is `--config-env=alias.=`, + # so its expansion is an environment variable's value. Reading that value is the + # recurring fail-open surface (fed by an ambient var, an inline/`env` prefix, an + # `export`, `set -a`, or a nested `bash -c` in any wrapper); the shape alone is + # sufficient. The allow-list is not consulted, as with the too-long-command path. + echo "BLOCKED: git alias '$sub' is defined via --config-env, so its expansion cannot be verified — failing closed." >&2 + echo "Define the alias in git config, run the subcommand directly, or set the guardrails block_dangerous_git_enabled option to false to bypass." >&2 + emit_tel "blocked" "config-env-alias" + exit 2 + fi + if ((alias_rc == 0)) && ((${HOOK_NO_ALIAS:-0} == 0)); then + # Inline alias (-c/--config): each spelling's expansion is literally present. Re-check + # EVERY spelling (plain and `.command`) independently so a benign expansion in one + # never suppresses a dangerous sibling in the other. + # shellcheck disable=SC2154 # HOOK_GIT_ALIAS_EXPS is set by hook::git_alias_expansion + for exp in ${HOOK_GIT_ALIAS_EXPS[@]+"${HOOK_GIT_ALIAS_EXPS[@]}"}; do + [[ -n "$exp" ]] || continue if [[ "$exp" == '!'* ]]; then # Shell alias: git runs the expansion as a shell command with the # invocation's trailing args appended (positional), so append them @@ -248,7 +271,6 @@ check_segment() { check_segment "${w[@]:0:gi+1}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}" HOOK_NO_ALIAS=0 fi - break done fi diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 3353ac188..eb091d980 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -203,6 +203,68 @@ run "git -c alias.rh='reset --hard' rh (inline git alias, blocked)" "git -c alia run "git -c alias.nuke='!git reset --hard' nuke (inline shell alias, blocked)" "git -c alias.nuke='!git reset --hard' nuke" 2 run "git -c alias.st=status st (safe alias, allowed)" "git -c alias.st=status st" 0 run "git -c alias.rh='reset --hard' status (alias defined, not run, allowed)" "git -c alias.rh='reset --hard' status" 0 +# --config-env== holds the alias expansion in an ENVIRONMENT VARIABLE, and +# this guard never reads that value: its origin — an ambient var, an inline/`env` prefix, +# an `export`, `set -a`, or a nested `bash -c` in any wrapper — is the recurring fail-open +# surface. An env-defined alias for the INVOKED subcommand is refused by SHAPE alone. +run "env-defined alias for the invoked sub (blocked by shape)" "git --config-env=alias.rh=AV rh" 2 +run "env-defined alias, two-word --config-env form (blocked)" "git --config-env alias.rh=AV rh" 2 +run "env-defined alias, benign-looking value STILL blocked (value never read)" "git --config-env=alias.st=AV st" 2 AV=status +run "env-defined alias, case-folded key (blocked)" "git --config-env=alias.RH=AV rh" 2 +run "env-defined alias, non-identifier env name (blocked)" "git --config-env=alias.rh=bad-rh rh" 2 +run "env-defined alias, leading-dash env name (blocked)" "env -- '-AV=x' git --config-env=alias.rh=-AV rh" 2 +run "env value last-wins over an inline decoy for the same key (blocked)" "git -c alias.rh=status --config-env=alias.rh=AV rh" 2 +# Inline (-c/--config) aliases still carry the expansion literally and are resolved. +run "inline dangerous alias (blocked)" "git -c alias.rh='reset --hard' rh" 2 +run "inline alias, case-folded subcommand (blocked)" "git -c alias.rh='reset --hard' RH" 2 +run "inline alias, case-folded key (blocked)" "git -c alias.RH='reset --hard' rh" 2 +# git also reads the `alias..command` subkey as the alias definition +# (`git -c alias.rh.command='reset --hard' rh` runs it); the guard classifies that +# spelling as an alias too, inline and by --config-env shape. +run "inline dangerous .command-subkey alias (blocked)" "git -c alias.rh.command='reset --hard' rh" 2 +run "env-defined .command-subkey alias for the invoked sub (blocked by shape)" "git --config-env=alias.rh.command=AV rh" 2 +run ".command-subkey alias, case-folded key (blocked)" "git -c alias.RH.command='reset --hard' rh" 2 +# A non-`command` alias subkey is not an alias to git, so it must not be blocked. +run "non-command alias subkey is not an alias (allowed)" "git -c alias.rh.nope='reset --hard' rh" 0 +# MAX-DANGER UNION: which spelling git runs when both are set is version-dependent, so a +# benign value in one spelling must never mask a dangerous value in the other — the guard +# blocks if EITHER spelling is dangerous, and allows only when BOTH are benign. +run "dangerous plain masked by a benign .command (blocked by union)" "git -c alias.rh='reset --hard' -c alias.rh.command=status rh" 2 +run "dangerous .command masked by a benign plain (blocked by union)" "git -c alias.rh=status -c alias.rh.command='reset --hard' rh" 2 +run "dangerous plain, benign .command decoy first (blocked by union)" "git -c alias.rh.command=status -c alias.rh='reset --hard' rh" 2 +run "both spellings benign (allowed)" "git -c alias.rh=status -c alias.rh.command=log rh" 0 +# Union on the --config-env shape path: an env spelling refuses even when the sibling +# inline spelling is benign (both command-line orders). +run "env plain spelling refuses despite a benign inline .command (blocked)" "git --config-env=alias.rh=AV -c alias.rh.command=status rh" 2 +run "env .command spelling refuses despite a benign inline plain (blocked)" "git --config-env=alias.rh.command=AV -c alias.rh=status rh" 2 + +# The env-defined alias is refused wherever it APPEARS, through any wrapper — no env +# propagation is tracked, so every prior env-carrying bypass (export / set -a / command +# prefix / bash -c / snapshot-global collision) is closed by construction. +run "env-defined alias inside an inline '!' shell alias (blocked)" "git -c \"alias.sh=!git --config-env=alias.rh=AV rh\" sh" 2 +# An inline alias whose expansion is itself a `--config-env` alias for the invoked sub +# runs at recursion depth 2, where the SHAPE refusal must still fire (real git runs it: +# reverts the worktree). Verified against ground truth. +run "wrapping inline alias expands to a --config-env alias (depth-2 shape refusal, blocked)" \ + "git -c alias.rh='--config-env=alias.foo=AV foo' rh" 2 "AV=reset --hard" +run "env-defined alias inside an env-prefixed bash -c (blocked)" "AV='reset --hard' bash -c 'git --config-env=alias.rh=AV rh'" 2 +run "env-defined alias after export in a shell-alias body (blocked)" "git -c \"alias.sh=!export AV='reset --hard'; git --config-env=alias.rh=AV rh\" sh" 2 +run "env-defined alias after 'then export' in a compound command (blocked)" "git -c 'alias.sh=!if true; then export AV=\"reset --hard\"; fi; git --config-env=alias.rh=AV rh' sh" 2 +run "env-defined alias after 'export NAME; NAME=val' (blocked)" "git -c 'alias.sh=!export AV; AV=\"reset --hard\"; git --config-env=alias.rh=AV rh' sh" 2 +run "env-defined alias after an assignment-prefixed export (blocked)" "git -c 'alias.sh=!AV=\"reset --hard\" export AV; git --config-env=alias.rh=AV rh' sh" 2 +run "env-defined alias after 'set -a; NAME=val' allexport (blocked)" "set -a; AV='reset --hard'; git --config-env=alias.rh=AV rh" 2 +run "env-defined alias whose env name collides with an internal global (blocked)" "git --config-env=alias.rh=HOOK_ENV_SNAPSHOT_OK rh" 2 "HOOK_ENV_SNAPSHOT_OK=reset --hard" + +# ACCEPTANCE — decidable safe WITHOUT reading any value, so still allowed: +run "--config-env setting a NON-alias key (allowed)" "git --config-env=core.pager=PAGERVAR status" 0 +run "--config-env alias for a subcommand that is NOT invoked (allowed)" "git --config-env=alias.foo=AV status" 0 +run "inline value last-wins over an earlier --config-env for the same key (allowed)" "git --config-env=alias.rh=AV -c alias.rh=status rh" 0 +# A `$( )` env name is command-substituted by the shell before git and split by the +# static parser — neither evaluates it, so no exec and nothing dangerous runs (git-fatal). +rm -f "$TEST_TMPDIR/pwned-dg" +run "injection-shaped config-env env name (allowed — never evaluated)" \ + "git --config-env=alias.rh=\$(touch $TEST_TMPDIR/pwned-dg) rh" 0 +assert_file_absent "config-env injection: no exec for a shell-metachar env name" "$TEST_TMPDIR/pwned-dg" run "command -p git reset --hard (command wrapper option, blocked)" "command -p git reset --hard" 2 run "command -- git reset --hard (command end-of-options, blocked)" "command -- git reset --hard" 2 run "exec -c git reset --hard (exec wrapper option, blocked)" "exec -c git reset --hard" 2 diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index 32ae8f5b5..0ed13ace1 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -210,30 +210,48 @@ check_segment() { # only — git does not expand the first word of an expansion as another alias — # enforced through HOOK_NO_ALIAS, which dynamic scoping carries into the # recursive call. + # The --config-env SHAPE refusal is value-blind, terminal, and must fire at EVERY + # recursion depth: a wrapping inline alias can expand to `--config-env=alias.=…` + # that defines the invoked subcommand (`git -c alias.c='--config-env=alias.foo=AV foo' + # c`), which git runs. It is therefore NOT gated by HOOK_NO_ALIAS. The inline-alias + # re-expansion and the gitconfig-alias probe below ARE one-level (HOOK_NO_ALIAS bounds + # the recursion — git does not re-expand an expansion's first word as another alias). + local exp reparse a alias_rc + local -a expw=() + hook::git_alias_expansion "$sub" + alias_rc=$? + if ((alias_rc == 2)); then + # Structural fail-closed: the invoked subcommand's alias is defined via --config-env + # (here or in a wrapping alias's expansion), whose value is the recurring fail-open + # surface (fed by an ambient var, an inline/`env` prefix, an `export`, `set -a`, or a + # nested `bash -c` in any wrapper); a commit smuggled through it cannot be verified, + # and defining an alias this way on a guarded invocation is never canonical. + echo "BLOCKED: git alias '$sub' is defined via --config-env, so its expansion cannot be verified — failing closed." >&2 + echo "Commit with \`git commit -F -\` (or the /commit skill), define aliases in git config, or set the guardrails block_noncanonical_commit_enabled option to false to bypass." >&2 + emit_tel "blocked" "config-env-alias" + exit 2 + fi if ((${HOOK_NO_ALIAS:-0} == 0)); then - local cv exp reparse a - local -a cfgv=() expw=() - cfgv=(${HOOK_GIT_CONFIG_VALUES[@]+"${HOOK_GIT_CONFIG_VALUES[@]}"}) - # LAST value wins, matching git: `-c alias.c=status -c alias.c=commit` - # runs commit. Taking the first match would let a decoy earlier value - # (expanding to a harmless subcommand) mask the real one. - exp="" - for cv in ${cfgv[@]+"${cfgv[@]}"}; do - [[ "$cv" == "alias.${sub}="* ]] && exp="${cv#*=}" - done - if [[ -n "$exp" ]]; then - inline_alias_handled=1 - if [[ "$exp" == '!'* ]]; then - reparse="${exp#!}" - for a in "${w[@]:sub_idx+1}"; do reparse+=" $(printf '%q' "$a")"; done - hook::bash_parse_segments "$reparse" check_segment - else - hook::env_s_split "$exp" - expw=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"}) - HOOK_NO_ALIAS=1 - check_segment "${w[@]:0:gi+1}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}" - HOOK_NO_ALIAS=0 - fi + if ((alias_rc == 0)); then + # Inline alias (-c/--config): each spelling's expansion is literally present. Re-check + # EVERY spelling (plain and `.command`) independently so a benign expansion in one + # never suppresses a dangerous sibling in the other. + # shellcheck disable=SC2154 # HOOK_GIT_ALIAS_EXPS is set by hook::git_alias_expansion + for exp in ${HOOK_GIT_ALIAS_EXPS[@]+"${HOOK_GIT_ALIAS_EXPS[@]}"}; do + [[ -n "$exp" ]] || continue + inline_alias_handled=1 + if [[ "$exp" == '!'* ]]; then + reparse="${exp#!}" + for a in "${w[@]:sub_idx+1}"; do reparse+=" $(printf '%q' "$a")"; done + hook::bash_parse_segments "$reparse" check_segment + else + hook::env_s_split "$exp" + expw=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"}) + HOOK_NO_ALIAS=1 + check_segment "${w[@]:0:gi+1}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}" + HOOK_NO_ALIAS=0 + fi + done fi # An alias can also live in .git/config, ~/.gitconfig, or system config, diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index f3166e758..6f9b6a5db 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -90,6 +90,70 @@ run "last inline alias value wins (blocked)" \ run "last inline alias value wins (allowed when the last is harmless)" \ "git -c alias.c=commit -c alias.c=status c -m x" 0 +# --- --config-env aliases are refused by SHAPE ------------------------------- +# `--config-env==` holds the alias expansion in an env var this guard never +# reads (its origin — an ambient var, an inline/`env` prefix, an `export`, `set -a`, or a +# nested `bash -c` in any wrapper — is the recurring fail-open surface). An env-defined +# alias for the INVOKED subcommand is refused by shape; a commit smuggled through it can +# never be verified. The extra-env below is ignored by the guard. +run "config-env alias for the invoked sub (blocked by shape)" "git --config-env=alias.c=AV c" 2 +run "config-env alias, two-word --config-env form (blocked)" "git --config-env alias.c=AV c" 2 +run "config-env alias, benign-looking value STILL blocked (value never read)" "git --config-env=alias.st=AV st" 2 AV=status +run "config-env alias, case-folded key (blocked)" "git --config-env=alias.C=AV c" 2 +run "config-env alias, non-identifier env name (blocked)" "git --config-env=alias.c=bad-name c" 2 +run "config-env alias, leading-dash env name (blocked)" "env -- '-CV=x' git --config-env=alias.c=-CV c" 2 +run "config-env value last-wins over an inline decoy (blocked)" "git -c alias.c=log --config-env=alias.c=AV c" 2 + +# Refused wherever it APPEARS, through any wrapper — no env propagation is tracked, so +# every prior env-carrying bypass is closed by construction. +run "config-env alias inside an inline '!' shell alias (blocked)" "git -c \"alias.sh=!git --config-env=alias.c=AV c --allow-empty -m x\" sh" 2 +# An inline alias whose expansion is itself a `--config-env` alias for the invoked sub +# runs at recursion depth 2, where the SHAPE refusal must still fire (real git commits). +run "wrapping inline alias expands to a --config-env alias (depth-2 shape refusal, blocked)" \ + "git -c alias.c='--config-env=alias.foo=AV foo' c" 2 AV=commit +run "config-env alias inside an env-prefixed bash -c (blocked)" "AV=commit bash -c 'git --config-env=alias.c=AV c -m x'" 2 +run "config-env alias after export in a shell-alias body (blocked)" "git -c \"alias.sh=!export AV=commit; git --config-env=alias.c=AV c --allow-empty -m x\" sh" 2 +run "config-env alias after 'then export' in a compound command (blocked)" "git -c 'alias.sh=!if true; then export AV=commit; fi; git --config-env=alias.c=AV c --allow-empty -m x' sh" 2 +run "config-env alias after an assignment-prefixed export (blocked)" "git -c 'alias.sh=!AV=commit export AV; git --config-env=alias.c=AV c --allow-empty -m x' sh" 2 +run "config-env alias after 'set -a; NAME=val' allexport (blocked)" "set -a; AV=commit; git --config-env=alias.c=AV c -m x" 2 +run "config-env alias, env name colliding with an internal global (blocked)" "git --config-env=alias.c=HOOK_ENV_SNAPSHOT_OK c" 2 "HOOK_ENV_SNAPSHOT_OK=commit" + +# ACCEPTANCE — decidable safe WITHOUT reading a value, so still allowed: +run "--config-env setting a NON-alias key, canonical commit (allowed)" "git --config-env=user.name=NAMEVAR commit -F -" 0 +run "--config-env alias for a subcommand that is NOT invoked (allowed)" "git --config-env=alias.foo=AV status" 0 +run "inline value last-wins over an earlier --config-env for the same key (allowed)" "git --config-env=alias.c=AV -c alias.c=log c" 0 +# A `$( )` env name is command-substituted by the shell before git and split by the +# static parser — neither evaluates it, so no exec and (git-fatal) no commit runs. +rm -f "$TEST_TMPDIR/pwned-nc" +run "injection-shaped config-env env name (allowed — never evaluated)" \ + "git --config-env=alias.c=\$(touch $TEST_TMPDIR/pwned-nc) c" 0 +assert_file_absent "config-env injection: no exec for a shell-metachar env name" "$TEST_TMPDIR/pwned-nc" + +# --- case-insensitive alias resolution (git folds config names) -------------- +run "inline alias, uppercase subcommand (blocked)" "git -c alias.c=commit C -m x" 2 +run "inline alias, uppercase alias key (blocked)" "git -c alias.C=commit c -m x" 2 +run "inline alias, uppercase both, to canonical form (allowed)" "git -c alias.C=commit C -F -" 0 + +# --- `alias..command` subkey is an alias definition too ------------------ +# git reads the `alias..command` subkey as the alias (`git -c alias.c.command=commit +# c -m x` commits non-canonically); the guard classifies that spelling inline and by shape. +run "inline .command-subkey alias to commit -m (blocked)" "git -c alias.c.command=commit c -m bypass" 2 +run "config-env .command-subkey alias for the invoked sub (blocked by shape)" "git --config-env=alias.c.command=AV c" 2 +run ".command-subkey alias, case-folded key (blocked)" "git -c alias.C.command=commit c -m x" 2 +# A non-`command` alias subkey is not an alias to git, so it must not be blocked. +run "non-command alias subkey is not an alias (allowed)" "git -c alias.c.nope=commit c -m bypass" 0 +# MAX-DANGER UNION: which spelling git runs when both are set is version-dependent, so a +# benign value in one spelling must never mask a commit alias in the other — the guard +# blocks if EITHER spelling commits non-canonically, and allows only when BOTH are benign. +run "commit plain masked by a benign .command (blocked by union)" "git -c alias.c=commit -c alias.c.command=status c -m x" 2 +run "commit .command masked by a benign plain (blocked by union)" "git -c alias.c=status -c alias.c.command=commit c -m x" 2 +run "commit plain, benign .command decoy first (blocked by union)" "git -c alias.c.command=status -c alias.c=commit c -m x" 2 +run "both spellings benign non-commit (allowed)" "git -c alias.c=status -c alias.c.command=log c" 0 +# Union on the --config-env shape path: an env spelling refuses even when the sibling +# inline spelling is benign (both command-line orders). +run "env plain spelling refuses despite a benign inline .command (blocked)" "git --config-env=alias.c=AV -c alias.c.command=status c" 2 +run "env .command spelling refuses despite a benign inline plain (blocked)" "git --config-env=alias.c.command=AV -c alias.c=status c" 2 + # --- other subcommands are untouched ----------------------------------------- run "git log (allowed)" "git log --oneline -5" 0 run "git push (allowed)" "git push origin main" 0 diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index 22bdafcc9..07ffee6ca 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.6.0", + "version": "0.6.1", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2, using the consuming repo's own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 721e41060..59279c3b2 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,16 @@ 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.6.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.6.0] ### Added diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 17212cd40..d10557f3f 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.5.0", + "version": "0.5.1", "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 ed04fa5c9..dd3eeb412 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,16 @@ 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.5.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.5.0] ### Added diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 73a648bd6..e7c4eae3e 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.0", + "version": "0.5.1", "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 0e55f01a7..542a7165d 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,16 @@ 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.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.5.0] ### Added diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index 2dbd5a77f..5f49d5243 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.3.0", + "version": "0.3.1", "description": "Auto-fix spelling typos on edit via typos-cli, unconditionally — 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 6d8a1c712..8723f8c03 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,16 @@ 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.3.1] + +### Changed + +- Sync of the shared `hook-utils.sh`: the git-option parser distinguishes `--config-env` + (an env-var name) from `-c`/`--config` (an inline value), and a `--config-env` alias for + a guarded subcommand is refused by shape rather than by resolving the environment + variable's value (`#740`). No behavior change for this plugin — it does not inspect git + config values; shipped so consumers receive the shared library update. + ## [0.3.0] ### Added diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index a5234be7b..b854e2598 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -628,6 +628,10 @@ hook::git_is_bin() { # 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 # HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. # shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_index() { @@ -640,41 +644,64 @@ hook::git_resolve_index() { while ((i < n)); do tok="${w[i]}" if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). ((i++)) continue fi case "${tok##*/}" in env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. ((i++)) - while ((i < n)) && [[ "${w[i]}" == -* ]]; do - case "${w[i]}" in - # -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. - -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}") - n=${#w[@]} - i=0 - continue 2 - ;; - -S* | --split-string=*) - local sval="${w[i]#-S}" - sval="${sval#--split-string=}" - hook::env_s_split "$sval" - w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") - n=${#w[@]} - i=0 - continue 2 - ;; - -u | --unset | -C | --chdir) ((i += 2)) ;; - -*) ((i++)) ;; - *) ((i++)) ;; - esac + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -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. + -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}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi done continue ;; @@ -778,6 +805,13 @@ hook::git_resolve_index() { # order, so a guard can inspect config assignments # without re-walking (commit messages and pathspecs # are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. # Call as: hook::git_resolve_subcommand # shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file hook::git_resolve_subcommand() { @@ -788,17 +822,34 @@ hook::git_resolve_subcommand() { HOOK_GIT_SUB="" HOOK_GIT_SUB_IDX=-1 HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() j=$((gi + 1)) while ((j < nseg)); do gw="${w[j]}" case "$gw" in - -c | --config | --config-env) - ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } ((j += 2)) ;; - --config=* | --config-env=*) + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") ((j++)) ;; -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) @@ -817,6 +868,61 @@ hook::git_resolve_subcommand() { return 1 } +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + # Single linear pass: read the command into a char array once (O(n)), then walk # it splitting top-level segments on UNQUOTED control operators and tokenizing # each segment into argv words honoring '…', "…", $'…', and backslash escapes @@ -934,8 +1040,8 @@ hook::bash_parse_segments() { # following lines is the command's stdin, so record the delimiter and # let the newline handler skip the body. A quoted/backslashed delimiter # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. - if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ - && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then ((i++)) local hstrip=0 if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then