Problem
hook-utils.sh helpers degrade silently to a permissive answer when their prerequisite is missing. That default is fine for the advisory/formatting hooks the helpers were written for, and wrong for the security guards that now consume them: a guard that cannot determine the truth must not proceed as if it had.
This is fleet-wide. hook-utils.sh is SSOT-synced by scripts/sync-hook-utils.sh from lib/hook-utils.sh, so every carrying plugin inherits the behavior.
Two instances, found independently by two different agents on two different PRs. That is the reason to file the pattern rather than either instance.
Instance 1 — path helpers fall back to the unresolved input
hook::physical_path returns its input unchanged when neither realpath nor readlink -f exists:
hook::physical_path() {
local resolved
if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then
if [[ -n "$resolved" ]]; then printf '%s' "$resolved"; return; fi
fi
printf '%s' "$1" # <- lexical path, silently
}
hook::repo_root does the same, falling back to the hint when git rev-parse --show-toplevel cannot answer.
Consequence found on #1097: a guard leaning on hook::physical_path for a containment check compared the lexical path where no canonicalizer existed, so an escaping symlink read as in-repository and was pinned by its lexical path.
Consequence on #1085 (this PR): block-noncanonical-commit needed "which repository will this command run in", where an unknown answer must block. Because both helpers report success-with-a-guess, the guard cannot distinguish "resolved" from "fell back", so neither helper was usable and the guard grew its own resolution with visible failure. Declining the shared helpers was the right call, but it means the lib is now bypassed by exactly the consumers with the strictest needs.
Instance 2 — the tokenizer does not honor # as a shell comment
hook::bash_parse_segments has no # handling, so words a shell would discard survive into the parsed argv.
Concrete chain, traced on #1085 with alias.b = 'commit --allow-empty -m bypass' persisted in the current repo:
$ git -c alias.a='!git b #' a -C <other-repo>
reparse handed to the tokenizer: git b # -C <other-repo>
tokenized argv: [git, b, #, -C, <other-repo>] <- '#' kept as a word
after alias splice: git commit --allow-empty -m bypass '#' -C <other-repo>
commit-argument scan: sees `-C` -> reads it as --reuse-message -> exempt=1
verdict: exit 0 (allowed)
real git: hands the body to a shell, which drops everything from the '#',
so git runs `git b` only and the non-canonical commit LANDS.
git appends the invocation's trailing words to a ! body, and the shell then discards them at the #. The guard keeps them, and a leftover -C grants a bogus --reuse-message exemption. Note the exemption is the fail-open: the alias is correctly resolved and the commit -m is reached, then the verdict is thrown away.
No guard-local workaround is appropriate — deciding whether a given # is quoted is the tokenizer's job, and approximating shell comment semantics inside one guard is the modelling-instead-of-asking mistake that produced several earlier bypasses in this same file.
Why one issue
Both instances are the same design decision: a shared helper answering "I could not determine this" as a usable value instead of a distinguishable failure. Fixing them one at a time invites the next consumer to hit the third instance. What is worth deciding is the lib-wide contract.
Fix direction
- Make undeterminable distinguishable. Give the affected helpers a failure signal a caller can branch on — a non-zero return, or an out-param plus empty result — so a guard can fail closed while an advisory hook keeps its current permissive behavior by ignoring it. Preserving today's behavior for existing callers matters: these are consumed fleet-wide.
- Document the contract per helper, so a future consumer does not have to read the implementation to learn that success does not mean resolved.
hook::bash_parse_segments: honor # as a comment where a shell would, with the quoting rules the tokenizer already implements for ', ", and $'…'.
- Sequencing note: any change here forces a version bump across every carrying plugin via
sync-hook-utils.sh --check-bump, so it wants to be a deliberate lib change rather than a rider on a feature PR.
Related
Problem
hook-utils.shhelpers degrade silently to a permissive answer when their prerequisite is missing. That default is fine for the advisory/formatting hooks the helpers were written for, and wrong for the security guards that now consume them: a guard that cannot determine the truth must not proceed as if it had.This is fleet-wide.
hook-utils.shis SSOT-synced byscripts/sync-hook-utils.shfromlib/hook-utils.sh, so every carrying plugin inherits the behavior.Two instances, found independently by two different agents on two different PRs. That is the reason to file the pattern rather than either instance.
Instance 1 — path helpers fall back to the unresolved input
hook::physical_pathreturns its input unchanged when neitherrealpathnorreadlink -fexists:hook::repo_rootdoes the same, falling back to the hint whengit rev-parse --show-toplevelcannot answer.Consequence found on #1097: a guard leaning on
hook::physical_pathfor a containment check compared the lexical path where no canonicalizer existed, so an escaping symlink read as in-repository and was pinned by its lexical path.Consequence on #1085 (this PR):
block-noncanonical-commitneeded "which repository will this command run in", where an unknown answer must block. Because both helpers report success-with-a-guess, the guard cannot distinguish "resolved" from "fell back", so neither helper was usable and the guard grew its own resolution with visible failure. Declining the shared helpers was the right call, but it means the lib is now bypassed by exactly the consumers with the strictest needs.Instance 2 — the tokenizer does not honor
#as a shell commenthook::bash_parse_segmentshas no#handling, so words a shell would discard survive into the parsed argv.Concrete chain, traced on #1085 with
alias.b = 'commit --allow-empty -m bypass'persisted in the current repo:git appends the invocation's trailing words to a
!body, and the shell then discards them at the#. The guard keeps them, and a leftover-Cgrants a bogus--reuse-messageexemption. Note the exemption is the fail-open: the alias is correctly resolved and thecommit -mis reached, then the verdict is thrown away.No guard-local workaround is appropriate — deciding whether a given
#is quoted is the tokenizer's job, and approximating shell comment semantics inside one guard is the modelling-instead-of-asking mistake that produced several earlier bypasses in this same file.Why one issue
Both instances are the same design decision: a shared helper answering "I could not determine this" as a usable value instead of a distinguishable failure. Fixing them one at a time invites the next consumer to hit the third instance. What is worth deciding is the lib-wide contract.
Fix direction
hook::bash_parse_segments: honor#as a comment where a shell would, with the quoting rules the tokenizer already implements for',", and$'…'.sync-hook-utils.sh --check-bump, so it wants to be a deliberate lib change rather than a rider on a feature PR.Related
!alias hop #1469 —block-dangerous-git'srepo_oid_width(), another "the guard cannot see which repository this runs in" instance.