Skip to content

feat(ci): gate exec-form hooks against bare command names (#2569) - #2571

Merged
kyle-sexton merged 8 commits into
mainfrom
ci/2569-hook-exec-form-gate
Aug 13, 2026
Merged

feat(ci): gate exec-form hooks against bare command names (#2569)#2571
kyle-sexton merged 8 commits into
mainfrom
ci/2569-hook-exec-form-gate

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

An exec-form hook — a hook object carrying args — resolves command as 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/sh hit the WSL relay bash.exe under System32 (dying with execvpe(/bin/bash) failed when no distro provides /bin/bash), and python/python3/py hit the zero-length WindowsApps App Execution Alias stub. A failed hook launch is a non-blocking error, so a PreToolUse guard wired this way silently enforces nothing.

This class has now shipped three times in disk-hygiene alone: #1006 fixed it, #1504 reintroduced it while fixing a different (Python resolution) bug, #2570 fixed it again — and #1416 was closed COMPLETED while the guard stayed dead through 73 recorded runs, every one a hook_non_blocking_error. plugins/claude-config/skills/audit/reference/audit-checklist.md Category D has carried this as an error row 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 sibling check-hook-userconfig-argv.sh gate — same cd-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:

Surface Covered
plugins/*/hooks/hooks.json (default location) yes
Manifest-pointed hook config (hooks as a string, or an array of paths) yes
Inline manifest hooks object yes
Skill / agent YAML frontmatter hooks: blocks yes — new; the userconfig-argv gate does not cover this, and it is where the third instance lived (#2568)

args presence is the sole exec-form discriminator. The command string is never searched for interpreter names, so the #2570 fix — shell form with a leading bare bash and shell: bash, exactly as plugins/repo-hygiene/skills/clean/SKILL.md now 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 by scripts/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 awk walk 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: pyyaml is hash-locked in .github/requirements-ci.txt beside every other Python pin, and resolved exactly as scripts/run-ruff.sh resolves ruff — a python that can already import it (the CI path), else uv 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: hooks is read only as a top-level frontmatter key, the one position Claude Code loads a skill or agent hook from. A hooks: mapping nested under another key is data, not a registration.

Relationship to the sibling gate: complementary, non-overlapping. check-hook-userconfig-argv.sh constrains whether a ${user_config.*} token may appear in a hook config; this gate constrains what shape command takes 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 command containing 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 waved python3 straight through, which is exactly what #2568 is. The failure is not "these particular names are bad"; it is "a bare command is 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.json or manifest today, and the alternatives cost nothing: ${CLAUDE_PLUGIN_ROOT}-rooted or absolute paths carry a separator and pass untouched, and shell form has no args at 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/sh fail it (WSL relay). python/python3/py fail it (WindowsApps stubs). node passes — node.exe is the only resolution — which is why both docs/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:

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

Test plan

Red — the gate fails against the shapes that actually shipped

A gate never demonstrated red is not a gate. 3a51996c is the commit immediately before #2570 landed, so its tree carries all three historical instances of this defect: the two wired hooks in hooks/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:

t=$(mktemp -d)
mkdir -p "$t/scripts" "$t/.github" "$t/plugins/disk-hygiene/hooks" "$t/plugins/disk-hygiene/skills/clean"
cp scripts/check-hook-exec-form.sh scripts/check-hook-exec-form-frontmatter.py "$t/scripts/"
cp .github/requirements-ci.txt "$t/.github/"
git show 3a51996c:plugins/disk-hygiene/hooks/hooks.json > "$t/plugins/disk-hygiene/hooks/hooks.json"
git show 3a51996c:plugins/disk-hygiene/skills/clean/SKILL.md > "$t/plugins/disk-hygiene/skills/clean/SKILL.md"
(cd "$t" && bash scripts/check-hook-exec-form.sh; echo "exit=$?")
EXEC-FORM HOOK: plugins/disk-hygiene/hooks/hooks.json:.hooks.PreToolUse[0].hooks[0]: exec-form hook (`args` present) with bare command "bash"
EXEC-FORM HOOK: plugins/disk-hygiene/hooks/hooks.json:.hooks.Stop[0].hooks[0]: exec-form hook (`args` present) with bare command "bash"
EXEC-FORM HOOK: plugins/disk-hygiene/skills/clean/SKILL.md:11: exec-form hook (`args` present) with bare command "python3"
exit=1

Every instance is named, on both surfaces, with a resolvable location. Note the JSON paths: the pre-#2570 hooks value 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

$ bash scripts/check-hook-exec-form.sh
No exec-form hooks with a bare command name.
exit=0

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.md carries shell form with a leading bare bash, and the gate would be wrong to flag it — args presence 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:

  • the pre-fix(disk-hygiene): launch wired hooks in shell form, reviving the dead destructive guard #2570 shape fails and names both wired entries; the fix(disk-hygiene): launch wired hooks in shell form, reviving the dead destructive guard #2570 shell-form fix passes on both surfaces; the fix(disk-hygiene): launch the skill-scoped guard in shell form (#2568) #2572 shape that unblocked this PR passes
  • rooted ${CLAUDE_PLUGIN_ROOT} command passes; absolute Windows path passes
  • node passes; bash, sh, python, python3, py, pwsh, deno each fail; allowlist contents pinned so growing it is a visible code+test diff
  • args: [] (present but empty) is still exec form
  • a matcher entry is not itself a hook object; one clean + one dirty sibling flags only the dirty one
  • manifest string-path, manifest array, inline manifest object; out-of-tree manifest path skipped visibly; unreferenced hooks/*.json not scanned; unparsable manifest does not crash the gate; an MCP command/args pair outside the hooks key is out of scope
  • every YAML spelling of the key is one declaration: hooks:, "hooks":, 'hooks':, hooks :, "hooks" :, "hooks":, "\U00000068ooks":, "\x68ooks": — each was a live bypass of the walk this gate used to carry
  • YAML the old walk refused or misread is now simply read: flow-style mappings, a whole declaration on the key line, an alias under the key resolved to its anchor, an anchor in value position, a merge key expanded (with the explicit-key-wins precedence pinned separately), a block-scalar command, a trailing comment on the key, and braces inside an ordinary args scalar (that last one had been red-lining valid frontmatter)
  • reported, never resolved by preference: a duplicate top-level hooks key, 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 two version members (0.11.0 and 0.12.0) — advertised release is parser-dependent #1492 shipped a duplicate manifest key through a fully green suite.
  • fail-closed on the one thing a parser still cannot clear: unparsable YAML frontmatter, and unparsable hook config JSON
  • frontmatter hygiene: line-accurate reporting, block-sequence args, unquoted value with a trailing comment, CRLF, agent frontmatter, a hooks: block in the prose body or a fenced example is not a declaration, a hooks: mapping nested under another key is not a declaration, and nothing outside the hooks key is ever judged (a folded description:, an &anchor, and a <<: merge in ordinary frontmatter stay inert)
  • a clean tree passes with an explicit positive statement

Repo gates run locally

shellcheck (clean), shfmt -d (clean), actionlint (clean), typos (clean), scripts/run-ruff.sh check on the new Python (clean), scripts/check-shell-portability.sh --paths on 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 mode 100755. 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 the ci-status.needs wiring 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

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@claude

claude Bot commented Aug 13, 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 marked this pull request as ready for review August 13, 2026 17:43
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@claude

claude Bot commented Aug 13, 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 Aug 13, 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: 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".

Comment thread .github/workflows/ci.yml
Comment thread scripts/check-hook-exec-form.sh Outdated
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@claude

claude Bot commented Aug 13, 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 Aug 13, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

@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 ci-status.needs finding is answered in-thread: red is by construction until #2568 / PR #2572 lands, this PR carries do-not-merge, and deferring the wiring would reproduce the #1416 shape the gate exists to end.

Please re-review the parser changes in particular — is_hooks_key / is_hooks_key_inline and the grep prefilter they must stay in sync with.

@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: 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".

Comment thread scripts/check-hook-exec-form.sh Outdated
Comment thread scripts/check-hook-exec-form.sh Outdated
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

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 plugins/disk-hygiene/skills/clean/SKILL.md (plus repo-hygiene's, in the same fixture tree):

$ bash scripts/check-hook-exec-form.sh
No exec-form hooks with a bare command name.
exit=0

So the sequence is settled and verified rather than assumed: #2572 merges, then this rebases onto main, hook-exec-form-gate and ci-status go green, and do-not-merge comes off. Posted the same evidence on #2572 so both sides hold the same ordering.

@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

@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:

  • No prefilter. All 997 plugins/**/*.md go to the walk in one batched awk pass; double-quoted keys and scalars are escape-decoded (\uXXXX, \xXX, \, \") before comparison.
  • Fail closed inside a hooks block on anything the walk cannot model: alias, anchor, merge key, flow mapping, block scalar, or any unclassifiable line — each with a named reason.
  • Narrowed to a top-level hooks key, the only position Claude Code reads one from, so a nested hooks: mapping is not mistaken for a declaration now that every file is walked.

48/48. Please look hardest at the inverse risk this introduces: false positives. Outside a hooks block nothing is judged, and args: as a block sequence of scalars must stay walkable — both are pinned, and the full 997-file corpus reports exactly one finding (the #2568 instance).

@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: 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".

Comment thread scripts/check-hook-exec-form.sh Outdated
Comment thread scripts/check-hook-exec-form.sh Outdated
Comment thread scripts/check-hook-exec-form.sh Outdated
kyle-sexton and others added 3 commits August 13, 2026 15:25
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

claude Bot commented Aug 13, 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 Aug 13, 2026

Copy link
Copy Markdown

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
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@claude

claude Bot commented Aug 13, 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 Aug 13, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

@codex review

Round 4 addressed in c84c3ba. The duplicate-key finding was real and reachable — hooks: {} followed by a second hooks: carrying an exec-form bare bash cleared the gate — and is now reported rather than resolved by preference, since PyYAML keeps the last value while js-yaml rejects the document and Claude Code reads this frontmatter with a JS parser. Scoped to the hooks subtree; merge-key precedence unaffected. 56/56, green on the real tree.

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.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: c84c3ba854

ℹ️ 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".

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
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Aug 13, 2026
@claude

claude Bot commented Aug 13, 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 Aug 13, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

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 << as the way in: an anchored mapping carrying two command keys contributed both to a hook object unexamined. A merged mapping's keys are the hook object's keys, so they carry the same ambiguity and now get the same refusal — followed transitively, with the same cycle guard the entry walk uses. One case pins it; 57/57, green on the real tree.

@kyle-sexton
kyle-sexton merged commit be1e0c8 into main Aug 13, 2026
41 checks passed
@kyle-sexton
kyle-sexton deleted the ci/2569-hook-exec-form-gate branch August 13, 2026 21:10
kyle-sexton added a commit that referenced this pull request Aug 14, 2026
… 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>
kyle-sexton added a commit that referenced this pull request Aug 14, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 14, 2026
…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

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 37s —— View job


Security review skipped

  • Gather context (PR body, comments, changed files)
  • Run /review:security-review per custom instructions
  • Post findings

/review:security-review's skip gate stops early when "PR is closed or not open." PR #2571 is merged (confirmed via the provided context and the local checkout, whose main history is already several commits past this PR's head c05b669d), so per the skill's instructions I'm stopping here and posting nothing else.

@github-actions

Copy link
Copy Markdown

Last security-reviewed head: c05b669dce50e2d91b1642dff482ae5938aeb375. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 14s —— View job


/review:code-review — skipped

  • Gather context (PR state, diff, CLAUDE.md)
  • Apply skip gate checks
  • Determine outcome

Skip gate hit: PR is closed / not open. Per this repo's plugins/review/skills/code-review/SKILL.md skip gate, condition 1 ("PR is closed or not open") applies — PR #2571 is already merged. Per the lane's instructions, deep review is skipped and nothing else is posted.

If a fresh review of a later change is needed, please reopen the PR or open a new one against main.

@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

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.

CI: add a repo-wide gate rejecting exec-form hooks whose command is a bare non-portable executable name

1 participant