Skip to content

fix(guardrails): close git-alias-chain re-expansion bypass of both git guards - #1503

Merged
kyle-sexton merged 7 commits into
mainfrom
fix/964-alias-chain-reexpansion-signed
Jul 26, 2026
Merged

fix(guardrails): close git-alias-chain re-expansion bypass of both git guards#1503
kyle-sexton merged 7 commits into
mainfrom
fix/964-alias-chain-reexpansion-signed

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Replaces #1085, whose branch could not be merged.

Why this PR exists

#1085 carried commit baa459b4, which had a correct committer email but no signature at all (verification.reason: unsigned). main requires verified signatures and GitHub evaluates every PR commit not reachable from the base, so #1085 was permanently BLOCKED with all checks green and zero unresolved threads. Adding further signed commits does not clear it — GitHub still sees the unsigned one.

Fixing it in place would need a history rewrite (force-push), which is denied in this environment. So the branch was rebuilt as a single correctly-signed commit on current main.

Content

Identical to #1085 — a clean 3-way merge --squash of its branch onto current main, no conflicts, so no main content was reverted:

  • hooks/block-dangerous-git.sh and hooks/block-noncanonical-commit.sh — close the alias-chain re-expansion bypass
  • matching .test.sh suites for both
  • both schema files, plugin manifest, CHANGELOG

Related

Closes #964. Replaces #1085.

…t guards

Rebuilt as a single signed commit. The prior branch history carried an unsigned
commit, which GitHub reports as an unverified signature and which blocks the
required-signatures rule on main. No content change.

Refs #964
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 45s —— View job


PR Review

  • Gather context and understand the PR
  • Read changed shell scripts (block-dangerous-git.sh, block-noncanonical-commit.sh)
  • Read test files for both hooks
  • Review schema and manifest changes
  • Assess correctness, security, error handling, and test coverage
  • Post complete review findings

Summary

