fix(guardrails): distinguish --config-env from -c/--config in shared git parser - #903
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a03ed406cb
ℹ️ 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".
|
Claude finished @kyle-sexton's task in 5m 35s —— View job Security Review
Finding 1 — Fail-open bypass via non-identifier env-var name in
|
|
Claude finished @kyle-sexton's task in 7m 23s —— View job PR Review
SummaryThe core design is correct and well-tested. The parser tagging, effective-value resolver, case-folding, and "last wins" semantics are all sound. However the Codex P1 comment identifies a real gap that the PR description incorrectly reasons away. One confirmed bypass remains. Details below. Finding 1 — CONFIRMED bypass: non-identifier env var names fail openSeverity: Security / block before merge The Codex reviewer is correct. The guard's comment at
This reasoning conflates two distinct cases:
Exploitable path: env 'bad-name=commit' git --config-env=alias.c=bad-name c -m xFlow through the guard:
The same shape works for Note the inline-prefix form ( Minimal fix: The identifier gate on COLLECTION ( There is also a missing test for this scenario — the existing injection-shaped test ( run "env-wrapper non-identifier env var (blocked)" \
"env 'bad-name=commit' git --config-env=alias.c=bad-name c" 2would have caught this gap. Finding 2 — Dead code in
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba3af533d0
ℹ️ 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".
|
Claude finished @kyle-sexton's task in 4m 30s —— View job PR Review
Both prior security findings are confirmed against the current code. Two P1 issues must be fixed before merge. The rest of the implementation is sound. Finding 1 — CONFIRMED: Non-identifier env-var name bypasses both guardsSeverity: P1 / Security The prior reviews correctly identify this. Tracing through the current code: Attack vector: env 'bad-name=commit' git --config-env=alias.c=bad-name c -m xTrace through
Why the comment at
This is correct for unset variables. It is wrong for non-identifier names supplied via The inline-prefix form ( Missing test: The existing injection test at line 118–120 of block-noncanonical-commit.test.sh and line 222–225 of block-dangerous-git.test.sh cover command-substitution injection shapes ( Fix direction (two options): Option A — Fail closed with a sentinel: Replace the else
# Cannot resolve non-identifier name via ${!…}; env(1) can set it for
# git via execve. Fail closed so the guards block rather than allow.
HOOK_GIT_CONFIG_EFFECTIVE+=("${key}=<unresolvable>")
fiThis is conservative (may block valid, harmless uses of non-identifier env-var names) but is the simplest safe path. Option B — Collect and linearly resolve: Collect ALL Finding 2 — CONFIRMED:
|
|
Claude finished @kyle-sexton's task in 4m 47s —— View job Security Review
Two confirmed security findings remain in this branch. Both were identified by prior automated reviews (Codex P1 and the prior Claude security reviews) and have not been addressed. Finding 1 — IMPORTANT | Confidence: HIGHNon-identifier Location: The comment at lines 868–871 states:
This reasoning conflates two distinct cases. The "fatal" claim is correct for unset variables, but not for non-identifier names that are set. git's env 'bad-name=commit' git --config-env=alias.c=bad-name c -m xTrace through
The same shape applies to Why this is distinct from the injection-shaped test that already exists in the test suite: the existing test pins that Suggested fix: In else
# Non-identifier name cannot be resolved safely via bash indirect
# expansion. Fail closed: treat as an unresolvable alias value so
# guards block rather than allow.
HOOK_GIT_CONFIG_EFFECTIVE+=("${key}=__unresolvable__")
fiA non-empty, non-matching expansion causes Finding 2 — IMPORTANT | Confidence: HIGH
Location:
AV='reset --hard' git -c alias.rh=status --config-env=alias.rh=AV rhTrace:
This is structurally identical to the Suggested fix: Mirror the noncanonical-commit pattern: accumulate exp=""
for cv in ${cfgv[@]+"${cfgv[@]}"}; do
[[ "${cv,,}" == "alias.${sub,,}="* ]] && exp="${cv#*=}"
done
if [[ -n "$exp" ]]; then
if [[ "$exp" == '!'* ]]; then
...hook::bash_parse_segments...
else
...check_segment...
fi
fiWhat is soundThe rest of the implementation is correctly executed:
Both unaddressed findings are bypasses of the same two guards this PR is trying to harden. The PR should not merge until Finding 1 and Finding 2 are resolved. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba3af533d0
ℹ️ 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".
|
Claude finished @kyle-sexton's task in 7m 40s —— View job PR Review — Commit
|
| # | Issue | Status |
|---|---|---|
| 1 | Non-identifier env-var via env '...' wrapper fails open |
Partially fixed — ambient-env case blocked; env 'bad-name=...' git vector still open |
| 2 | block-dangerous-git.sh breaks on first alias match |
Not fixed — break at line 256 unchanged |
| 3 | Shell-alias recursion loses outer env assignments | Not fixed |
|
Claude finished @kyle-sexton's task in 8m 3s —— View job Security Review
The latest commit ( Finding 1 — IMPORTANT | Confidence: HIGH
Location: The Trace for env 'bad-rh=reset --hard' git --config-env=alias.rh=bad-rh rh
The same shape applies to env 'bad-c=commit' git --config-env=alias.x=bad-c x -m msgThe existing test at block-dangerous-git.test.sh:224 sets Fix: The Finding 2 — IMPORTANT | Confidence: HIGH
Location: This was identified by prior reviews and has not been addressed. The AV='reset --hard' git -c alias.rh=status --config-env=alias.rh=AV rhAfter The alias loop at line 232 matches
Finding 3 — IMPORTANT | Confidence: HIGHShell alias recursion resets Location: This is the Codex P1 comment on the Verified attack for AV=commit git -c alias.sh='!git --config-env=alias.c=AV c --allow-empty -m x' sh
The analogous attack on Fix: Before the What the latest commit (
|
| # | Finding | Severity | Confidence | Status |
|---|---|---|---|---|
| 1 | env 'bad-name=…' git — non-identifier name in env wrapper, not in HOOK_GIT_ENV_ASSIGNMENTS, printenv returns empty |
IMPORTANT | HIGH | Open |
| 2 | block-dangerous-git.sh:256 break — first-match alias wins; git uses last-match |
IMPORTANT | HIGH | Open |
| 3 | hook::git_resolve_index resets HOOK_GIT_ENV_ASSIGNMENTS on recursive shell-alias call |
IMPORTANT | HIGH | Open |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f8bf01b89
ℹ️ 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".
## Summary Part of #836 (epic #830, sub-item 6) — this PR does not close it (fleet adoption is a separate follow-up, see Next). Lands the `hook-observability` owner doc — the first of two PRs for #836, following the `hook-precision` precedent (`d0805dc8fc`, PR #761: doc-only commit, fleet adoption deferred to a follow-up) and `docs/PLUGIN-PHILOSOPHY.md`'s own registry rule: *"A new cross-plugin convention lands in an owner doc before a second plugin adopts it."* ## Design decisions - **Corrects the epic brief's framing.** Brief item 6 says hooks "emit `statusMessage`." Fresh fetch of <https://code.claude.com/docs/en/hooks> (2026-07-22) shows `statusMessage` is a static `hooks.json` handler-config field (sibling of `type`/`command`/`timeout`), not a runtime JSON-output field. The doc states this correction explicitly. - **`systemMessage` scope, precisely bounded.** Required only for a missing-runtime-prerequisite silent-skip (the existing doctrine at `lib/hook-utils.sh:26-30`, now generalized fleet-wide). Explicitly *not* required for exit-2 blocking paths (already user-visible via Claude Code's own permission-denial UI) or for legitimate agent-only advisory findings (`additionalContext` is correct there). - **Telemetry scope, precisely bounded.** Required for every meaningful outcome (a check that ran and returned ok/blocked/skipped-for-cause), not for pure inapplicability short-circuits (wrong tool, excluded path, missing prerequisite) — verified empirically against all 8 existing telemetry-emitting guardrails hooks, all of which already follow this shape. - **Grounds the "local envelope, not real OTel export" design** in the documented fact that Claude Code strips `OTEL_*` exporter env vars from every hook subprocess — a hook cannot emit real OTel even if it tried. - **`prompt_id` correlation deferred**, not included — it's a `hook-telemetry` schema change (`schema_version` 1.0 → 1.1) touching ~25 producer call sites, not a `hook-observability` concern. Filed as #930. - **Documents the `check-silent-skips.sh` gate correction**, and the `statusMessage`/`systemMessage`/telemetry rollout, as explicitly pending work for the follow-up PR — the doc states current state honestly rather than describing not-yet-landed adoption as done. ## Review history - Codex (round 1, 2 findings, fixed in `c0105d3a37`): the doc described the `statusMessage` rollout and the `check-silent-skips.sh` gate correction in present tense as already complete — neither has landed yet. Reworded both as explicitly pending. - Codex (round 2, 1 finding, fixed in `72aa0a37ad`): "24 wired producer hooks" was wrong — actual count via `grep -rc '"type": "command"' plugins/*/hooks/hooks.json` is 27 across the 12 touched plugins. Fixed in the doc and the plan. - Codex (round 3, 3 findings, fixed in `06602d3c27`): (1) the same 24→27 count reappeared in a not-yet-resolved thread — confirmed already fixed; (2) "systemMessage already implemented fleet-wide" conflated the composing helpers existing fleet-wide with actual adoption at every skip site — reworded; (3) "telemetry on every exit path" doesn't match the fleet's actual (and correct) shape — verified empirically across all 8 telemetry-emitting guardrails hooks that pre-`emit_tel` exits are pure inapplicability short-circuits, corrected the rule to "every meaningful outcome." - Codex (round 4, 1 finding, fixed in `4c57feb6f5`): `docs/topics/836-hook-observability/PLAN.md` staying tracked through both this PR and the follow-up violates `docs/conventions/topic-docs/README.md`'s contract-tier rule — "committed on the task branch only; pruned before merge." Pruned in this PR; the follow-up recreates its own scoped `PLAN.md` on its own branch and prunes it before its own merge, same pattern. ## Next Fleet adoption (statusMessage across 27 wired producer hooks in 12 plugins, systemMessage fixes for 11 genuine gaps, 1 telemetry gap, and the silent-skip-gate correction) lands in a follow-up PR that closes #836, branched off main once this merges. ## Related - #836 — epic sub-item this PR is part of (not closed by this PR) - #930 — deferred `prompt_id`-correlation follow-up - #761 (`d0805dc8fc`) — `hook-precision` convention, the doc-first-then-adopt precedent this PR's structure matches <details> <summary>Final PLAN.md (topic doc, pruned from the tree in this PR's last commit — preserved here per convention)</summary> # Plan: #836 — hook-observability fleet convention + fleet adoption ## Brief Issue #836 (epic #830, sub-item 6 of `docs/topics/lint-static-analysis-gaps/PLAN.md`, lines 37-41): > Hook-observability fleet convention — every fleet hook emits `statusMessage` (during run), > `systemMessage` (failure/notable action), and the hook-telemetry OTel envelope. Grounded in > current official hooks docs at authoring time (no native user-visible hook UI exists as of > 2026-07-21; OTel events + author-emitted messages are the sanctioned surfaces). Optional > sub-item: upstream feature request for a native verbose-hooks UI toggle. Acceptance criteria (PLAN.md lines 64-65): *"Hook-observability convention documented as an owner doc (convention registry row) and adopted by every fleet hook; conformance audited."* ## Brief-said-X / docs-say-Y / so-we-did-Z (mandatory correction) The brief says every hook "emits `statusMessage`". Fresh fetch of <https://code.claude.com/docs/en/hooks> (2026-07-22) shows `statusMessage` is a static field on the hooks.json handler object — sibling of `type`/`command`/`timeout`/`if`/`once` — not a runtime JSON-output field a hook script emits on stdout. It is "a custom spinner message displayed while the hook runs," declared once at config time. So we corrected the mechanism: fleet adoption of `statusMessage` is a `hooks.json` config edit, not a shell-script change. This does not change the acceptance criterion's intent (a live status label during hook execution) — only the implementation surface. ## Research findings (fresh, cited) Source: <https://code.claude.com/docs/en/hooks>, fetched 2026-07-22. 1. **`statusMessage`** — handler-object config field (`hooks.json`), optional, no default. Spinner label shown while the hook process runs. 2. **`systemMessage`** — JSON output field (exit 0), "warning message shown to the user," 10,000 char cap, immediate effect. The composing helpers (`hook::emit_channels` / `hook::emit_skip_notice`, `lib/hook-utils.sh:58,74`) exist fleet-wide and are already callable by every hook — adoption at every missing-prerequisite skip site is not yet complete; see "systemMessage — 11 genuine gaps" below for the sites still on stderr-only or `additionalContext`-only. 3. **Exit-code display semantics** (load-bearing for scoping "notable action" below): - Exit 0: stdout parsed as JSON if present; stderr is ignored — never shown to user or agent on exit 0. - Exit 2: stderr fed to Claude as an error / shown to user depending on event; for `PreToolUse` this blocks the tool call — the block itself is the user-visible surface (via Claude Code's own permission-denial UI), independent of any `systemMessage`. 4. **OTel correlation** — hook input JSON carries `prompt_id` (v2.1.196+), which matches the `prompt.id` attribute on real OpenTelemetry events, enabling external correlation. Not adopted in this lane — see "Deferred: prompt_id correlation" below. 5. **Why the envelope is local-file, not real OTel export** — Claude Code strips all `OTEL_*` exporter environment variables from every hook subprocess it spawns (documented at `/docs/en/monitoring-usage#administrator-configuration`). A hook process cannot emit real OTel telemetry even if it wanted to; `hook::emit_telemetry`'s file-sink envelope is the only surface available to a hook. This convention doc states that rationale explicitly so it reads as a grounded design choice, not an oversight. ## Deferred: prompt_id correlation Adding `prompt_id` to the telemetry envelope (`hook::emit_telemetry`'s `data` object, or a new schema field) is a genuine improvement — it would let external tooling correlate a hook's local telemetry with the same turn's real OTel events. It requires either a new parameter on `hook::emit_telemetry` (`lib/hook-utils.sh`, the synced SSOT) or updating every producer's `data_json` construction (25 call sites) to extract and pass it. Bundling it into #836 would: - Be a `hook-telemetry` schema change (bump `schema_version` 1.0 → 1.1), not a `hook-observability` concern — different owner doc, different issue. - Force either an inconsistent partial rollout (some producers populate `prompt_id`, others don't — indistinguishable from "genuinely absent, pre-first-input" per the docs) or a 25-file sweep unrelated to this issue's three-surface scope. Filed separately, not fixed here. `prompt_id`-correlation stays out of #836; tracked as #930. ## Two-PR structure (precedent-matched) Per `docs/PLUGIN-PHILOSOPHY.md:272-274`: "A new cross-plugin convention lands in an owner doc before a second plugin adopts it." Confirmed via git history: `hook-precision` (`d0805dc8fc`, PR #761) landed as a doc-only commit (README + one registry row), with fleet adoption explicitly deferred to follow-up work ("member fixes ride their own issues"). Matching that precedent: - **PR A — convention doc** (this PR). `docs/conventions/hook-observability/README.md` (new, owner doc) + one row in `docs/PLUGIN-PHILOSOPHY.md`'s Convention registry table. Body: "Part of #836" (not "Closes" — the issue's acceptance criteria require adoption too). - **PR B — fleet adoption.** Branches off main after PR A merges, so hooks.json/scripts can cite the merged doc. Closes #836. Recreates its own scoped `PLAN.md` on its own branch, pruned before its own merge (topic-docs contract-tier rule — see Review history). ## PR B scope (second lane, after PR A merges) Zero `lib/hook-utils.sh` (SSOT) edits — verified: every fix uses an existing helper (`hook::emit_skip_notice`, `hook::emit_telemetry`, `hook::require_jq`). This collapses the collision risk against open PR #903 (also touches `lib/hook-utils.sh`) to zero for this lane. ### statusMessage — mechanical, all 27 wired producer hooks, 12 plugins Add a `statusMessage` field to every `command`-type handler object in each plugin's `hooks.json`. One line per handler, present-tense gerund wording. Plugins touched (hooks/ present): actionlint, bash-format, biome-format, claude-ops, desktop-notification, eol-normalizer, go-format, guardrails, markdown-format, powershell-format, ruff-format, typos-format. Each gets a patch-level `plugin.json` version bump + CHANGELOG entry. ### systemMessage — 11 genuine gaps All 9 use `hook::require_jq <event> "guardrails" "$INPUT"` (not raw `emit_skip_notice` — needs the once-per-session gate `require_jq` wraps, or a broad matcher spams the notice on every invocation): - `plugins/guardrails/hooks/block-dangerous-git.sh:47-50` - `plugins/guardrails/hooks/block-hook-bypass.sh:45-48` - `plugins/guardrails/hooks/block-no-verify.sh:45-48` - `plugins/guardrails/hooks/block-noncanonical-commit.sh:72-75` - `plugins/guardrails/hooks/cli-flag-verify.sh:38-41` (jq-missing) and `:78` (bundled-verifier missing — currently fully silent; paired with manual `hook::notice_once` since `require_jq` doesn't fit a non-jq prerequisite) - `plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh:56-59` - `plugins/guardrails/hooks/hardcoded-path-check.sh:40-43` - `plugins/guardrails/hooks/secret-pattern-detection.sh:33-36` - `plugins/guardrails/hooks/workflow-resilience-check.sh:24-27` (secondary gap, bundled with its telemetry fix below) Convert agent-only skip branches to dual-channel: - `plugins/claude-ops/hooks/skill-usage-audit.sh:42-43,58-59` - `plugins/claude-ops/hooks/skill-usage-expansion-audit.sh:53-54,71-72` No change to the 6 pure-telemetry claude-ops emitters or guardrails' exit-2 block paths (already correct per the doc's scoping rules). Bundled: tighten `scripts/check-silent-skips.sh`'s `is_visible()` to drop bare `>&2` as a sanctioned signal (verified safe — the only 9 fleet sites relying on that leniency are the 9 converted above) and flip its corresponding test fixture. ### Telemetry — 1 genuine gap, precisely scoped `plugins/guardrails/hooks/workflow-resilience-check.sh` has zero telemetry calls anywhere, including its meaningful outcomes (fan-out detected, throttle applied, advisory issued) — unlike every sibling guardrails hook. Add a `hook::emit_telemetry` call at each meaningful exit; its pure-inapplicability exits correctly need none, matching the sibling pattern. ### Housekeeping bundled into PR B Reconcile `docs/conventions/hook-telemetry/README.md`'s stale Implementers table (omits actionlint, biome-format, eol-normalizer, powershell-format, several guardrails hooks, skill-usage-expansion-audit). ### Test plan (PR B) - `claude plugin validate --strict` per touched plugin (statusMessage acceptance already smoke- tested against go-format — passes). - Each converted hook: existing `*.test.sh` extended with a jq-missing-path case asserting `systemMessage` is now non-empty. - `scripts/validate-plugin-contracts.mjs` full run. - Local CI-equivalent gate sweep: hygiene, changelog-parity-gate, hook-utils-sync (expect no diff — confirms zero-SSOT-edit empirically), silent-skip-gate (post-tightening), skill-quality-gate. ## Open decisions / not yet locked - Exact `statusMessage` wording per hook — draft during PR B implementation (27 short strings). - Whether the upstream native-verbose-hooks-UI feature request (brief's optional sub-item) gets filed externally or noted as declined — decide at PR B close-out. </details> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
Operator ruling (ratification interview, 2026-07-22 ~03:05Z): security review CLEARED — merge approved. Reviewed via tower security brief: fail-open in |
|
Tower correction (2026-07-22 ~03:10Z): merge HELD — ruling premise broken. The clear-and-merge ruling was based on a review snapshot predating four NEW unresolved Codex P1 threads (23:29Z–00:46Z), each a verified fail-open bypass of the guards this PR fixes: (1) non-identifier --config-env names allowed instead of failing closed, (2) first-match alias expansion lets a later dangerous --config-env value mask behind an earlier harmless -c, (3) command-line env assignments lost across '!' shell-alias recursion, (4) leading-dash env names dropped by printenv option-parsing. Defer-forbidden classes 2/3 — these get fixed on this branch before merge. do-not-merge restored; fixer dispatched; re-merge after fixes + clean re-review under the operator's overnight Opus-confirm protocol. |
|
Claude finished @kyle-sexton's task in 6m 42s —— View job Security Review
Result: All four prior P1 bypasses are now closed. No new security findings.The four bypasses identified by prior automated reviews and tracked through the Prior Finding 1 — CLOSED:
|
|
Claude finished @kyle-sexton's task in 3m 52s —— View job PR Review — Commit
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa132ebd1d
ℹ️ 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".
Rebasing #903 onto current main dropped the restack-merge commits that had progressively re-headed the reposture over each released guardrails version. Main's #945 (hook-observability fleet adoption) also minor-bumped every plugin that carries the shared hook-utils.sh, so each of this branch's bumps now sat at or below main. Re-head all 12 hook-utils-carrying plugins to a patch above main's current version (guardrails 0.10.0 -> 0.10.1; the eleven formatter/ops plugins likewise), and move each branch changelog entry above main's latest entry, preserving main's released history (including #945's entries) intact beneath it. The reposture code (shape-based --config-env refusal; env-resolver removed) is unchanged; this commit only reconciles version headings and changelog placement. Refs #740 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0b6cb50 to
24bbc1b
Compare
|
Claude finished @kyle-sexton's task in 6m 13s —— View job PR Review — HEAD
|
|
Claude finished @kyle-sexton's task in 11m 0s —— View job Security Review
Head commit reviewed: Design summaryThe core security bet of this PR is the structural fail-closed reposture introduced in
No new security findings at this HEADThe structural approach eliminates the entire class of value-propagation bypasses that drove the prior ~10 review rounds. Every bypass pattern from those rounds (ambient env, Verified working correctly:
Acknowledged residuals — out of scope for this PR, referred to #964Two bypass classes are explicitly deferred; they pre-date and survive this PR's scope: H1 — Outer command's AV='reset --hard' git --config-env=alias.rh=AV -c alias.sh='!git rh' shTrace: the outer command's Real git: passes This is the H1 class. Kyle-sexton's comment at H2 — Three-level alias chain through option-prefixed expansion (Codex P1 at 07:36Z) git -c alias.rh='-c alias.foo="--config-env=alias.bar=AV bar" foo' rhTrace: outer Real git: The distinction from the depth-2 case fixed by This is the H2/Codex P1 class. Also acknowledged at Codex P1 from
|
git honors both `alias.<name>` and its `alias.<name>.command` subkey as the alias definition for a subcommand (`git -c alias.rh.command='reset --hard' rh` runs it), and applies last-wins across the two spellings. The guard's alias-key classifier matched only the plain `alias.<name>` form, so a dangerous alias smuggled through the `.command` subkey — via `-c` or `--config-env` — was classified as a non-alias and run unchecked (fail-open). Extend hook::git_alias_expansion to also match `alias.<name>.command=`, folding case the same way as the plain form. The single match site routes both the `--config-env` shape refusal and the inline `-c` expansion re-check, so one edit closes the fail-open on both paths. The 12 plugin copies of the shared lib are re-synced byte-identical. Empirically confirmed on git 2.54.0.windows.1 that `.command` is the only honored alias subkey and that git applies last-wins across the plain and `.command` spellings; regression tests in the lib suite and both guardrails guard suites cover the inline, shape, non-command-subkey control, and cross-form last-wins cases. Refs #740 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 0s —— View job PR Review — Commit
|
| Scenario | block-dangerous-git | block-noncanonical-commit |
|---|---|---|
Inline .command alias blocked |
✓ | ✓ |
--config-env .command shape-blocked |
✓ | ✓ |
Case-folded .command key blocked |
✓ | ✓ |
Non-command subkey NOT treated as alias |
✓ | ✓ |
.command last-wins over earlier plain (allowed) |
✓ | ✓ |
Plain last-wins over earlier .command (blocked) |
✓ | ✓ |
Open question: is alias.<name>.command a real git feature?
The prior review assessed the Codex P1 as "likely a false positive" (MEDIUM confidence) because alias.rh.command would be parsed as section=alias, subsection=rh, variable=command — a different config key from alias.rh. The commit author's test comment states the behavior is verified against real git. If it IS a real git feature, the fix is exactly right. If it is NOT, the fix adds matching logic for a config key git never reads, which is harmless: alias.rh.nope=reset --hard (any other subkey) still correctly returns 1 and doesn't over-block, because only .command is matched.
In either case the fix is strictly an improvement: if the feature is real it closes a bypass; if it is not, it adds a harmless additional pattern.
One modeling subtlety (pre-existing, not introduced by this commit)
Git's config system treats alias.rh and alias.rh.command as independent config keys, each with their own last-wins stack. When both are set, git presumably picks one over the other by some precedence rule (e.g., always prefers .command if present, or uses whichever appears later across all config sources). The guard's cross-form last-wins model treats both as interchangeable in a single linear ordering — which matches the -c command-line ordering but may diverge from git's precedence if both keys appear in gitconfig. This is a pre-existing modeling approximation that applies to the entire --config-env shape-refusal design and is not introduced by this commit. The guard's model is conservative in the security direction for the relevant attack shapes.
Dead code (pre-existing, not introduced by this commit)
lib/hook-utils.sh:696: *) ((i++)) ;; inside the env handler is unreachable (outer condition requires token to start with -). Flagged in prior reviews; not a correctness or security concern.
Assessment
The implementation is correct, the tests are comprehensive for all relevant cases, and no new security surfaces are introduced. This commit closes the Codex P1 finding about the .command subkey and is consistent with the structural shape-refusal design established in 9eb9c0e.
This branch remains clear for merge from a security standpoint. The two explicitly deferred items (H1 and H2 from #964) are pre-existing and unchanged by this commit.
|
Claude finished @kyle-sexton's task in 4m 22s —— View job Security Review
HEAD reviewed: The Finding — IMPORTANT | Confidence: HIGH
Location: This PR establishes that git honors pexp=$(git -C "$(effective_dir "${w[@]}")" config --get "alias.$sub" 2>/dev/null)
Attack path: git c -m "non-canonical message"
Suggested fix: Add a second pexp=$(git -C "$(effective_dir "${w[@]}")" config --get "alias.$sub" 2>/dev/null)
[[ -z "$pexp" ]] && pexp=$(git -C "$(effective_dir "${w[@]}")" config --get "alias.$sub.command" 2>/dev/null)Add a test case to
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df23907aa1
ℹ️ 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".
…pelling last-wins Which spelling git runs when both `alias.<sub>` and `alias.<sub>.command` are set on one command line is git-version-dependent. The prior fix classified the alias with cross-spelling last-wins (the later spelling decides), so a dangerous value in one spelling could be masked by a benign value in the other: on a git that resolves the two the opposite way, git executes the dangerous alias while the guard sees only the benign one and allows it (fail-open on the masked spelling). Rewrite hook::git_alias_expansion to fail 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: an env (--config-env) spelling in either place refuses by shape (rc 2), otherwise every present spelling's inline expansion is exposed via HOOK_GIT_ALIAS_EXPS and both guards re-check each, blocking if any resolves to a guarded operation. A benign expansion in one spelling never suppresses a dangerous sibling; allowed only when both spellings are benign. This needs no git-version fact — on a git where a benign later `.command` genuinely overrides, the guard over-blocks, which is fail-safe. The scalar HOOK_GIT_ALIAS_EXP is replaced by the array HOOK_GIT_ALIAS_EXPS; both guard consumers iterate it with the set -u-safe empty-array idiom. The 12 plugin copies of the shared lib are re-synced byte-identical. Regression tests cover the union in both directions, the --config-env shape path masked across spellings in both command-line orders, within-spelling last-wins, and both-benign acceptance. Refs #740 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compose the guardrails identical-version collision: main landed its own 0.10.1 (hardcoded-path-check no-project scope fix), so this branch's entry re-heads to 0.10.2 above it; plugin.json advances to 0.10.2.
|
Claude finished @kyle-sexton's task in 5m 27s —— View job PR Review — HEAD
|
|
Claude finished @kyle-sexton's task in 5m 52s —— View job Security Review — HEAD
|
plain_kind |
cmd_kind |
result |
|---|---|---|
env |
any | return 2 (shape-refuse — correct) |
| any | env |
return 2 (shape-refuse — correct) |
inline |
"" |
rc=0, EXPS=(plain_exp) |
"" |
inline |
rc=0, EXPS=(cmd_exp) |
inline |
inline |
rc=0, EXPS=(plain_exp, cmd_exp) |
In the last three rows, both guards iterate every entry in HOOK_GIT_ALIAS_EXPS and re-check each expansion independently — a dangerous entry in one spelling can no longer be suppressed by a benign entry in the other.
Masking case (the Codex P1 from df23907): git -c alias.rh='reset --hard' -c alias.rh.command=status rh → plain_exp="reset--hard" (inline), cmd_exp="status" (inline) → EXPS=("reset --hard", "status") → reset --hard is re-checked and blocks. Correct ✓
Reverse masking: git -c alias.rh=status -c alias.rh.command='reset --hard' rh → EXPS=("status", "reset --hard") → reset --hard is re-checked and blocks. Correct ✓
Env masking across spellings (both orders): git --config-env=alias.rh=AV -c alias.rh.command=status rh → plain_kind="env" → return 2 unconditionally. git --config-env=alias.rh.command=AV -c alias.rh=status rh → cmd_kind="env" → return 2. Both correct ✓
Structural shape refusal — CONFIRMED INTACT
block-dangerous-git.sh:235–236
hook::git_alias_expansion is called BEFORE the HOOK_NO_ALIAS gate. The alias_rc == 2 path (exit 2) fires at every recursion depth — a wrapping inline alias that expands to --config-env=alias.<sub>=… is refused at depth 2, as required. The depth-invariant block from 7ea3b9d is preserved. ✓
No injection surface in the new code
key="${cv%%=*}"strips at the first=; no evaluation occurs.[[ "${key,,}" == "alias.${sub,,}" ]]and[[ "${key,,}" == "alias.${sub,,}.command" ]]are pure bash string comparisons;subis not glob-expanded.HOOK_GIT_ALIAS_EXPSentries are consumed with${arr[@]+"${arr[@]}"}(set -u safe), then passed to the existing alias-expansion consumers (shell-alias reparse viahook::bash_parse_segments, or git-alias splice viahook::env_s_split). These paths were already present and their injection safety was verified in prior rounds.
Gitconfig fallback gap — PRE-EXISTING, filed as #1022 (not blocking)
block-noncanonical-commit.sh:263:
pexp=$(git -C "$(effective_dir "${w[@]}")" config --get "alias.$sub" 2>/dev/null)git config --get "alias.c" returns empty when the gitconfig contains [alias "c"] command = commit (a different config key than [alias] c = commit). The fallback therefore misses gitconfig-resident alias.<sub>.command aliases. Attack: a user with [alias "c"] command = commit in ~/.gitconfig can run git c -m "non-canonical message" without triggering block-noncanonical-commit.
This gap is pre-existing — it was not introduced by this PR. It was present before df23907 (at that point, neither gitconfig nor command-line .command aliases were detected). This PR closed the command-line case; the gitconfig case remains open. This was identified in the 16:30:51Z review round and split to issue #1022 ("config-write threat model, orthogonal"). block-dangerous-git has no gitconfig fallback at all — consistent with its design.
Not a regression; not blocking per operator tracking of #1022.
Deferred items — unchanged, not blocking
- H1/H2 from 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) #964 (
AV='reset --hard' git --config-env=alias.rh=AV -c alias.sh='!git rh' sh; outer-c/--config-enventries not visible to inner shell-alias reparse) — pre-existing, explicitly filed, scoped as a follow-on defer-forbidden CRITICAL. env -u/env -iover-blocking (P2) — guard blocks a command git would also reject (fatal on missing var). Conservative direction; tracked in guardrails: env -u / env -i prefixes false-positive under value-blind config-env shape guard (over-block, fail-safe) #1013.
Cosmetic (pre-existing, not blocking)
lib/hook-utils.sh:696: *) ((i++)) ;; inside the env handler option-processing branch is dead code — the outer if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]] guarantees any token reaching the case starts with -. Same pattern in the sudo handler. Not a correctness or security issue.
Summary
The cf08116 union fix is correct and complete for its stated scope. No new P1 or P2 security issues are present in the current HEAD. The one confirmed gap (gitconfig fallback for .command form) is pre-existing and tracked as #1022.
This branch is clear for merge from a security standpoint, subject to the pre-existing H1/H2 (#964) and #1022 deferrals and pending operator re-ratification of the structural reposture.
Resolve actionlint CHANGELOG/version collision with the shared-lib cascade that landed on main (#903): main's 0.5.1 is the hook-utils.sh sync; this branch's telemetry hook-id fix becomes 0.5.2 on top.
Resolve the markdown-format version collision introduced when #903 (shared git parser fix) landed on main and cascade-bumped markdown-format to 0.6.1 — the same bump this branch made. Re-bump to 0.6.2 (one past main) and split the CHANGELOG so #903's 0.6.1 entry and this branch's out-of-tree fix (now 0.6.2) each stand alone. No shared-lib change from this branch; main's hook-utils.sh (including #903's --config-env parser change) is taken as-is.
main's #903 updated the shared git parser in lib/hook-utils.sh; the autonomy copy added by this branch predated it, tripping hook-utils-sync and cross-plugin-source-drift on the merge ref. Re-run of scripts/sync-hook-utils.sh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017P1vVA8iViUTfQWjA9tgZG
…IR unset (#972) (#1030) ## Summary `markdown-format`'s PostToolUse hook linted `.md` files **outside any repository** (a loop lane's scratchpad/temp comment-body composed for `gh issue comment --body-file`) with repo-doc rules that do not apply — most visibly MD041 (first-line-h1) and MD013 (line-length). Pure advisory noise on every such write. Cause: when `CLAUDE_PROJECT_DIR` is unset (an autonomous session whose cwd is not a repo), the shared `hook::read_file_path` guard applies no membership scoping, so the hook processed the file wherever it lived. ## Fix Add a **markdown-format-local** fallback in `markdown-format.sh`, right after the extension gate: when `CLAUDE_PROJECT_DIR` is unset, skip a file that is not under any git working tree. ```sh if [[ -z "${CLAUDE_PROJECT_DIR:-}" ]] && ! git -C "$(dirname "$FILE")" rev-parse --show-toplevel >/dev/null 2>&1; then exit 0 fi ``` A scratch/temp file in no git tree is skipped; a repo `.md` edited in such a session is still linted; set-`CLAUDE_PROJECT_DIR` behavior is unchanged. `--show-toplevel` succeeds only inside a working tree — the same predicate `hook::repo_root` already uses — and the extra `git rev-parse` runs only on the unset path. ### Why local, not in the shared guard The obvious-looking fix — teach the shared `hook::read_file_path` in `lib/hook-utils.sh` to fall back to git-tree membership — is **wrong**, and its test suite proves it: `hook::read_file_path` is consumed by 10 hooks, and `guardrails/cli-flag-verify` is a **location-independent guardrail** — it catches hallucinated CLI flags in written content regardless of repository membership (a bad `gh` flag in a scratchpad comment-body is precisely its job, and precisely the file this hook should *not* lint). Widening the shared guard made `cli-flag-verify.test.sh` fail 9 assertions (the hook began skipping its out-of-tree fixtures). The two hooks want **opposite** unset-case membership policies, so the repo-scoping policy belongs in `markdown-format`, not the shared library. This keeps the change to one plugin (matching the issue's scope and rule 6d's single-plugin version bump) and touches no shared code. ## Verification Ran locally on Windows Git Bash (git 2.x, jq present), branch merged up to date with current `origin/main`: - **`plugins/markdown-format/hooks/markdown-format.test.sh`: PASS=67 FAIL=0.** New case passes: an out-of-tree scratchpad `.md` is skipped (exit 0, no findings, file left unmodified). The in-tree-still-linted acceptance case is covered by every existing `$REPO` fixture (they live in a git working tree and already run with `CLAUDE_PROJECT_DIR` unset). The `telemetry/slow-sink` case that previously failed on this Windows host is now green: main's 0.6.2 made that detector differential rather than a fixed wall-clock bound, which this branch picks up in the merge. - **`plugins/guardrails/hooks/cli-flag-verify.test.sh`: PASS=48 FAIL=0** — confirms the guardrail is untouched (this is the regression the shared-lib approach caused; the local fix avoids it). - **`lib/hook-utils.test.sh`: PASS=83 FAIL=0** (post-merge, includes #903's tests). - `scripts/sync-hook-utils.sh --check` → 12 copies match; `--check-bump origin/main` → "Lib unchanged; no version bumps required" (no shared-lib touch). - `scripts/check-changelog-parity.sh --check-bump origin/main` → OK (`markdown-format` 0.6.3 with entry). - `shellcheck` on `markdown-format.sh` + `markdown-format.test.sh` → clean; `markdownlint-cli2` on the CHANGELOG → 0 errors. Closes #972 ## Related **Draft hold released.** Issue #972 records the git-working-tree fallback as a *defaulted* decision with an open veto window ("maintainer-vetoable"), not a required approval. The window has been open since 2026-07-22; no veto was entered on the issue or this PR, the work-class was operator-ratified on 2026-07-23, and the implementation matches the defaulted branch and all three acceptance criteria verbatim. Marked ready on that basis. The version collisions are **resolved**: #903 cascade-bumped `markdown-format` to `0.6.1`, then main shipped `0.6.2` (test-only differential fd1-leak detector). This branch merged `origin/main` in and placed the out-of-tree fix under **`0.6.3`**, keeping both prior entries intact. The net diff (GitHub "Files changed") is the four `markdown-format` files; no shared code is touched. History note: earlier commits on this branch attempted a shared-lib approach (edit `lib/hook-utils.sh` + sync 12 copies + bump all 12). That was reverted after `cli-flag-verify.test.sh` proved the guardrail divergence described above. The superseded cascade commit remains reachable in the "Commits" tab only via an `ours`-merge and contributes nothing to the tree; this repository is **squash-merge only** (`allow_rebase_merge` / `allow_merge_commit` both false), so the intermediate commits collapse to the net four-file change on merge and the superseded cascade can never be replayed. **Deferred follow-up (not in scope for #972):** the 9 sibling formatter hooks (`bash-format`, `biome-format`, `eol-normalizer`, `go-format`, `powershell-format`, `ruff-format`, `typos-format`, `actionlint`) share the same latent out-of-tree noise. Fixing them as a class wants an *opt-in* shared scoping mechanism (formatters opt in; the guardrail stays location-agnostic) — worth a separate issue with that trigger recorded. Origin: converted from the fleet-sweep #657 line (markdown-format comment-body lint noise). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Work-class: C3 (bug-fix-shaped) — attended triage 2026-07-23, operator-ratified. 🤖 --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

What
The shared git-option parser
hook::git_resolve_subcommand(lib/hook-utils.sh) collected-c,--config, and--config-envvalues into oneHOOK_GIT_CONFIG_VALUESarray with no marker distinguishing them. But--config-env=<key>=<envvar>supplies the name of an environment variable holding the value (git reads it at runtime), not the value itself. Both git guards read the env-var name as the literal alias expansion, so:block-noncanonical-commit—git --config-env=alias.z=AV zwithAV=commitwas not recognized as a commit (verified exit 0 → fail open).block-dangerous-git— the same shape for its alias-expansion path (AV='reset --hard').Neither is a bug in the guards; the information they need was not in the array.
Closes #740.
Fix
hook::git_resolve_subcommandfills a parallelHOOK_GIT_CONFIG_VALUE_KINDSarray ("inline"for-c/--config,"env"for--config-env), for both the two-word and=-attached forms.hook::git_effective_config_valuesprojects each value to its EFFECTIVE assignment: inline values pass through; anenvvalue<key>=<envvar>resolves to<key>=${!envvar}against the hook's inherited environment (the same environment git reads), gated on the env-var name being a valid shell identifier and using${!envvar-}forset -usafety. An unset or invalid-name variable projects to an empty value — git itself rejects an unset--config-envvariable (fatal), so the assignment never takes effect and the empty projection yields no spurious alias match.HOOK_GIT_CONFIG_VALUESto the resolvedHOOK_GIT_CONFIG_EFFECTIVE.block-no-verify.shis intentionally unchanged: it keys on the config key (core.hooksPath=), which survives resolution, so it is not affected by the env/value confusion.Review-driven hardening (two further fail-opens of the same guards)
An independent security review of the initial fix found two more residual bypasses of these guards — both verified against real git behavior — now closed on this branch:
AV=commit git --config-env=alias.c=AV c(inline prefix) andenv AV=commit git …set the variable only in git's environment — a self-contained one-liner that passed an ambient-only check.hook::git_resolve_indexnow collects the command-line assignments it already walks past (both the inline prefix and theenvwrapper) intoHOOK_GIT_ENV_ASSIGNMENTS, and the resolver prefers them over ambient (last wins), matching what git's process actually sees. The identifier gate on the indirect expansion is pinned by an injection-shaped-name test proving no evaluation occurs (no file created).git -c alias.RH='reset --hard' rhandgit -c alias.rh=… RHboth run the alias, yet the guards' inline-alias re-check matched case-sensitively. Both guards now fold both sides of the alias-key match (the expansion value keeps its case).The
--config-envinline-prefix case that the issue documented as out of scope is thus specifically covered here for config resolution; the parser still does not evaluate shell assignments generally.Blast radius
hook-utils.shis a shared library materialized into every carrying plugin viascripts/sync-hook-utils.sh, and the delivery gate requires every carrying plugin to bump its version (the plugin version is the consumer update-cache key). So this change syncs the lib to all 11 carrying plugins with a patch bump + changelog entry each (guardrails carries the real fix note; the other 10 note a no-behavior-change shared-lib sync).Tests
lib/hook-utils.test.sh— parser kind tagging (both forms); effective-value projection (env resolved, inline pass-through, unset → empty, invalid/injection-shaped identifier → empty with no evaluation); and command-line-assignment resolution (inline prefix,envwrapper, override-of-ambient) through the fullgit_resolve_indexpath.=and two-word forms), inline-prefix andenv-wrapper bypass blocked, case-folded alias (upper subcommand / upper key) blocked, safe env alias allowed, unset var allowed (git rejects it), injection-shaped env-var name allowed with an asserted no-file (identifier-gate pin).lib/hook-utils.test.sh(75),block-noncanonical-commit(59),block-dangerous-git(203);shellcheck --rcfile=.shellcheckrc;sync-hook-utils.sh --check/--check-bump;check-changelog-parity.sh --check/--check-bump;validate-plugins.sh; markdownlint — all pass.Related
--config-envresidual surfaced; feat(guardrails): block non-canonical git commit; drop --trailer conjunct #736 documented it in the new hook's header rather than half-fixing it in one consumer. This PR fixes it in the shared parser so both guards inherit the fix.