feat(ci): gate exec-form hooks against bare command names (#2569) - #2571
Conversation
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c12d4367b
ℹ️ 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 encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
@codex review Both threads are addressed and resolved. The quoted-YAML-key finding was a genuine bypass and is fixed in 6b6a146 (prefilter + walk now accept every spelling of the key, three cases pinning it, suite 39/39 green in CI). The Please re-review the parser changes in particular — |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b6a146a5d
ℹ️ 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".
|
Ordering cross-check — the blocking PR is now open as #2572, and its shape clears this gate. Ran this PR's gate against #2572's head version of $ bash scripts/check-hook-exec-form.sh
No exec-form hooks with a bare command name.
exit=0So the sequence is settled and verified rather than assumed: #2572 merges, then this rebases onto |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
@codex review Round 2 addressed in 7e85ee5, both threads resolved. Both findings were symptoms of one mistake — enumerating YAML spellings — so the fix inverts the posture rather than adding patterns:
48/48. Please look hardest at the inverse risk this introduces: false positives. Outside a hooks block nothing is judged, and |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7e85ee5b2c
ℹ️ 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".
An exec-form hook (a hook object carrying `args`) resolves `command` as an executable through PATH, so a bare name is machine-dependent. On Windows `bash` resolves to the WSL relay bash.exe under System32 and `python3` to a zero-length WindowsApps App Execution Alias stub; the launch fails, and a failed hook launch is a non-blocking error, so a PreToolUse guard wired this way silently enforces nothing. The class has shipped three times in disk-hygiene alone: #1006 fixed it, #1504 reintroduced it while fixing Python resolution, #2570 fixed it again. The claude-config audit checklist carried it as an `error` row throughout — a checklist a human reads is not a gate. Add scripts/check-hook-exec-form.sh, modelled on the sibling check-hook-userconfig-argv.sh gate: same scope rules for hook config JSON (default hooks/hooks.json, manifest-pointed paths with the out-of-tree trust boundary, inline manifest hooks object), extended to the skill/agent YAML frontmatter `hooks:` blocks that gate does not cover — the surface where the remaining instance lives. `args` presence is the sole exec-form discriminator, so shell form with a leading bare `bash` (the #2570 fix) is never flagged. The rule is the broad one: no path separator in `command` fails, rather than a denylist of names already known to burn us — a {bash, sh} denylist would have waved `python3` straight through. `node` is the one allowlisted bare name, because no Windows shim or alias stub shadows it and both docs/PLUGIN-PHILOSOPHY.md and the audit checklist name it as THE Windows-correct exec-form spelling; the array is pinned by a test so growing it is a visible diff. Wire it as its own self-test-first CI job and into the ci-status needs graph, and point the philosophy doc's Hooks row at the mechanical check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
Two paths skipped silently rather than failing: an unparsable hook config JSON, and a frontmatter `hooks:` key carrying its whole declaration inline (flow mapping, anchor, or alias) instead of opening a block. Both cleared a file the gate had not actually inspected — the no-op-that-looks-green shape this gate exists to stop. Both now report and count an error, with the file named. Adds the two cases to the suite (36 total). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
…2569) YAML permits `"hooks":` and `'hooks':`, and permits whitespace before the colon; all spellings declare the same block. Both the grep prefilter and the awk walk matched only the bare token, so a skill writing the key any other way was never handed to the scanner and the gate cleared it — the exact silent pass this gate exists to stop. Caught in review. Both stages now accept every spelling, with three added cases pinning them (39 total). Also state the second half of the allowlist's admission criterion: unshimmed is necessary, not sufficient — an entry must also answer a real in-repo need the repo's docs sanction, which is why `pwsh` and `deno` stay out despite being unshimmed. An unused allowlist entry is a hole nobody is watching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
#2569) A repeated key made the gate read the FIRST value while PyYAML's own loader keeps the last, so `hooks: {}` followed by a second `hooks:` carrying an exec-form bare `bash` cleared the gate while the consumer would load the violation. Same for a repeated `command` inside a hook object. Caught in review. Matching last-wins would close that hole but pick a side: PyYAML keeps the last value and js-yaml rejects the document outright, so a duplicate key is genuinely ambiguous, and a gate must not choose the reading that clears the file. It is also not a spec curiosity — #1492 shipped a duplicate manifest key through a fully green suite, which is why this repo already gates JSON for it. So report it. A repeated top-level `hooks` key, or a repeated key inside the hooks declaration, is UNREADABLE FRONTMATTER with the second key's line named; duplicates elsewhere in the frontmatter stay out of scope, since this gate interprets only the hooks subtree. Two cases pin both positions (56 total). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
@codex review Round 4 addressed in c84c3ba. The duplicate-key finding was real and reachable — This should be the last round unless something reachable remains. If what turns up is a YAML shape no plugin author in this repo would write and that the fail-closed and duplicate-key rules already cover generically, I would rather answer it in-thread than keep enlarging the surface — but a bypass or a false positive I will fix. |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
The check landed on keys written at the hook object and at the document root, which left `<<` as the way in: an anchored mapping carrying two `command` keys contributed them to a hook object unexamined, and the gate read one of them without noticing the other. A merged mapping's keys are the hook object's keys, so they are subject to the same ambiguity and the same refusal. Follows the merge chain transitively, with the same cycle guard the entry walk uses. One case pins it (57 total). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Self-caught follow-up to the duplicate-key fix, landed in c05b669 before it could become a round-5 finding. The check covered keys written at the hook object and at the document root, which left |
… Stop audit (#2580) ## Problem Claude Code records a hook that fails to launch only as a `hook_non_blocking_error` transcript attachment — the guarded tool call proceeds as if approved, and nobody is told. The #1416 → #2570 → #2572 → #2571 chain fixed the disk-hygiene instances and gated the source shape, but the fleet's only silent-failure detector (`disk-hygiene`'s `guard_launch_monitor.py`) lives **inside the plugin it watches** and launches **through the same registration form it watches**. Full-fleet transcript mining on the incident host (97 transcript files, all projects) shows what that coupling costs: | hook | failures | stderr | | --- | --- | --- | | `PreToolUse:Bash` — `destructive_guard.py` | 95 | `execvpe(/bin/bash) failed` (WSL relay) | | `PreToolUse:PowerShell` — `destructive_guard.py` | 45 | same | | `Stop` — `guard_launch_monitor.py` (the detector itself) | 23 | same | And the stale-session window no source-side gate can reach: hook config loads at session start, so a session running when the #2570 fix landed on disk (2026-08-13T21:19:56Z) kept executing the dead exec-form config — **22 further failures after the fix shipped**, latest 2026-08-14T03:53Z, guard and detector both dead, zero operator-visible signal. ## Fix `hook-failure-audit.sh`, an eighth `claude-ops` `*-audit` hook, registered on `Stop`: - **Decoupled by construction:** lives in a plugin whose hook registrations have been shell-form `"${CLAUDE_PLUGIN_ROOT}"/hooks/*.sh` throughout — alive during the entire incident, including the stale-session window. A defect that kills a watched plugin's launch path cannot take this detector with it. - **Bounded cost:** `Stop` cadence (once per turn, per `guard_launch_monitor.py`'s ADR 0004 / D-12 rationale), transcript-tail read capped at 2 MB with the truncated first line dropped — O(cap), not O(session length). - **Structural matching, never substring:** a record counts only when top-level `.type == "attachment"` and `.attachment.type == "hook_non_blocking_error"`. A `hook_success` whose stdout quotes an error, and a message record quoting a failure record as a string — both false-positive shapes hit while mining the incident transcripts — cannot fire it (both pinned in the contract test). - **Once per session per distinct failing hook,** re-warning when a *new* hook starts failing; marker bookkeeping under `${CLAUDE_PLUGIN_DATA}` degrades toward re-warning, never toward silence. - **`systemMessage`** names the failing hooks, counts, a stderr snippet, the fail-open consequence, and the restart-to-reload remedy for the stale-session case; **telemetry envelope** carries privacy-safe subjects (hook names only). - Advisory: always exit 0; fail-open jq gate with the standard skip notice; kill switch `hook_failure_audit_enabled`. Overlap with `guard_launch_monitor.py` is deliberate: that monitor keeps its guard-specific semantics; a destructive-guard failure may warn twice. Its own doc block names over-warning as the safe direction for this class. ## Red-first evidence `hook-failure-audit.test.sh` was written and run **before** the hook existed: ```text FAIL: failure surfaced -> exit 0: expected exit 0, got 127 FAIL: names the dead hook: 'PreToolUse:Bash' not in: bash: .../hook-failure-audit.sh: No such file or directory ... PASS=4 FAIL=20 ``` With the hook in place: `PASS=28 FAIL=0`. The core fixture is a structural copy of a real incident attachment record (WSL-relay stderr, session `ac1c95e3`). ## Gates - `hook-failure-audit.test.sh` — PASS=28 FAIL=0 - `shellcheck` both new files — clean - `check-hook-exec-form.sh` — clean (the new registration is shell form) - `check-changelog-parity.sh --check / --check-bump origin/main / --check-order` — clean (0.31.14 → 0.32.0) - `check-silent-skips.sh`, `check-hook-userconfig-argv.sh`, `check-manifest-duplicate-keys.py`, `check-cross-plugin-source-drift.sh` — clean - `check-shell-portability.sh origin/main` — clean - `typos` on all touched files — clean - lefthook pre-commit suite — passed on commit ## Related Closes #2577 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d-written jq loop (#2578) (#2581) ## Summary `plugins`' `sync` steps told the reader to take an id list out of `fleet-state.sh`'s JSON and loop a `claude plugin` call over it, but never supplied the extraction — so every reader hand-wrote their own `jq -r`. On Windows that hand-written `jq` reintroduces a CR that `fleet-state.sh` is careful to strip, and the sweep then fails for every id but the last. Observed live: **64 of 65 user-scope updates failed**, all with `Plugin "<name>" not found` — text identical to the documented bare-name gotcha, so it reads as "the marketplace is broken". This adds `fleet-state.sh --ids <selector>`, which emits the id list directly (CR-free by construction), points Steps 2-5 at it, and corrects the CRLF mechanism `gotchas.md` describes. ## Related Closes #2578. ## The mechanism, corrected The native Windows `jq` opens stdout in **text mode**, so every `\n` it writes becomes `\r\n`. Which capture is actually corrupted depends on how it is read — verified on jq 1.8.2 (winget `jqlang.jq`) with MSYS bash 5.3.9: | Read form | Result | | --- | --- | | `x=$(… )`, single-line output | **clean** — `$(…)` strips the trailing `\r\n` as a unit | | `x=$(… )`, multi-line output | **every line but the last carries `\r`** | | `mapfile -t` / `readarray -t` | **every element carries `\r`**, last included | | jq output read back by jq | **self-cleaning** — jq's stdin is text-mode too | Two of those contradict what `gotchas.md` said, and the corrections matter: - It claimed a single-line capture retains the `\r`. It does not, and that claim predicts the wrong symptom — *all* ids failing rather than all but the last. The all-but-last pattern is the single most useful diagnostic signal here (if only the last item in a batch worked, stop looking for a logic bug), and the old text talked the reader out of it. - jq→jq relays are self-cleaning, which narrows the hazard considerably: it is not "any jq output", it is **jq's line output reaching a non-jq consumer** — an external command's argv, a string comparison, a file write. That is exactly the shape the production loop had. `IFS=$'\n'` does not help in any case: `\r` is not the separator, it rides inside the token. ## Repo-wide sweep (item 2) Scripted, paren-aware scan of all **499** tracked `.sh` files and **1120** `.md` files for jq output reaching a line-wise consumer — `jq | while read`, `< <(jq)`, `mapfile`/`readarray`, `for x in $(jq)`, and `VAR=$(jq …)` later split via `<<<"$VAR"`. **31 shell sites and 4 markdown fenced blocks; 0 unguarded.** (Rows below group sites that share a file or a synced source; the per-row counts sum to 31 + 4.) | Site | Shape | Verdict | | --- | --- | --- | | `lib/hook-utils.sh:935` + **16** synced plugin copies | `read -d '' < <(jq -j)` | **Guarded** — `clean="${v//$'\r'/}"` after the read, with a comment naming this exact hazard. One source, 17 files. | | `scripts/check-hook-exec-form.sh:282` | `while read < <(jq -r)` | **Guarded** — `rel="${rel%$'\r'}"` in the loop body | | `scripts/check-hook-userconfig-argv.sh:113` | `while read < <(jq -r)` | **Guarded** — same in-loop strip | | `.claude/hooks/hook-telemetry-sink.sh:42` + `plugins/claude-ops/hooks/` copy | `mapfile < <(jq)` | **Guarded** — `\| tr -d '\r'` in the pipeline | | `plugins/claude-ops/.../fleet-state.sh:591` | `while read <<<"$names"` | **Guarded** — file-scoped `jq()` wrapper (line 105) | | `plugins/claude-config/.../check-plugin-drift.sh:270` | `while read <<<"$var"` | **Guarded** — `\| tr -d '\r'` | | `plugins/work-items/.../claim.sh:102`, `lib/lease.sh:54` | `while read < <(jq -c)` | **Benign** — jq→jq relay; `row` is only ever re-parsed by jq, and the scalars derived from it are single-line `$()` captures | | `plugins/work-items/scripts/backfill-capability-tier-labels.sh:90` | `jq -c \| while read` | **Benign** — same relay. `body` reaches `grep -E` with **unanchored** patterns, so a trailing CR cannot change the match | | `plugins/source-control/.../fetch-annotations.sh:128` | `while read <<<"$var"` | **Benign** — relay; `cr_id`/`cr_name` are single-line captures | | `plugins/claude-config/.../fix-plugin-drift.sh:125,134` (2) | `while read <<<"$var"` | **Benign** — ids go to `jq -nR '[inputs]'`, which strips the CR on input. Reproduced end-to-end: `settings.json` keys come out clean and the orphan `del()` succeeds | | `plugins/claude-config/.../fix-plugin-drift.sh:140,146` (2) | `while read <<<"$var"` | **Benign** — display-only `printf` | | 4 markdown fenced shell blocks (`wayfind/tracker-mechanics.md`, `babysit-loop`, `attend-queue`, `work-loop`) | `--jq \| while read` | **Guarded** — all four already carry `\| tr -d '\r'` | `fix-plugin-drift.sh` was the one site that looked genuinely broken — it builds `"\(.name)@\(.marketplace)"` ids, the exact shape from the incident, and feeds them into a read-modify-write of the user's `settings.json`. It was worth reproducing rather than reasoning about, and the reproduction is what surfaced jq's input-side text-mode translation: the CR is stripped again by `jq -nR`, `del()` matches, and nothing corrupt is written. Classified benign on evidence, not inspection. ## Remediation chosen, and why **Eliminate the reader-side `jq` rather than document a guard around it.** Every guard style already in this repo (`| tr -d '\r'`, `${v//$'\r'/}`, `${var%$'\r'}`, the file-scoped `jq()` wrapper) only helps code that is *in* the repo. The failure here was in shell an agent improvised from prose, so no guard convention could have reached it. `--ids` removes the improvisation surface: the step is now a `read` loop over a script that already routes every `jq` call through its wrapper. Rejected alternatives: adding `| tr -d '\r'` to the prose (still hand-written, still forgettable, and only fixes Step 3 of four); `IFS=$'\n'` (verified not to help); `jq --raw-output0` + `read -d ''` (works, but would introduce a second idiom next to the `tr -d '\r'` convention already established across 20+ sites). ## Red-first evidence (item 4) New assertions run against the **unmodified** `fleet-state.sh` (pre-fix script + new tests, in the worktree so `hook-utils.sh` still resolves): ``` 41 cases, 8 failed FAIL: --ids installed-user: exit 0 FAIL: --ids installed-user: one fully-qualified id per line FAIL: --ids missing-user-install: catalog entry installed nowhere FAIL: --ids CR regression: exit 0 under a CRLF-emitting jq FAIL: --ids CR regression: ids are byte-exact under a CRLF-emitting jq FAIL: --ids unknown selector: names the bad selector FAIL: --ids with no selector: says a selector is required FAIL: --ids with --all: refuses rather than inventing a shape ``` Every pre-existing case stayed green, so the 8 failures are the new contract and nothing else. **The CR case is host-independent.** A PATH stub named `jq` normalizes every emitted line to exactly one trailing CR (`sed 's/\r*$/\r/'`), so it exercises the same bytes on a Linux runner (where real jq emits bare LF and the stub *adds* the CR) as on Windows (where the stub is a no-op). `command jq` resolves through PATH, so `fleet-state.sh`'s own wrapper calls the stub exactly as it would call a native Windows jq — meaning the case goes red both if `--ids` is removed **and** if that wrapper is ever deleted. Two things learned from #2571's lesson and applied here: - A stub probe asserts the stub really emits CRLF, so a stub that quietly stopped working cannot make the CR assertion vacuously pass. - The CR assertion has an explicit **empty-output** branch. Against the pre-fix script the output was empty and `*$'\r'*` was vacuously false — the assertion passed for the wrong reason until that branch was added. That is the same "test encodes the bug as the contract" failure #2571 fixed, and it showed up here in the first red run. ## Should this be a CI lint? (item 6) — No Not in #2569/#2571's gate: `check-hook-exec-form.sh` is exec-form/bare-command-name domain, and a CR class shares none of its parsing. The only sane home would be `scripts/check-shell-portability.sh` (changed-`.sh` lint, data-driven token list, an existing `!name` mechanism for classes needing code rather than an ERE, and a per-site `portability-ok:` escape). Even so, it should not be added now: 1. **It would not have caught this incident.** The failing shell was never a tracked file. Every gate in this repo reads the repo. 2. **There is nothing to catch.** 29/29 sites already guarded or benign; a lint shipping against zero violations is pure false-positive risk on unrelated PRs. 3. **The analysis it needs does not fit the gate.** Guards legitimately appear in four forms and often in the *loop body*, after the redirect that names `jq` — `check-shell-portability.sh`'s line-record model has no loop-scope view, and every existing class is decidable within a record. If an unguarded site ever does land, `check-shell-portability.sh` is the home and this section is the design note. Filing a follow-up issue for a lint I am arguing against would just park the same false-positive risk in the backlog. ## Changes - `plugins/claude-ops/skills/plugins/scripts/fleet-state.sh` — `--ids <selector>` (`installed-user`, `current-project`, `missing-user-install`, `missing-enabled`). One tab-separated record per line, first field always the fully-qualified id; `current-project` carries `scope` as a second field. Selector validated at parse time; refuses `--all`; per-marketplace failure blocks go to stderr in this mode. Header documents the record shape and the widened exit-2 set. - `plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh` — 22 assertions across 10 cases, incl. the host-independent CR regression, the dual-scope pairing case, and the error-block-off-stdout case. - `context/sync.md` — Steps 2-5 cite `--ids`; Step 3 carries the full guarded loop. - `context/gotchas.md` — mechanism corrected (per-read-form table, all-but-last signature, jq→jq self-cleaning, `mapfile` caveat) and pointed at `--ids`. No second CR section added. - `CHANGELOG.md` / `plugin.json` — 0.31.15. ## Verification | Gate | Result | | --- | --- | | `fleet-state.test.sh` (Windows, real native jq) | 45 cases, 0 failed | | `scripts/validate-plugins.sh` | pass | | `scripts/check-changelog-parity.sh --check` / `--check-bump origin/main` | pass | | `shellcheck` (both changed `.sh`) | clean | | `markdownlint-cli2` (all changed `.md`) | 0 errors | | `scripts/check-shell-portability.sh --paths` | no unexcused constructs | | `scripts/check-skill-portability.sh --paths` | no unexcused tokens | The `hook-utils-windows` lane covers `lib/hook-utils.test.sh`; this change is claude-ops-scoped, so the meaningful Windows evidence is the local run above against the real native jq — the exact binary whose text-mode stdout causes the defect. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ENTS.md (#2584) (#2585) Closes #2584 ## Summary Documents the Windows/git trap behind CI's `exec-bit=failure` in `AGENTS.md`: under `core.filemode=false` (every NTFS clone), `chmod +x` never reaches the index, so a newly added shebang file commits as `100644` and nothing looks wrong locally until the `hygiene` lane goes red. Two PRs hit this in one day (#2583 here, melodic-software/dotfiles#479). ## Fix Adds an `AGENTS.md` section, placed with the existing commit-mechanics rule ("Stage explicit paths"), that: - leads with the literal symptom string `exec-bit=failure` so a search from the red lane lands on it; - gives the two-line fix (`chmod +x` + `git update-index --chmod=+x`), which writes the index entry regardless of `core.filemode`; - explains why the defect is invisible locally on Windows and how to see it (`git ls-files --stage`); - points at the source-control commit skill's existing `exec-bit-check.sh` and its `reference/exec-bit.md` for depth — the trap bites only commits made without that skill. Deliberately documentation, not a new gate: the defect is already enforced fail-closed by the hygiene lane (whose per-file annotation prints the exact fix command) and auto-fixed at commit time by the commit skill; a third gate would duplicate the same CI round or introduce local git hooks, a mechanism this repo does not use. Rationale in #2584. ## Verification Mechanism verified empirically on a Windows/Git Bash box before documenting: fresh `git init` sets `core.filemode=false`; `chmod +x` + `git add` stages `100644`; `git update-index --chmod=+x` flips the entry to `100755`; a later `git add` of content edits preserves `100755`. The exec-bit action source at the pinned SHA (`ci-workflows` `c265418`) confirms the check is shebang-based and extension-agnostic, and mode-gated on `100644`. Local gates on the change: `markdownlint-cli2 AGENTS.md` (0 errors), `typos` (clean), `editorconfig-checker` (clean), `lychee --offline` (relative link OK), `gitleaks git` (no leaks), `scripts/affected-tests.sh` (no suites selected — every changed file is a recorded no-suite class). ## Related - Refs #2583 — this repo's occurrence (rate-limit-guard bench harness scripts) - Refs melodic-software/dotfiles#479 — same-day occurrence in a sibling repo - Refs #2569 / #2571 — the hook-portability gate precedent weighed (and distinguished) in the document-vs-enforce call 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 37s —— View job Security review skipped
|
|
Last security-reviewed head: |
|
Claude finished @kyle-sexton's task in 2m 14s —— View job
|
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
Summary
An exec-form hook — a hook object carrying
args— resolvescommandas an executable through PATH, not as a shell command line. A bare name is therefore machine-dependent, and on Windows two spellings resolve to something that is not the interpreter the author meant:bash/shhit the WSL relaybash.exeunderSystem32(dying withexecvpe(/bin/bash) failedwhen no distro provides/bin/bash), andpython/python3/pyhit the zero-length WindowsApps App Execution Alias stub. A failed hook launch is a non-blocking error, so aPreToolUseguard wired this way silently enforces nothing.This class has now shipped three times in
disk-hygienealone: #1006 fixed it, #1504 reintroduced it while fixing a different (Python resolution) bug, #2570 fixed it again — and #1416 was closedCOMPLETEDwhile the guard stayed dead through 73 recorded runs, every one ahook_non_blocking_error.plugins/claude-config/skills/audit/reference/audit-checklist.mdCategory D has carried this as anerrorrow the whole time. A checklist a human reads is not a gate; nothing structural prevented reintroduction. This PR is that structure.What lands
scripts/check-hook-exec-form.sh+scripts/check-hook-exec-form.test.sh, modelled directly on the siblingcheck-hook-userconfig-argv.shgate — samecd-to-repo-root shape, same scope rules for hook config JSON, same out-of-tree manifest-path trust boundary with a visible skip, same self-test-first CI wiring, same failure-output style.Coverage — both declaration surfaces, because the defect has appeared in each:
plugins/*/hooks/hooks.json(default location)hooksas a string, or an array of paths)hooksobjecthooks:blocksargspresence is the sole exec-form discriminator. The command string is never searched for interpreter names, so the #2570 fix — shell form with a leading barebashandshell: bash, exactly asplugins/repo-hygiene/skills/clean/SKILL.mdnow carries it — is not flagged. That is a named test case, not an accident.Two readers, one rule — and why the YAML one is a real parser
The JSON surface is read with
jq, which parses that format completely. The frontmatter surface is read byscripts/check-hook-exec-form-frontmatter.py, which hands the document to PyYAML. Both readers only report exec-form hooks; the shell gate owns the rule, so one implementation governs both surfaces.That split was not the starting point. The frontmatter surface began as a hand-rolled
awkwalk over block-style YAML, and review found four ways past it in three rounds — a quoted key"hooks":, two escape encodings of the same key ("hooks":, then"\U00000068ooks":), an alias under the key, and a brace inside an ordinary scalar misread as flow style. Two were fail-open, two were false positives. Each fix enlarged the parser and invited the next case, because "which spellings does YAML permit" has no natural end — and a gate that misparses is worse than no gate, since it either blocks valid frontmatter or waves through the very defect it exists to catch. This one had done both.PyYAML settles the class by construction: quoted and escaped keys arrive already decoded by the scanner, anchors and aliases and merge keys resolve, flow and block style are the same document. The fail-closed refusals those rounds forced went away with it — the reader now refuses only frontmatter that genuinely does not parse.
The dependency is handled the way this repo already handles a pinned tool:
pyyamlis hash-locked in.github/requirements-ci.txtbeside every other Python pin, and resolved exactly asscripts/run-ruff.shresolves ruff — a python that can already import it (the CI path), elseuv run --with pyyaml==<pin>reading that same pin (the cross-platform local path, no global install and no virtualenv ceremony), else exit 1. A missing module never becomes a silent pass over 997 files.Scope narrowing that came with it:
hooksis read only as a top-level frontmatter key, the one position Claude Code loads a skill or agent hook from. Ahooks:mapping nested under another key is data, not a registration.Relationship to the sibling gate: complementary, non-overlapping.
check-hook-userconfig-argv.shconstrains whether a${user_config.*}token may appear in a hook config; this gate constrains what shapecommandtakes once a hook is in exec form. Neither subsumes the other.The rule, and why this one
Broad rule, not a denylist: an exec-form
commandcontaining no path separator (/or\) fails.A denylist of known-problematic names can only ever hold the spellings someone was already burned by — and that is precisely this defect's history. A
{bash, sh}denylist written after #1006 would have wavedpython3straight through, which is exactly what #2568 is. The failure is not "these particular names are bad"; it is "a barecommandis a PATH lookup whose resolution is a property of the machine, and CI cannot see the machine". The broad rule names the actual mechanism.Nothing legitimate is rejected. The tree contains zero exec-form hooks in any
hooks.jsonor manifest today, and the alternatives cost nothing:${CLAUDE_PLUGIN_ROOT}-rooted or absolute paths carry a separator and pass untouched, and shell form has noargsat all and is never inspected.One allowlisted bare name:
node. Not a judgement call about "real executables on every platform" — that unverifiable judgement is what failed three times. The admission criterion is mechanical: a name qualifies only when no Windows shim, relay, or App Execution Alias stub shadows it on PATH ahead of the real interpreter.bash/shfail it (WSL relay).python/python3/pyfail it (WindowsApps stubs).nodepasses —node.exeis the only resolution — which is why bothdocs/PLUGIN-PHILOSOPHY.md(Hooks row) and the claude-config audit checklist already name"command": "node", "args": [...]as the Windows-correct exec-form spelling. Allowlisting it keeps the gate agreeing with the repo's own documented guidance instead of contradicting it; a gate that forbids what the philosophy doc recommends does not get obeyed, it gets exempted. The array is a bash literal in the script (not a data file) and is pinned by a test, so growing it is a visible code+test diff, never a quiet one-word edit.Deviation from the issue's proposal — stated, not silent
The issue proposed "a documented escape-hatch allowlist file that can only shrink" for file paths, mirroring
scripts/hook-userconfig-argv-allowlist.txt. This PR does not ship that file. Reasoning:required:true+ argv, no unset case) adoption. Here no legitimate case exists: shell form and a rooted path are always available at zero cost, so a path exemption could only ever grandfather debt.bashalive across fix(repo-hygiene): destructive-guard fail-open on Windows — shell-form hook launch + jq fail-closed degraded mode #1006 → fix(disk-hygiene): guard-launch detector shares the guard's interpreter, so it is blind to the interpreter fail-open #1504. For a defect whose signature is "the guard is silently off", the escape hatch and the bug are the same object.If a genuine case ever appears, adding the file is a small, reviewed change modelled on the sibling (including its stale-entry guard). I would rather add it against evidence than ship it empty.
Also
hook-exec-form-gate(self-test first, then the gate), added to theci-statusneedsgraph — a gate that is not a required check is the disk-hygiene: the destructive-operation guard has never launched — 73 recorded runs, all hook_non_blocking_error #1416 failure mode again.docs/PLUGIN-PHILOSOPHY.mdHooks row now names the failing spellings and points at the mechanical check, in the repo's own "prose states it but cannot self-verify" idiom. No behavioural doc change —"command": "node"remains correct, which is the payoff of allowlisting it.Test plan
Red — the gate fails against the shapes that actually shipped
A gate never demonstrated red is not a gate.
3a51996cis the commit immediately before #2570 landed, so its tree carries all three historical instances of this defect: the two wired hooks inhooks/hooks.json(both"command": "bash"+args) and the skill-frontmatter one ("command": "python3"+args, which #2568 owned). Reconstruct them into a fixture tree and run the gate:Every instance is named, on both surfaces, with a resolvable location. Note the JSON paths: the pre-#2570
hooksvalue is an event-keyed object, not an array, so a shape-specific walk would have found one entry and missed the other.Green — the current tree
Clean across every JSON surface and all 997 markdown files under
plugins/.This PR was red by construction until #2568 landed, on exactly one file —
plugins/disk-hygiene/skills/clean/SKILL.md:11— and it deliberately never touched that file, grandfathered it in a baseline, or added a path allowlist to clear itself. #2568's fix (PR #2572,be72131e) removed the last instance; this branch is rebased on top of it. That ordering is the point rather than an inconvenience: this class's entire history is a guard being switched off with a plausible-looking justification attached, and a gate that exempts its own last violation to go green is that same move.The shape #2572 landed is pinned as a case here too, so the gate can never start rejecting the fix that unblocked it. So is the must-stay-green case from #2570:
plugins/repo-hygiene/skills/clean/SKILL.mdcarries shell form with a leading barebash, and the gate would be wrong to flag it —argspresence is the sole exec-form discriminator, and the command string is never searched for interpreter names.Self-test
scripts/check-hook-exec-form.test.sh— 57 cases, all passing locally and in CI. Fixture-tree pattern copied from the sibling gate's suite. Coverage:${CLAUDE_PLUGIN_ROOT}command passes; absolute Windows path passesnodepasses;bash,sh,python,python3,py,pwsh,denoeach fail; allowlist contents pinned so growing it is a visible code+test diffargs: [](present but empty) is still exec formmatcherentry is not itself a hook object; one clean + one dirty sibling flags only the dirty onehooks/*.jsonnot scanned; unparsable manifest does not crash the gate; an MCPcommand/argspair outside thehookskey is out of scopehooks:,"hooks":,'hooks':,hooks :,"hooks" :,"hooks":,"\U00000068ooks":,"\x68ooks":— each was a live bypass of the walk this gate used to carrycommand, a trailing comment on the key, and braces inside an ordinaryargsscalar (that last one had been red-lining valid frontmatter)hookskey, a duplicate key inside the hooks declaration, or one arriving through a<<merge. PyYAML keeps the last value and js-yaml rejects the document, so what would actually run is ambiguous — and a gate premised on "do not assume how this resolves on the target machine" must not resolve it toward the reading that clears the file. Not theoretical: skill-quality: plugin.json ships twoversionmembers (0.11.0 and 0.12.0) — advertised release is parser-dependent #1492 shipped a duplicate manifest key through a fully green suite.args, unquoted value with a trailing comment, CRLF, agent frontmatter, ahooks:block in the prose body or a fenced example is not a declaration, ahooks:mapping nested under another key is not a declaration, and nothing outside thehookskey is ever judged (a foldeddescription:, an&anchor, and a<<:merge in ordinary frontmatter stay inert)Repo gates run locally
shellcheck(clean),shfmt -d(clean),actionlint(clean),typos(clean),scripts/run-ruff.sh checkon the new Python (clean),scripts/check-shell-portability.sh --pathson both shell files (clean),check-silent-skips.sh,check-discriminating-test-skips.sh,check-changelog-parity.sh --check,check-contract-slice-prune.sh --check,check-orphaned-fixtures.sh --check,check-cross-plugin-source-drift.sh --check,check-contract-clause-coverage.py,check-manifest-duplicate-keys.py. All three new scripts committed mode100755. No--no-verify.Review
Four rounds from
chatgpt-codex-connector, seven findings, all addressed and resolved. Six were code changes; one — "fix the existing violation or defer requiring the gate" — was answered rather than applied, because deferring theci-status.needswiring reproduces the #1416 shape this PR exists to end. Rounds 2 and 3 are what motivated replacing the hand-rolled YAML walk with a real parser (three of those findings were bypasses of a parser that was chasing YAML's spelling rules); round 4 found the duplicate-key ambiguity above. That reasoning is in the resolved threads and in the reader's own doc-block.Related
commandis a bare non-portable executable name #2569python3in exec form (latent Windows fail-open) #2568 (PR fix(disk-hygiene): launch the skill-scoped guard in shell form (#2568) #2572) — was the one remaining violation this gate reported; landed asbe72131e, and this branch is rebased on top of itCOMPLETEDwhile the guard was dead