This PR closes a real, verified bypass of both git guards via multi-hop alias chaining (#964). The implementation is sound, the test coverage is comprehensive and rigorous, and the two design invariants — memoization and budget cap — are correctly scoped. I have a few observations and one minor correctness note.


Core Bypass Being Closed

The old code used HOOK_NO_ALIAS=1 to prevent any further alias resolution after the first hop. This meant git -c alias.rh=foo -c alias.foo='reset --hard' rh was allowed: the guard expanded rh → foo, then refused to expand foo (HOOK_NO_ALIAS), and since foo is not literally reset, it passed. The fix correctly recurses to the terminal subcommand. ✓


Critical Additional Fix: Splice Widening

The old recursive call was:

check_segment "${w[@]:0:gi+1}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}"

The new call is:

nextw=("${w[@]:0:sub_idx}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}")

The old code sliced 0..gi (just git), dropping every -c/--config/--config-env global between git and the subcommand. A second hop would therefore see empty config: the alias.foo definition for the second hop was invisible, and the --config-env shape refusal would never fire at hop 2. The widened slice 0..sub_idx carries all globals into every recursive hop. This is a standalone correctness bug fixed in the same change. ✓


explicit_git_dir → Last-Wins Fix

The old explicit_git_dir returned on the first --git-dir match. The new explicit_global scans all occurrences and retains only the last, matching git's own last-wins semantics for repeated options. git --git-dir=A --git-dir=B commit was previously scoped to repo A; now it correctly resolves to B. This matters for the sequencer probe and is a quiet correctness fix. ✓


Bounding Design

Memoization (HOOK_ALIAS_MEMO): The comment's invariant holds — a verdict is a pure function of (HOOK_ALIAS_SEEN, argv) with all other inputs invocation-constant. A state that didn't block the first time cannot block the second. Memo hits correctly don't count against the budget (the counter increment is AFTER the memo lookup). ✓

Budget cap (HOOK_ALIAS_WORK_MAX=128): Invocation-global, not path-local, and fail-CLOSED on exhaustion. The calibration comment in block-dangerous-git.sh ("MAX_COMMAND_LEN admits chains of roughly 430 hops") and in block-noncanonical-commit.sh ("60-hop chain measured ~0.5s") both justify 128 as a safe bound well above any real usage. ✓

HOOK_ALIAS_SEEN save/restore: Saved before the expansion loop, subcommand pushed before recursing, and correctly cleared for ! shell aliases (fresh git process semantics). The outer set is restored after every branch, so sibling segments and unwound hops start clean. ✓


block-noncanonical-commit.sh Additional Complexity

The persisted-alias path adds HOOK_EFFECTIVE_BASE, HOOK_SHELL_ALIAS_SEEN, repo_identity, and collect_locating_globals. These are all necessary because:

  • A ! body runs from the outer repository's top level (not the session cwd or the invoking subdirectory). HOOK_EFFECTIVE_BASE is correctly set to HOOK_REPO_IDENTITY (from rev-parse --show-toplevel) before each ! reparse, then restored. ✓
  • A persisted shell alias (alias.a = !git a) makes real git fork endlessly without committing — the guard correctly models this as allow-safe by skipping repeats in HOOK_SHELL_ALIAS_SEEN. ✓
  • repo_identity deliberately hands the literal composed path to git -C, not a pre-resolved one, because lexical . / .. cancellation is wrong on platforms with symlinks. The test suite includes a platform-gated symlink fixture for this. ✓
  • Locating globals (--git-dir, --work-tree, --namespace) are replayed onto the repo_identity probe in command-line order (last-wins by git itself). The test for --git-dir/--work-tree through a shell alias verifies this doesn't produce false positives. ✓

One nuance: The inline ! path clears HOOK_ALIAS_SEEN=() but does not clear HOOK_SHELL_ALIAS_SEEN. This is intentional — the shell seen-set acts as a cross-hop cycle guard for persisted shell aliases even inside an inline ! body. The comment says "persisted-config shell hops are bounded by HOOK_SHELL_ALIAS_SEEN below" which is accurate and safe.


Minor Observations

alias_reexpand_admit key asymmetry between the two guards (intentional, but worth noting): block-dangerous-git.sh's key does not include HOOK_EFFECTIVE_BASE, while block-noncanonical-commit.sh's does. This is correct — the dangerous-git guard resolves only inline aliases and never calls persisted_alias, so the repository directory has no effect on the analysis. Not an issue, but reviewers seeing both guards for the first time will notice the difference.

No test for block-dangerous-git.sh persisted-config aliases: That guard intentionally doesn't do persisted-alias lookups (only inline -c), so there's nothing missing — this is by design.

Schema descriptions updated correctly: Both alias-traversal-cap form tokens are now documented in the form field descriptions of the telemetry schemas. ✓

Version bump: 0.16.3 → 0.16.4 is appropriate for a security fix. ✓


Test Coverage Assessment

Both suites are rigorous and include:

  • Two-hop, three-hop, .command-subkey second hop, and ! shell-alias body re-invoking outer chain name
  • Alias cycle termination without hanging
  • Wall-clock ceiling assertions (run_bounded with timeout 30) for the timing regressions — an exit-code-only assertion would not catch re-introduced exponential blowup
  • Budget exhaustion message assertion (checks the block message contains "re-expansions")
  • Benign controls for all new cases

The block-noncanonical-commit.test.sh additions also cover nested repository descent, top-level body execution, --git-dir/--work-tree identity replay, and symlinked .. paths (platform-gated). This is unusually thorough for security-adjacent shell code. ✓


Verdict

Approve. The security fix is correct, complete, and well-bounded. No issues requiring changes were found. The implementation correctly models git's alias expansion semantics, the bounding invariants are sound, and the test coverage verifies both correctness and performance properties.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 7m 13s —— View job


Security Review

  • Gather PR diff and changed files
  • Review block-dangerous-git.sh for security issues
  • Review block-noncanonical-commit.sh for security issues
  • Review test scripts for security concerns
  • Review schema/manifest changes
  • Report findings

Summary

This PR replaces a one-level alias-expansion guard (HOOK_NO_ALIAS=1) in both git hooks with a full chain traversal bounded by memoization and an invocation-wide work budget. The fix targets a real, exploitable bypass where a two-hop inline alias chain could smuggle a dangerous form through an already-expanded subcommand name.

No critical security issues were found in the new code. Two lower-severity observations are documented below.


The Fixed Bypass — Verified Correct

Old behavior (HOOK_NO_ALIAS=1 one-level guard):

A command like git -c alias.a=b -c alias.b='reset --hard' a would:

  1. Expand a → b, set HOOK_NO_ALIAS=1, re-check git b
  2. See b could be further expanded to reset --hard, but HOOK_NO_ALIAS=1 blocked that step
  3. Scan subcommand b — not push/reset/clean/etc. → NOT BLOCKED
  4. Real git executes reset --hard → destruction

New behavior (HOOK_ALIAS_SEEN seen-set + alias_reexpand_admit budget gate):

The same command now traverses the full chain: a → b → reset --hard, correctly blocking at step 3. The memoization key includes the current HOOK_ALIAS_SEEN contents (ensuring different chain states don't collapse) and the full argv (ensuring splice-carried globals survive each hop). The budget cap of 128 analyses fails closed, which is the right behavior when traversal cost is exhausted.

The old splice ${w[@]:0:gi+1} (only up to the git word, dropping inline -c alias.X=... globals) is replaced with ${w[@]:0:sub_idx} (all globals between git and the subcommand), which is necessary for nested hops to see the alias definitions they're resolving. This is a correctness requirement, not just a style change.


Findings

SUGGESTION — block-dangerous-git.sh still has a known gap for persisted-config aliases

Confidence: High (gap is real) | Severity: Low (pre-existing, documented)

block-noncanonical-commit.sh gains persisted alias resolution in this PR (the new persisted_alias + HOOK_SHELL_ALIAS_SEEN infrastructure). block-dangerous-git.sh does not — it resolves only inline (-c alias.X=...) alias chains.

A user with alias.a = reset --hard in their .gitconfig can bypass block-dangerous-git.sh via git a. This was true before this PR and remains true after it. The scripts document this class as out-of-scope ("friction guard against accidental destruction, not a sandbox"). Calling it out for completeness since the two guards now diverge in what they detect.

Relevant location — inline-only expansion in block-dangerous-git.sh


SUGGESTION — repo_identity cache key uses $* (IFS-joined) for locating globals

Confidence: Low (contrived to trigger) | Severity: Low (conservative failure mode)

In block-noncanonical-commit.sh line 205:

key="$dir"$'\n'"$*"

$* joins "$@" with the first character of IFS (space by default). Two different argument configurations that serialize to the same string — e.g., --git-dir=a b (one word with a space) vs. --git-dir=a b (two words) — would share a cache entry. In practice this cannot be triggered because collect_locating_globals receives already-tokenized words from the hook parser, so --git-dir=a b (a single token with an embedded space) would never arise from a legitimately-parsed command. If it did, the cache would return a stale identity, potentially causing a persisted alias body to be analyzed in the wrong repository context — a wrong cycle-detection key, not an exploitable block bypass (since an incorrect identity causes conservative over-analysis, not under-analysis, in most paths).

Using "${*+${@@Q}}" or printf '%q ' "$@" as the key suffix would eliminate the ambiguity.


Conclusion

The bypass fix is sound. The new traversal infrastructure (memoization, budget gate, seen-set management, repo identity for cycle detection, persisted alias lookup in block-noncanonical-commit.sh) is well-designed and consistently implemented. Both fail-closed paths (budget cap, config-env alias shape refusal) look correct. No IMPORTANT or CRITICAL findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7fdebc0d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
…tree

A `!` shell-alias body launches at the work-tree top level only when git can
compute a prefix — when the caller sits inside the effective work tree. Under an
explicit --git-dir/--work-tree whose tree does not contain the caller, git skips
that chdir and runs the body where the caller stands. block-noncanonical-commit
collapsed to the top level unconditionally, so a body's `git -C child p`
resolved the work tree's child repo while real git ran the caller's — a
non-canonical commit passed (fail-open) and its mirror false-blocked a canonical
one.

Replace repo_identity with alias_launch_dir, which reads git's own answer via
`rev-parse --show-toplevel --show-prefix`: a nonempty prefix (or pure discovery
with no locating globals) returns the top level; an empty prefix under explicit
globals returns the caller's composed directory. The inside-work-tree branch is
unchanged. Adds bypass + false-block twins outside the work tree and an
inside-work-tree pair pinning the top-level branch.

Closes #964.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb1c5f8a9f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
…eexpansion-signed

# Conflicts:
#	plugins/guardrails/.claude-plugin/plugin.json
#	plugins/guardrails/CHANGELOG.md
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 886a6f3aa7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
Two fail-open holes in the new launch-directory lookup:

- The cache key joined the replayed locating globals with `$*`, flattening argv
  boundaries: `--git-dir 'X --work-tree' --namespace Z` and `--git-dir X
  --work-tree '--namespace Z'` — different repositories to git — produced one
  key, so the first git segment in a payload poisoned the cache for the second.
  Key each argv word `%q`-encoded instead; the key is now injective on the argv.
- The toplevel and prefix were parsed from one newline-delimited `rev-parse`
  call, so a repository path containing a newline truncated the toplevel and
  misread the rest as a prefix. Read them in separate calls; each value is
  delimited only by the command substitution's trailing-newline strip, which
  cannot corrupt an interior newline. The prefix call is skipped when the
  toplevel is empty, so the allow path pays no extra fork.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00a3f3038d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
`$(…)` strips every trailing newline, but a repository top-level path may end in
one (POSIX permits any byte but NUL and `/`), so dropping it returned a different
sibling directory — a wrong repository, fail-open on the alias walk. Capture each
`rev-parse` path field through a sentinel byte that absorbs the strip, then remove
git's single line terminator explicitly. Interior and trailing newlines in either
field now survive intact; the separate-call-per-field boundary is unchanged.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4491966ef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
… peel

The prior sentinel fix peeled a trailing CR after the LF, which is the same
fail-open one byte over: a POSIX top-level path may legitimately end in `\r`, and
stripping it resolves a different sibling. git ends `rev-parse --show-toplevel`/
`--show-prefix` with a BARE LF, not a CRLF, even on Windows — verified on git
2.54.0.windows.1 via `od -c` (output ends `… w o r k \n`, no `\r`). So peel
exactly one trailing `\n` and nothing else; interior/trailing newlines and a
trailing CR all survive. Add a POSIX-gated regression fixture (a repository whose
top-level path ends in a newline, whose stripped sibling carries a canonical
alias) that asserts the block on POSIX and skips loudly where the platform cannot
host such a path. Correct the round-2 CHANGELOG claim that the trailing-newline
strip was harmless, and add the sentinel-framing bullet.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

…ixture

The launch-directory probe's sentinel framing is correct and unit-verified for
every newline position (interior, single/double trailing, CRLF terminator). The
end-to-end fixture added for it actually exercised a DIFFERENT, pre-existing entry
point: the payload `cwd` is read through a bare `$(…)` one layer earlier and its
trailing newline is stripped before the probe runs, so the fixture reached the
stripped sibling regardless of the probe fix. Reaching a newline top level through
the probe alone needs a literal newline in the parsed command, which is
impractical to fixture faithfully.

Drop the conflated fixture and rely on the framing unit proof. The payload-cwd
newline strip is pre-existing and shared with main (lower severity — the session
cwd is not attacker-controlled — and its cross-platform fix must handle jq's CRLF
on Windows), tracked as #1536 rather than absorbed here.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@kyle-sexton
kyle-sexton merged commit 4510db6 into main Jul 26, 2026
30 checks passed
@kyle-sexton
kyle-sexton deleted the fix/964-alias-chain-reexpansion-signed branch July 26, 2026 09:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f68e9fe6f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

reparse="${exp#!}"
for a in "${w[@]:sub_idx+1}"; do reparse+=" $(printf '%q' "$a")"; done
hook::bash_parse_segments "$reparse" check_segment
[[ -n "$seg_dir" ]] || seg_dir="$(effective_dir "${w[@]:0:sub_idx}")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude wrapper options from alias directory resolution

When Git is reached through a wrapper, this slice includes the wrapper's arguments, so effective_dir can mistake a value-taking wrapper option for Git's -C. GNU env --help documents -u, --unset=NAME as removing an environment variable, meaning env -u -C git … runs Git without changing directory; however, the guard interprets that -C git as relocation. I reproduced this with an outer repository containing nested repositories at child and git/child: env -u -C git -c alias.a='!git -C child p' a made the guard inspect git/child's canonical p alias and return 0, while real Git inspected child's commit --allow-empty -m bypass alias and committed. Pass only Git's globals between gi and sub_idx to the directory and locating-global helpers.

Useful? React with 👍 / 👎.

kyle-sexton added a commit that referenced this pull request Jul 26, 2026
…1543)

*This was generated by AI during work-loop execution.*

## Summary

- BSD/macOS `mktemp` has no `-p` flag. #1510 left `mktemp -p` STAGED
(inactive) in
`scripts/shell-portability-tokens.txt` after enabling the sibling `date
-d`/`stat -c` classes,
because migrating the corpus was a real, multi-plugin effort out of
scope for that PR. This item
  does that migration.
- Migrated every `mktemp -p <DIR> <template>` / `mktemp -d -p <DIR>
<template>` call site (fresh
`grep -rn "mktemp[^\n]*-p" --include="*.sh"` at execution time found
**61 call sites across 24
files in 13 plugins** — the issue's "~20 sites"/"~24 files, ~56 call
sites" estimates undercounted)
to the portable `mktemp [-d] "$DIR/template"` form, which both GNU and
BSD `mktemp` accept
identically via the positional TEMPLATE argument instead of `-p`. Sites
with no explicit template
(`mktemp -p "$DIR"`) get an explicit `tmp.XXXXXXXXXX` template (GNU's
own default) rather than
relying on TMPDIR-inheritance semantics, which differ between dialects.
- Activated the `mktemp -p` token in
`scripts/shell-portability-tokens.txt` (moved ACTIVE, extended
to a combined-short-option-cluster match — `p` anywhere in a cluster
like `-dp` — mirroring the
existing `sort -V` / `grep -P` / `echo -e` tokens) and trimmed the
STAGED section comment.
- Updated `scripts/check-shell-portability.test.sh`'s staged-classes
test: split the old combined
"date -d, stat -c, mktemp -p all inactive" assertion into a "date -d,
stat -c still inactive"
assertion plus new assertions that `mktemp -p` (including the `-dp`
combined-cluster form) is now
  flagged and that the portable `mktemp "$DIR/template"` form is not.
- **A real, verified scope boundary.** Running
`scripts/check-shell-portability.sh --all` against a
pristine `origin/main` (before this PR, in a throwaway detached
worktree) already exits 1 with 68
pre-existing `PORTABILITY:` findings — none of them `mktemp`. Running
the identical scan on this
branch produces the byte-for-byte identical finding set (`mktemp`
migration nets zero), confirming
those 68 are pre-existing corpus debt orthogonal to this issue's scope
(the regex-escape family
`\b \< \> \s \S \w \W`, deliberately bare/over-flag by design, plus one
unrelated unrelated `sed -i`
site). Filed separately as #1540 rather than expanding this PR's blast
radius into an unrelated
  ~68-site triage effort.
- **5 annotations, in scope.** Touching `block-hook-bypass.test.sh` and
`markdown-format.test.sh` (to
fix their own `mktemp -p` sites) makes the diff-gated CI check scan
those files' FULL content, which
surfaced 5 of the 68 pre-existing findings in those two files
specifically (PowerShell
module-qualified command strings, a Windows path literal, and one
unsuffixed `sed -i` in a test
fixture — all pre-existing, unrelated to `mktemp -p`, none on lines this
PR touches). Since this
PR's own act of touching those files is what makes them newly
load-bearing for CI, annotated all 5
with `portability-ok: <reason>` rather than letting an unrelated
pre-existing gap fail this PR's own
CI run. Verified: `check-shell-portability.sh origin/main` (diff-mode,
what CI actually runs) is
  clean before and after — 0 findings.
- Bumped `plugin.json` + added a `CHANGELOG.md` entry for all 13 touched
plugins (actionlint,
autonomy, bash-format, biome-format, claude-ops, desktop-notification,
eol-normalizer, go-format,
guardrails, markdown-format, powershell-format, ruff-format,
typos-format) — test-only changes, so
each gets a patch bump under `### Changed`. `guardrails` landed at
`0.17.2` (not `0.17.1`, which a
  concurrently-merged PR (#1503) claimed first) after a rebase conflict.

## Test plan

- [x] `bash scripts/check-shell-portability.test.sh` — 71/71 passing,
including the new mktemp
assertions (flags `mktemp -p`, flags the `-dp` combined cluster, does
not flag the portable
`mktemp "$DIR/template"` form) and the retained
date-d/stat-c-still-staged assertion.
- [x] `scripts/check-shell-portability.sh origin/main` (diff-mode, what
CI runs on this PR) — clean,
      0 findings across the 24 changed shell files.
- [x] `scripts/check-shell-portability.sh --all` against the full corpus
— 63 pre-existing findings
remain (68 minus the 5 annotated in this PR's own touched files), all
pre-existing and tracked
      in #1540; zero `mktemp` findings anywhere.
- [x] Every one of the 24 migrated `*.sh` files executed directly and
passing: `actionlint-check`,
`lane-stop-gate`, `bash-format`, `biome-format`, `desktop-notification`,
`eol-normalizer`,
`go-format` (skipped — no `goimports` binary on this host,
pre-existing/unrelated),
`block-dangerous-git` (305/305), `block-hook-bypass` (203/203),
`block-no-verify` (112/112),
`cli-flag-verify` (48/48), `flag-commit-pr-skill-bypass` (28/28),
`hardcoded-path-check`
(72/72), `secret-pattern-detection` (42/42), `skill-reference-verify`
(68/68),
`stale-path-verify` (73/73), `workflow-resilience-check` (15/15),
`markdown-format` (92/92),
`powershell-format`, `ruff-format` (52/52), `typos-format` (41/41).
`claude-ops-test-helpers.sh`
and `guardrails-test-helpers.sh` are sourced helpers (never directly
executed).
- [x] `shellcheck --rcfile=.shellcheckrc` on every changed `*.sh` file —
clean.
- [x] `typos --config _typos.toml` on every changed file — clean.
- [x] `markdownlint-cli2` on every changed `CHANGELOG.md` — clean.
- [x] `git diff --stat` reviewed: every `plugin.json` diff is a single
version-line change (no
unintended reformatting/escaping — an early `json.dump`-based approach
mangled em-dashes into
`—` escapes across whole files and was caught and redone as a targeted
regex substitution
      before committing).

## Related

Closes #1527.
Follow-up filed: #1540 (the 68 pre-existing, untriaged `--all` corpus
findings this issue's own
acceptance criteria surfaced but which are out of scope for a `mktemp
-p`-only migration).
Follows #1510 (staged-class enable trigger), #1491/#1511 (the gate
itself).

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 30, 2026
…uests (#1781)

## Why

A review that lands **after** a merge has nowhere to go:

- the ruleset's `required_review_thread_resolution` is a **merge-time
predicate** that already passed;
- the babysit lane works *open* PRs, and a merged PR leaves that queue;
- nothing on a merged PR surfaces its open threads — GitHub shows the
merge, not the findings.

Six findings — one **P1** — posted **46 seconds after #1720 merged** and
sat unread for a day. They
surfaced only because a later session happened to audit that merge
batch. Nothing was bypassed; the
gate was satisfied *because the threads did not yet exist*.

The morning brief is the right home: read-only, unattended, and already
where attention signals land.

## What it does

Compares each unresolved thread's **first-comment timestamp** against
the PR's `mergedAt`, and
reports only threads the gate could never have seen. A thread that
predates the merge was visible to
the gate — that is an ordinary unresolved thread, not this failure mode,
and it stays out.

- **One line per PR, at that PR's worst severity, with a finding
count.** Several findings on one PR
are one thing to go look at; repeating the title per thread buries every
other PR. Collapsing on
the *worst* severity means a P0 sitting beside advisory findings can
never be softened.
- **Severity survives to the operator** — a stranded P1 must not read
like a P3.
- **`--stranded-days`** (default 3) — wide enough to cover slow bot
review *and* an operator-absent
  weekend.

## It fails loud, not clear

A GraphQL error document is well-formed JSON that simply carries no
`data`. The extraction would
yield an empty list and render **"every merged PR in the window is
clear"** — an all-clear asserted
from an answer never received, which is the same fail-open shape this
section exists to catch.

This is not hypothetical: a rate-limit error did exactly that during
development. An API error now
says explicitly that it is *not* an all-clear, and prints the message.
Covered by a regression case.

## This is a standing leak, not a one-off

Its **first live run** against this repository immediately surfaced four
more stranded findings on
other merged PRs — including a **P1 on #1694** (merged `05:04:45Z`,
finding posted `05:05:20Z`, 35
seconds later) recording that a shipped `autonomy` cell **never reached
installations**.

## Verification

- `morning-brief.test.sh`: **30 → 63 cases, 0 failures.**
- The **negative** cases carry the weight — a pre-merge thread, an
already-resolved post-merge
thread, and a merge outside the window must all stay silent, or the
section is noise rather than
signal. Plus: collapse-does-not-soften-severity, highest-severity-first,
window-widening, and the
  API-error case above.
- The fixture mirrors the real #1720 shape, including the 46-second gap.
- `shellcheck -x` on script and test — clean. One `SC2016` is declared,
not blanket-suppressed: the
`$owner`/`$name`/`$endCursor` in the GraphQL query are server-side
variables bound by `-F` and
  **must** reach the server unexpanded.
- `node scripts/validate-plugin-contracts.mjs` — 43 setup skills, 2153
files, pass.
- `npx markdownlint-cli2` on both changed markdown files — 0 errors.

### Live run — posted in full in the comments below

A live run on the current branch found **44 merged PRs carrying
post-merge findings in a five-day
window: 0 P0, 10 P1, 34 P2.** Among the P1s: **#1503**, a
guardrail-bypass fix whose own review
landed unread, and **#1322** with 5 findings.

Read the **second** comment for the authoritative figures — the first
was produced by the
pre-review severity logic and reported a false P0, which review then
caught. No truncation warning
fired, so the read is complete.

The five-day window filter was spot-checked against `mergedAt` (a PR
numbered #969 in a 5-day window
looks wrong until you check: it merged `2026-07-25`, 4.2 days before the
run).

This is a far larger leak than the six findings that exposed it.

## Related

Closes #1777
Refs #1720
Refs #1759

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 30, 2026
…1785)

## Why

A **reproducible bypass of the commit guard**, reported on #1503 *after
that PR merged* — so the
thread-resolution gate never saw it and nothing surfaced it. Found by
the stranded-findings sweep.

The directory and locating-global helpers (`effective_dir`,
`collect_locating_globals`,
`explicit_git_dir`) were handed the **whole pre-git argv slice**,
wrapper arguments included. They
parse for git's `-C` / `--git-dir` / `--work-tree`, and they cannot know
which *wrapper* options take
a value.

GNU env's `-u NAME` consumes the next word as the variable to unset. So
in `env -u -C git …`, the
`-C` is env's operand and `git` is the command — **git receives no `-C`
and never changes
directory**. The 0-based slice instead saw the bare tokens `-C git` and
resolved into `./git`.

The guard therefore inspected one repository's aliases while git
executed another's. With `child`
holding `commit --allow-empty -m bypass` and the decoy `git/child`
holding the canonical
`commit -F -`:

```
env -u -C git -c alias.a='!git -C child p' a
```

returned **0** — allowed — while real git committed non-canonically.

## What changed

The three helpers now receive only the slice from the **resolved git
token** (`gi`) to the
subcommand. Four call sites.

The two sites that build `nextw` still start at index 0, deliberately:
they *reconstruct the
invocation* rather than parse git's options, and dropping the wrapper
there would rewrite the
command. The boundary is documented at `effective_dir` so the next
caller does not reintroduce it.

## Verification — against the unfixed hook, not merely green

A guardrail test that cannot fail proves nothing, so the regression case
was run against `main`'s
hook first:

| | unfixed (`origin/main`) | with this fix |
|---|---|---|
| `env -u -C git …` reaches the non-canonical commit | **FAIL — exit 0**
(bypass reproduced) | **ok — exit 2** |
| git's own `-C` still relocates the alias lookup | ok — exit 2 | ok —
exit 2 |

The second row is the guard against over-correcting: the narrower slice
must not stop honouring a
relocation git really performs.

Full suite: **109 assertions, 0 failures.** `shellcheck -x` clean on
both files.

### One correction made during this work

I first wrote a second case asserting `env -C git …` was also a bypass.
It is not — `hook-utils.sh`
already treats `-C` as an env option consuming its operand, so `git`
becomes env's *directory* and
the resolver correctly finds no git command. That assertion was wrong
and was removed rather than
papered over.

## Related

Closes #1503
Refs #1777

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CRITICAL: git guards fail open on chained inline aliases — one-level re-expansion drops command-line -c/--config-env (case C + config-env H1/H2)

1 participant