You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
a single hooks/trailhead-bash-guards.js runs the five guards in sequence over one parsed stdin, first deny wins, same deny messages and exit codes as today; each guard stays its own module (unit tests unchanged or ported);
hooks.json registers one Bash entry instead of five (spawns per Bash call: 7 -> 3, counting secret-read and the injection scanner);
the merged hook honours TRAILHEAD_MOD_GUARDS=<guard,...>@<epoch-ms>: it skips a listed guard only when the stamp is fresh (threshold a few seconds, a named constant), otherwise runs it; malformed or stale value -> run every guard;
one guard throwing never skips the others; the merged hook fails closed per guard as each guard does today;
the Codex projection and the npm installer carry the merged hook (host-neutral, no mod needed); hook counts in READMEs/docs/badges updated.
Done when every existing guard test passes through the merged hook, a Bash call spawns one guard process instead of five, and a fresh TRAILHEAD_MOD_GUARDS skips exactly the listed guards while a stale one skips none.
Goal. One node process runs the five Bash PreToolUse guards (commit, secret, install, search, body) instead of five, with identical verdicts, honouring a fresh TRAILHEAD_MOD_GUARDS stamp. Integration branch: feat/mod.
Failure posture (clarification). "Fails closed per guard as each guard does today" is read as same posture as today, per guard: today every one of the five allows on an internal error (exit 0), and secret-guard additionally emits its loud "could not scan" advisory once it has identified a tracker write. The merged hook preserves exactly that, guard by guard; one guard's throw never prevents the others from running.
Seams
Per-guard pure evaluator. Each of the five guard modules gains an exported evaluate(cmd) returning a verdict object, with no process.exit and no stdout writes:
{ kind: 'allow' }
{ kind: 'block', payload }, where payload is the exact JSON object the guard writes today ({decision:'block', code, reason}), and exit code 2 is implied
{ kind: 'advise', payload, stderr } (secret-guard's could-not-scan advisory only)
Each guard's existing internal try/catch (and its fail-open behaviour) moves inside evaluate, so evaluate itself never throws in normal operation. The guard's own run(data) / require.main CLI is rewritten as a thin wrapper over evaluate that writes payload and exits exactly as today, so each standalone script keeps byte-identical behaviour (its existing test file is the regression net).
reads stdin once (same 5s timeout → exit 0), JSON.parse once; unparseable input → exit 0 (same as every guard today); extracts tool_input.command.
guard order is fixed: commit, secret, install, search, body (today's hooks.json order).
guard selection via argv --guards=<name,...> (default: all five). Modules are required lazily, only for selected guards, so the Codex install, which never ships trailhead-search-guard.js, registers --guards=commit,secret,install,body.
mod skip: parseModGuards(value, now) → a Set of guard names to skip, or empty. Format <name>(,<name>)*@<epoch-ms>; skip only when the whole value is well-formed and 0 <= now - stamp <= MOD_GUARDS_FRESH_MS (named constant, 5000). Malformed, missing, future-dated or stale → empty set (run every guard). Unknown names are ignored.
runs each selected, non-skipped guard in sequence, each call wrapped in its own try/catch: a throw counts as that guard's today-posture (allow; for secret, the guard's own catch already produces the advisory inside evaluate), and the loop continues.
first block wins: on the first block, write that guard's payload to stdout and exit 2 (later guards are not run, matching how Claude Code reports the first denying hook; an earlier guard's advisory is dropped in that case because the command does not run).
no block: if any guard advised, write its stderr line(s) to stderr and a single stdout JSON whose hookSpecificOutput.additionalContext joins the advisories (today only one guard can advise, so the output equals secret-guard's today); exit 0. Otherwise exit 0 with empty stdout.
exports parseModGuards, MOD_GUARDS_FRESH_MS, GUARD_ORDER, runGuards(cmd, {guards, env, now}) for unit tests.
Registration.
plugins/trailhead/hooks/hooks.json: the five Bash entries become one node "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-bash-guards.js".
bin/trailhead.js (npm/Claude): HOOK_FILES gains trailhead-bash-guards.js (the five guard files stay: they are now required modules); installClaude registers only the merged entry and migrates by stripping the five legacy PreToolUse registrations first; uninstallClaude strips the merged entry too; the Claude verify spec expects the merged registration.
bin/lib/codex-projection.jscodexHookEntries: the four Bash entries become one trailhead-bash-guards.js --guards=commit,secret,install,body; the Codex installer strips the four legacy registrations (migration); Codex verify spec updated; the projected Codex prose (line ~116) updated.
Docs.README.md, README.it.md, site/src/content/docs/docs/hooks.md (and getting-started.mdx / configuration.mdx if they count hooks): the guards stay eight distinct guards, but the five Bash guards now run in one process; registration counts and the guardrails badge corrected to be accurate; TRAILHEAD_MOD_GUARDS documented in one line in hooks.md. CHANGELOG Unreleased entry.
TDD (config tdd: seams): tests first, at the seams
plugins/trailhead/hooks/trailhead-bash-guards.test.js (new, RED first):
end to end (spawn the merged hook): a block from each of the five guards produces that guard's exact stdout + exit 2; a clean command → exit 0, empty stdout; first block wins when two guards would block (e.g. a non-conventional git commit also carrying cd x && grep y z); a fresh TRAILHEAD_MOD_GUARDS=commit@<now> lets a bad commit through while a stale one blocks it, and a fresh list skips exactly the listed guards (others still block); --guards=commit,secret,install,body never loads search-guard (a cd x && grep y z passes); one guard forced to throw (secret's TRAILHEAD_SECRETGUARD_THROW=1 seam, plus a test-only seam TRAILHEAD_BASHGUARDS_THROW=<guard> that makes the runner treat that guard's evaluate as throwing) still lets the next guard block; the secret advisory surfaces unchanged when nothing blocks.
every existing guard test through the merged hook: each of the five *-guard.test.js reads its hook path from process.env.TRAILHEAD_GUARD_HOOK || <own script>; the new suite re-runs each of those five test files with TRAILHEAD_GUARD_HOOK=<merged> and asserts exit 0. Where an existing end-to-end case's input would also trip a different guard in the merged process, that is surfaced, not papered over (report it; adjust only the input of that case if it is incidental).
registration: hooks.json has exactly one PreToolUse entry with matcher Bash and it holds exactly one command (the merged hook); none of the five legacy scripts is registered anywhere in hooks.json.
bin/trailhead.test.js, bin/lib/install-verify.test.js, bin/lib/codex-projection.test.js, bin/lib/host-descriptor.test.js: update expectations; add a migration case (a settings.json / Codex hooks.json carrying the legacy registrations ends with only the merged one after install).
refactor: expose a pure evaluate() from each Bash guard (CLI behaviour unchanged, existing tests green)
test: cover the merged Bash guard hook + feat: run the five Bash guards in one hook process (RED then GREEN; may be one commit if the RED state cannot be committed under the hook)
feat: register the merged Bash guard hook on Claude Code and Codex (hooks.json, installer, projection, migration, tests)
docs: describe the merged Bash guard hook
Verification criteria
npm test green (every suite, plus claude plugin validate / claude plugin test).
Each of the five existing guard test files passes unchanged against its own script and with TRAILHEAD_GUARD_HOOK pointing at the merged hook.
hooks.json and the Codex projection register one Bash guard command (Claude: per Bash call 3 spawns = merged + secret-read + injection scanner; Codex: the same 3).
A fresh TRAILHEAD_MOD_GUARDS skips exactly the listed guards; a stale or malformed one skips none.
node bin/trailhead.js --codex into a temp CODEX_HOME (and a Claude temp config dir) installs, registers only the merged Bash entry, and passes install-verify.
Undeclared-coupling check
No other ticket is in progress on the map (no assignee on any open ticket besides this one). The mod's future guard porting (fog 11) reads TRAILHEAD_MOD_GUARDS from this hook by design.
Goal. One node process runs the five Bash PreToolUse guards (commit, secret, install, search, body) instead of five, with identical verdicts, honouring a fresh TRAILHEAD_MOD_GUARDS stamp. Integration branch: feat/mod.
Failure posture (clarification). "Fails closed per guard as each guard does today" is read as same posture as today, per guard: today every one of the five allows on an internal error (exit 0), and secret-guard additionally emits its loud "could not scan" advisory once it has identified a tracker write. The merged hook preserves exactly that, guard by guard; one guard's throw never prevents the others from running.
Seams
Per-guard pure evaluator. Each of the five guard modules gains an exported evaluate(cmd, { env = process.env } = {}) returning a verdict object, with no process.exit and no stdout writes (every process.env read inside a guard, e.g. secret-guard's TRAILHEAD_SECRETGUARD_THROW seam, goes through the passed env, so the runner's env and the unit tests' env are the runtime's; file reads such as --body-file stay real fs reads):
{ kind: 'allow' }
{ kind: 'block', payload }, where payload is the exact JSON object the guard writes today ({decision:'block', code, reason}), and exit code 2 is implied
{ kind: 'advise', payload, stderr } (secret-guard's could-not-scan advisory only)
Each guard's existing internal try/catch (and its fail-open behaviour) moves inside evaluate, so evaluate itself never throws in normal operation. The guard's own run(data) / require.main CLI is rewritten as a thin wrapper over evaluate that writes payload and exits exactly as today, so each standalone script keeps byte-identical behaviour (its existing test file is the regression net).
reads stdin once (same 5s timeout → exit 0), JSON.parse once; unparseable input → exit 0 (same as every guard today); extracts tool_input.command.
guard order is fixed: commit, secret, install, search, body (today's hooks.json order).
guard selection via argv --guards=<name,...> (default: all five). Modules are required lazily, only for selected guards, so the Codex install, which never ships trailhead-search-guard.js, registers --guards=commit,secret,install,body. A missing, empty or malformed --guards, or one containing any unknown name (e.g. a typo secert), is invalid as a whole and falls back to all five (a config error never disables a guard silently). A selected guard whose module fails to require writes a one-line stderr warning naming it and is skipped (today's fail-open posture for that guard); the others still run.
mod skip: parseModGuards(value, now) → a Set of guard names to skip, or empty. Format <name>(,<name>)*@<epoch-ms>; skip only when the whole value is well-formed and 0 <= now - stamp <= MOD_GUARDS_FRESH_MS (named constant, 5000). Malformed, missing, future-dated or stale → empty set (run every guard). Unknown names are ignored.
runs each selected, non-skipped guard in sequence, each call wrapped in its own try/catch: a throw counts as that guard's today-posture (allow; for secret, the guard's own catch already produces the advisory inside evaluate), and the loop continues.
first block wins: on the first block, write that guard's payload to stdout and exit 2; later guards are not run. Any advisory an earlier guard produced keeps its stderr line (written before the block), so the observable stderr matches today's separate processes; only its stdout additionalContext is dropped, since stdout carries the block verdict and the command does not run. Tested explicitly.
no block: if any guard advised, write its stderr line(s) to stderr and a single stdout JSON whose hookSpecificOutput.additionalContext joins the advisories (today only one guard can advise, so the output equals secret-guard's today); exit 0. Otherwise exit 0 with empty stdout.
exports parseModGuards, MOD_GUARDS_FRESH_MS, GUARD_ORDER, runGuards(cmd, {guards, env, now, evaluators}) (evaluators overrides the lazily required modules, for tests only) for unit tests.
Registration.
plugins/trailhead/hooks/hooks.json: the five Bash entries become one node "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-bash-guards.js".
bin/trailhead.js (npm/Claude): the install-verify model is split into shipped runtime files (every script copied, including the five guard modules, now only required) and registered entrypoints (the commands that must appear in settings.json / hooks.json: the merged hook, secret-read, mockup-link (Claude) / mockup-link-stop (Codex), the injection scanner, check-update). verifyInstall checks every shipped file exists and every entrypoint is registered, and no longer requires a shipped module to be registered. HOOK_FILES gains trailhead-bash-guards.js; installClaude registers only the merged entry and migrates by stripping the five legacy PreToolUse registrations first; uninstallClaude strips the merged entry too; the Claude verify spec expects the merged registration.
bin/lib/codex-projection.jscodexHookEntries: the four Bash entries become one trailhead-bash-guards.js --guards=commit,secret,install,body; the Codex installer strips the legacy registrations (commit, secret, install, body, and the pre-Audit the Codex/multi-host install for inert hooks #169 search) before adding the merged one (migration); uninstallCodex strips the merged entry and every legacy registration; Codex verify spec updated; the projected Codex prose (line ~116) updated. Install and uninstall stay idempotent (running either twice leaves the same file).
Docs.README.md, README.it.md, site/src/content/docs/docs/hooks.mdsite/src/content/docs/docs/getting-started.mdx (it describes the Codex hooks), and configuration.mdx if it counts hooks: the guardrails stay the same set of distinct protections (the badge and "ships N hooks" keep counting guardrails, never shrunk because five share a process), and a sentence states that the five Bash guards run in one process; wherever registrations or spawns are counted, they are labelled as such; TRAILHEAD_MOD_GUARDS documented in one line in hooks.md. CHANGELOG Unreleased entry.
TDD (config tdd: seams): tests first, at the seams
plugins/trailhead/hooks/trailhead-bash-guards.test.js (new, RED first):
end to end (spawn the merged hook): a block from each of the five guards produces that guard's exact stdout + exit 2; a clean command → exit 0, empty stdout; first block wins when two guards would block (e.g. a non-conventional git commit also carrying cd x && grep y z); a fresh TRAILHEAD_MOD_GUARDS=commit@<now> lets a bad commit through while a stale one blocks it, and a fresh list skips exactly the listed guards (others still block); --guards=commit,secret,install,body never loads search-guard (a cd x && grep y z passes); one guard forced to throw (secret's TRAILHEAD_SECRETGUARD_THROW=1 seam, plus a unit test that injects a throwing fake evaluator through runGuards(cmd, { evaluators }); no new fault-injection env var ships) still lets the next guard block; the secret advisory surfaces unchanged when nothing blocks.
every existing guard test through the merged hook: each of the five *-guard.test.js reads its hook path from process.env.TRAILHEAD_GUARD_HOOK || <own script>; the new suite re-runs each of those five test files with TRAILHEAD_GUARD_HOOK=<merged> and asserts exit 0. Where an existing end-to-end case's input would also trip a different guard in the merged process, that is surfaced, not papered over (report it; adjust only the input of that case if it is incidental).
parseModGuards boundaries: age exactly 0 and exactly MOD_GUARDS_FRESH_MS → fresh, MOD_GUARDS_FRESH_MS + 1 → stale.
--guards empty / malformed / containing any unknown name (commit,secert) → all five run; a module that fails to load is skipped with a stderr warning while the others still block; an earlier secret advisory's stderr survives a later block.
registration: hooks.json has exactly one PreToolUse entry with matcher Bash and it holds exactly one command (the merged hook); none of the five legacy scripts is registered anywhere in hooks.json.
bin/trailhead.test.js, bin/lib/install-verify.test.js, bin/lib/codex-projection.test.js, bin/lib/host-descriptor.test.js: update expectations; assert the Codex Bash entry's command carries exactly --guards=commit,secret,install,body; assert install-verify passes with the five modules shipped but unregistered and fails when a registered entrypoint is missing; add migration cases: a Claude settings.json and a Codex hooks.json carrying the legacy registrations end with only the merged one after install; uninstall (Claude and Codex) removes the merged entry and every legacy one; install twice and uninstall twice are idempotent.
refactor: expose a pure evaluate() from each Bash guard (CLI behaviour unchanged, existing tests green)
test: cover the merged Bash guard hook + feat: run the five Bash guards in one hook process (RED then GREEN; may be one commit if the RED state cannot be committed under the hook)
feat: register the merged Bash guard hook on Claude Code and Codex (hooks.json, installer, projection, migration, tests)
docs: describe the merged Bash guard hook
Verification criteria
npm test green (every suite, plus claude plugin validate / claude plugin test).
Each of the five existing guard test files passes unchanged against its own script and with TRAILHEAD_GUARD_HOOK pointing at the merged hook.
hooks.json and the Codex projection register one Bash guard command (Claude: per Bash call 3 spawns = merged + secret-read + injection scanner; Codex: the same 3).
A fresh TRAILHEAD_MOD_GUARDS skips exactly the listed guards; a stale or malformed one skips none.
node bin/trailhead.js --codex into a temp CODEX_HOME (and a Claude temp config dir) installs, registers only the merged Bash entry, and passes install-verify.
Undeclared-coupling check
No other ticket is in progress on the map (no assignee on any open ticket besides this one). The mod's future guard porting (fog 11) reads TRAILHEAD_MOD_GUARDS from this hook by design.
Reviewer: Codex CLI (codex exec, read-only), 3 rounds (2 configured, a 3rd at the maintainer's request to run until convergence). Converged: no blocking concern left.
Round 1
BLOCKING: evaluate(cmd) read process.env implicitly, so tests and runtime could diverge. Resolved: evaluate(cmd, { env }); every guard env read goes through it.
BLOCKING: Codex uninstall did not remove the legacy registrations. Resolved: install and uninstall strip every legacy registration (including the pre-Audit the Codex/multi-host install for inert hooks #169 search one), idempotent, tested.
non-blocking: guardrail count vs registration count. Resolved: docs and badge keep counting guardrails; registrations and spawns are labelled as such.
non-blocking: a secret advisory was dropped when a later guard blocked. Resolved: its stderr line is kept; only stdout carries the block.
non-blocking: malformed --guards. Resolved: falls back to all five.
Round 2
BLOCKING: --guards=commit,secert silently disabled secret. Resolved: any unknown name invalidates the whole list (all five run).
BLOCKING: verifyInstall required every shipped hook file to be registered. Resolved: shipped runtime files and registered entrypoints checked separately.
non-blocking: assert the exact Codex --guards argument; test freshness boundaries (0, 5000, 5001 ms). Both added.
Round 3
non-blocking: no fault-injection env var in shipped code. Accepted: tests inject a throwing evaluator via runGuards.
non-blocking: getting-started.mdx describes Codex hooks. Accepted: added to the docs list.
non-blocking: instrument a real spawn counter. Deferred: the spawn count is fixed by the registered hook config, which is asserted for both hosts; a process-level counter would test Claude Code itself, not trailhead.
I1: fixed (45bc345). A guard module that failed to load was skipped with only a stderr line (hidden on exit 0); it now also emits a session advisory naming the guard.
Tests.npm test: 24 suites ok, 0 failed (including claude plugin validate / claude plugin test on the executor run; re-run here with the mod checks skipped, same result). New trailhead-bash-guards.test.js: 88 checks. The five existing guard suites pass unchanged against their own script and through the merged hook (TRAILHEAD_GUARD_HOOK): commit 15, secret 34, install 52, search 28, body 47.
Code review (commits d4d4bf0, d2e86ce, fc0b92a, afe071b): clean. Checked byte-identical standalone guard output, first-block-wins with an earlier advisory's stderr kept, parseModGuards bounds (0 and 5000 ms fresh, 5001 stale; malformed, future and stale skip none), --guards fallback to all five on empty/malformed/any unknown name, lazy loading without search-guard on Codex, install migration and idempotent uninstall on both hosts, install-verify split between shipped files and registered entrypoints, docs with no em-dashes.
Goal-backward verification: status: met. Every claim evaluated and satisfied: one merged hook with unchanged verdicts; existing guard tests pass through it; one Bash PreToolUse registration (3 processes per Bash call on Claude Code and Codex); fresh TRAILHEAD_MOD_GUARDS skips exactly the listed guards, stale/malformed none; per-guard failure isolation with today's posture; Codex projection (--guards=commit,secret,install,body) and npm installer carry it (temp-dir installs of both pass install-verify and block a bad command); READMEs, hooks.md, getting-started and CHANGELOG updated, guardrail badge still counting protections.
Acceptance. No user-facing UI: proven by the automated suites and by running the installed copies from temp Claude and Codex installs.
Noted outside this ticket: the README badge reads guardrails-7 hooks while the text says eight hooks; it predates this ticket (1725acd) and was left untouched.
Resolved. The five Bash PreToolUse guards (commit, secret, install, search, body) now run in one node process, plugins/trailhead/hooks/trailhead-bash-guards.js.
d4d4bf0refactor: expose a pure evaluate() from each Bash guard: each guard exports evaluate(cmd, { env }); its standalone CLI is a thin wrapper with byte-identical behaviour.
d2e86cefeat: run the five Bash guards in one hook process: fixed order, first block wins, per-guard failure isolation, --guards= subset with an all-five fallback, TRAILHEAD_MOD_GUARDS=<names>@<epoch-ms> skip with MOD_GUARDS_FRESH_MS = 5000.
fc0b92afeat: register the merged Bash guard hook on Claude Code and Codex: one Bash entry in hooks.json, the npm installer and the Codex projection (Codex with --guards=commit,secret,install,body), legacy registrations stripped on install and uninstall, install-verify checks shipped files and registered entrypoints separately.
Follow-up: 80418b7docs: note that a guard failing to load is reported documents the I1 fix (45bc345) in README.md, README.it.md and the site's hooks page.
Activity
ToRvaLDz commentedon Oct 11, 2026
PLAN
Goal. One
nodeprocess runs the five BashPreToolUseguards (commit, secret, install, search, body) instead of five, with identical verdicts, honouring a freshTRAILHEAD_MOD_GUARDSstamp. Integration branch:feat/mod.Failure posture (clarification). "Fails closed per guard as each guard does today" is read as same posture as today, per guard: today every one of the five allows on an internal error (exit 0), and secret-guard additionally emits its loud "could not scan" advisory once it has identified a tracker write. The merged hook preserves exactly that, guard by guard; one guard's throw never prevents the others from running.
Seams
evaluate(cmd)returning a verdict object, with noprocess.exitand no stdout writes:{ kind: 'allow' }{ kind: 'block', payload }, wherepayloadis the exact JSON object the guard writes today ({decision:'block', code, reason}), and exit code 2 is implied{ kind: 'advise', payload, stderr }(secret-guard's could-not-scan advisory only)Each guard's existing internal try/catch (and its fail-open behaviour) moves inside
evaluate, soevaluateitself never throws in normal operation. The guard's ownrun(data)/require.mainCLI is rewritten as a thin wrapper overevaluatethat writespayloadand exits exactly as today, so each standalone script keeps byte-identical behaviour (its existing test file is the regression net).plugins/trailhead/hooks/trailhead-bash-guards.js:JSON.parseonce; unparseable input → exit 0 (same as every guard today); extractstool_input.command.commit, secret, install, search, body(today'shooks.jsonorder).--guards=<name,...>(default: all five). Modules arerequired lazily, only for selected guards, so the Codex install, which never shipstrailhead-search-guard.js, registers--guards=commit,secret,install,body.parseModGuards(value, now)→ aSetof guard names to skip, or empty. Format<name>(,<name>)*@<epoch-ms>; skip only when the whole value is well-formed and0 <= now - stamp <= MOD_GUARDS_FRESH_MS(named constant,5000). Malformed, missing, future-dated or stale → empty set (run every guard). Unknown names are ignored.try/catch: a throw counts as that guard's today-posture (allow; for secret, the guard's own catch already produces the advisory insideevaluate), and the loop continues.block, write that guard'spayloadto stdout and exit 2 (later guards are not run, matching how Claude Code reports the first denying hook; an earlier guard's advisory is dropped in that case because the command does not run).stderrline(s) to stderr and a single stdout JSON whosehookSpecificOutput.additionalContextjoins the advisories (today only one guard can advise, so the output equals secret-guard's today); exit 0. Otherwise exit 0 with empty stdout.parseModGuards,MOD_GUARDS_FRESH_MS,GUARD_ORDER,runGuards(cmd, {guards, env, now})for unit tests.plugins/trailhead/hooks/hooks.json: the five Bash entries become onenode "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-bash-guards.js".bin/trailhead.js(npm/Claude):HOOK_FILESgainstrailhead-bash-guards.js(the five guard files stay: they are now required modules);installClauderegisters only the merged entry and migrates by stripping the five legacyPreToolUseregistrations first;uninstallClaudestrips the merged entry too; the Claude verify spec expects the merged registration.bin/lib/codex-projection.jscodexHookEntries: the four Bash entries become onetrailhead-bash-guards.js --guards=commit,secret,install,body; the Codex installer strips the four legacy registrations (migration); Codex verify spec updated; the projected Codex prose (line ~116) updated.README.md,README.it.md,site/src/content/docs/docs/hooks.md(andgetting-started.mdx/configuration.mdxif they count hooks): the guards stay eight distinct guards, but the five Bash guards now run in one process; registration counts and the guardrails badge corrected to be accurate;TRAILHEAD_MOD_GUARDSdocumented in one line in hooks.md. CHANGELOGUnreleasedentry.TDD (config
tdd: seams): tests first, at the seamsplugins/trailhead/hooks/trailhead-bash-guards.test.js(new, RED first):parseModGuards: fresh stamp → listed set; stale (now - stamp > MOD_GUARDS_FRESH_MS) → empty; future-dated → empty; malformed (commit,@123,commit@abc,,@1, empty) → empty; unknown name ignored.git commitalso carryingcd x && grep y z); a freshTRAILHEAD_MOD_GUARDS=commit@<now>lets a bad commit through while a stale one blocks it, and a fresh list skips exactly the listed guards (others still block);--guards=commit,secret,install,bodynever loads search-guard (acd x && grep y zpasses); one guard forced to throw (secret'sTRAILHEAD_SECRETGUARD_THROW=1seam, plus a test-only seamTRAILHEAD_BASHGUARDS_THROW=<guard>that makes the runner treat that guard's evaluate as throwing) still lets the next guard block; the secret advisory surfaces unchanged when nothing blocks.*-guard.test.jsreads its hook path fromprocess.env.TRAILHEAD_GUARD_HOOK || <own script>; the new suite re-runs each of those five test files withTRAILHEAD_GUARD_HOOK=<merged>and asserts exit 0. Where an existing end-to-end case's input would also trip a different guard in the merged process, that is surfaced, not papered over (report it; adjust only the input of that case if it is incidental).hooks.jsonhas exactly onePreToolUseentry with matcherBashand it holds exactly one command (the merged hook); none of the five legacy scripts is registered anywhere inhooks.json.bin/trailhead.test.js,bin/lib/install-verify.test.js,bin/lib/codex-projection.test.js,bin/lib/host-descriptor.test.js: update expectations; add a migration case (a settings.json / Codex hooks.json carrying the legacy registrations ends with only the merged one after install).Files touched
plugins/trailhead/hooks/trailhead-{commit,secret,install,search,body}-guard.js(+ their tests: hook-path override only), newplugins/trailhead/hooks/trailhead-bash-guards.js+ test,plugins/trailhead/hooks/hooks.json,bin/trailhead.js,bin/lib/codex-projection.js,bin/trailhead.test.js,bin/lib/install-verify.test.js,bin/lib/codex-projection.test.js,bin/lib/host-descriptor.test.js(as needed),README.md,README.it.md,site/src/content/docs/docs/hooks.md,CHANGELOG.md.Commits (atomic, each
Refs: #207)refactor: expose a pure evaluate() from each Bash guard(CLI behaviour unchanged, existing tests green)test: cover the merged Bash guard hook+feat: run the five Bash guards in one hook process(RED then GREEN; may be one commit if the RED state cannot be committed under the hook)feat: register the merged Bash guard hook on Claude Code and Codex(hooks.json, installer, projection, migration, tests)docs: describe the merged Bash guard hookVerification criteria
npm testgreen (every suite, plusclaude plugin validate/claude plugin test).TRAILHEAD_GUARD_HOOKpointing at the merged hook.hooks.jsonand the Codex projection register one Bash guard command (Claude: per Bash call 3 spawns = merged + secret-read + injection scanner; Codex: the same 3).TRAILHEAD_MOD_GUARDSskips exactly the listed guards; a stale or malformed one skips none.node bin/trailhead.js --codexinto a tempCODEX_HOME(and a Claude temp config dir) installs, registers only the merged Bash entry, and passes install-verify.Undeclared-coupling check
No other ticket is in progress on the map (no assignee on any open ticket besides this one). The mod's future guard porting (fog 11) reads
TRAILHEAD_MOD_GUARDSfrom this hook by design.ToRvaLDz commentedon Oct 11, 2026
PLAN
Goal. One
nodeprocess runs the five BashPreToolUseguards (commit, secret, install, search, body) instead of five, with identical verdicts, honouring a freshTRAILHEAD_MOD_GUARDSstamp. Integration branch:feat/mod.Failure posture (clarification). "Fails closed per guard as each guard does today" is read as same posture as today, per guard: today every one of the five allows on an internal error (exit 0), and secret-guard additionally emits its loud "could not scan" advisory once it has identified a tracker write. The merged hook preserves exactly that, guard by guard; one guard's throw never prevents the others from running.
Seams
evaluate(cmd, { env = process.env } = {})returning a verdict object, with noprocess.exitand no stdout writes (everyprocess.envread inside a guard, e.g. secret-guard'sTRAILHEAD_SECRETGUARD_THROWseam, goes through the passedenv, so the runner'senvand the unit tests'envare the runtime's; file reads such as--body-filestay realfsreads):{ kind: 'allow' }{ kind: 'block', payload }, wherepayloadis the exact JSON object the guard writes today ({decision:'block', code, reason}), and exit code 2 is implied{ kind: 'advise', payload, stderr }(secret-guard's could-not-scan advisory only)Each guard's existing internal try/catch (and its fail-open behaviour) moves inside
evaluate, soevaluateitself never throws in normal operation. The guard's ownrun(data)/require.mainCLI is rewritten as a thin wrapper overevaluatethat writespayloadand exits exactly as today, so each standalone script keeps byte-identical behaviour (its existing test file is the regression net).plugins/trailhead/hooks/trailhead-bash-guards.js:JSON.parseonce; unparseable input → exit 0 (same as every guard today); extractstool_input.command.commit, secret, install, search, body(today'shooks.jsonorder).--guards=<name,...>(default: all five). Modules arerequired lazily, only for selected guards, so the Codex install, which never shipstrailhead-search-guard.js, registers--guards=commit,secret,install,body. A missing, empty or malformed--guards, or one containing any unknown name (e.g. a typosecert), is invalid as a whole and falls back to all five (a config error never disables a guard silently). A selected guard whose module fails torequirewrites a one-line stderr warning naming it and is skipped (today's fail-open posture for that guard); the others still run.parseModGuards(value, now)→ aSetof guard names to skip, or empty. Format<name>(,<name>)*@<epoch-ms>; skip only when the whole value is well-formed and0 <= now - stamp <= MOD_GUARDS_FRESH_MS(named constant,5000). Malformed, missing, future-dated or stale → empty set (run every guard). Unknown names are ignored.try/catch: a throw counts as that guard's today-posture (allow; for secret, the guard's own catch already produces the advisory insideevaluate), and the loop continues.block, write that guard'spayloadto stdout and exit 2; later guards are not run. Any advisory an earlier guard produced keeps its stderr line (written before the block), so the observable stderr matches today's separate processes; only its stdoutadditionalContextis dropped, since stdout carries the block verdict and the command does not run. Tested explicitly.stderrline(s) to stderr and a single stdout JSON whosehookSpecificOutput.additionalContextjoins the advisories (today only one guard can advise, so the output equals secret-guard's today); exit 0. Otherwise exit 0 with empty stdout.parseModGuards,MOD_GUARDS_FRESH_MS,GUARD_ORDER,runGuards(cmd, {guards, env, now, evaluators})(evaluatorsoverrides the lazily required modules, for tests only) for unit tests.plugins/trailhead/hooks/hooks.json: the five Bash entries become onenode "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-bash-guards.js".bin/trailhead.js(npm/Claude): the install-verify model is split into shipped runtime files (every script copied, including the five guard modules, now onlyrequired) and registered entrypoints (the commands that must appear in settings.json / hooks.json: the merged hook, secret-read, mockup-link (Claude) / mockup-link-stop (Codex), the injection scanner, check-update).verifyInstallchecks every shipped file exists and every entrypoint is registered, and no longer requires a shipped module to be registered.HOOK_FILESgainstrailhead-bash-guards.js;installClauderegisters only the merged entry and migrates by stripping the five legacyPreToolUseregistrations first;uninstallClaudestrips the merged entry too; the Claude verify spec expects the merged registration.bin/lib/codex-projection.jscodexHookEntries: the four Bash entries become onetrailhead-bash-guards.js --guards=commit,secret,install,body; the Codex installer strips the legacy registrations (commit, secret, install, body, and the pre-Audit the Codex/multi-host install for inert hooks #169 search) before adding the merged one (migration);uninstallCodexstrips the merged entry and every legacy registration; Codex verify spec updated; the projected Codex prose (line ~116) updated. Install and uninstall stay idempotent (running either twice leaves the same file).README.md,README.it.md,site/src/content/docs/docs/hooks.mdsite/src/content/docs/docs/getting-started.mdx(it describes the Codex hooks), andconfiguration.mdxif it counts hooks: the guardrails stay the same set of distinct protections (the badge and "ships N hooks" keep counting guardrails, never shrunk because five share a process), and a sentence states that the five Bash guards run in one process; wherever registrations or spawns are counted, they are labelled as such;TRAILHEAD_MOD_GUARDSdocumented in one line in hooks.md. CHANGELOGUnreleasedentry.TDD (config
tdd: seams): tests first, at the seamsplugins/trailhead/hooks/trailhead-bash-guards.test.js(new, RED first):parseModGuards: fresh stamp → listed set; stale (now - stamp > MOD_GUARDS_FRESH_MS) → empty; future-dated → empty; malformed (commit,@123,commit@abc,,@1, empty) → empty; unknown name ignored.git commitalso carryingcd x && grep y z); a freshTRAILHEAD_MOD_GUARDS=commit@<now>lets a bad commit through while a stale one blocks it, and a fresh list skips exactly the listed guards (others still block);--guards=commit,secret,install,bodynever loads search-guard (acd x && grep y zpasses); one guard forced to throw (secret'sTRAILHEAD_SECRETGUARD_THROW=1seam, plus a unit test that injects a throwing fake evaluator throughrunGuards(cmd, { evaluators }); no new fault-injection env var ships) still lets the next guard block; the secret advisory surfaces unchanged when nothing blocks.*-guard.test.jsreads its hook path fromprocess.env.TRAILHEAD_GUARD_HOOK || <own script>; the new suite re-runs each of those five test files withTRAILHEAD_GUARD_HOOK=<merged>and asserts exit 0. Where an existing end-to-end case's input would also trip a different guard in the merged process, that is surfaced, not papered over (report it; adjust only the input of that case if it is incidental).parseModGuardsboundaries: age exactly0and exactlyMOD_GUARDS_FRESH_MS→ fresh,MOD_GUARDS_FRESH_MS + 1→ stale.--guardsempty / malformed / containing any unknown name (commit,secert) → all five run; a module that fails to load is skipped with a stderr warning while the others still block; an earlier secret advisory's stderr survives a later block.hooks.jsonhas exactly onePreToolUseentry with matcherBashand it holds exactly one command (the merged hook); none of the five legacy scripts is registered anywhere inhooks.json.bin/trailhead.test.js,bin/lib/install-verify.test.js,bin/lib/codex-projection.test.js,bin/lib/host-descriptor.test.js: update expectations; assert the Codex Bash entry's command carries exactly--guards=commit,secret,install,body; assert install-verify passes with the five modules shipped but unregistered and fails when a registered entrypoint is missing; add migration cases: a Claude settings.json and a Codex hooks.json carrying the legacy registrations end with only the merged one after install; uninstall (Claude and Codex) removes the merged entry and every legacy one; install twice and uninstall twice are idempotent.Files touched
plugins/trailhead/hooks/trailhead-{commit,secret,install,search,body}-guard.js(+ their tests: hook-path override only), newplugins/trailhead/hooks/trailhead-bash-guards.js+ test,plugins/trailhead/hooks/hooks.json,bin/trailhead.js,bin/lib/codex-projection.js,bin/trailhead.test.js,bin/lib/install-verify.test.js,bin/lib/codex-projection.test.js,bin/lib/host-descriptor.test.js(as needed),README.md,README.it.md,site/src/content/docs/docs/hooks.md,CHANGELOG.md.Commits (atomic, each
Refs: #207)refactor: expose a pure evaluate() from each Bash guard(CLI behaviour unchanged, existing tests green)test: cover the merged Bash guard hook+feat: run the five Bash guards in one hook process(RED then GREEN; may be one commit if the RED state cannot be committed under the hook)feat: register the merged Bash guard hook on Claude Code and Codex(hooks.json, installer, projection, migration, tests)docs: describe the merged Bash guard hookVerification criteria
npm testgreen (every suite, plusclaude plugin validate/claude plugin test).TRAILHEAD_GUARD_HOOKpointing at the merged hook.hooks.jsonand the Codex projection register one Bash guard command (Claude: per Bash call 3 spawns = merged + secret-read + injection scanner; Codex: the same 3).TRAILHEAD_MOD_GUARDSskips exactly the listed guards; a stale or malformed one skips none.node bin/trailhead.js --codexinto a tempCODEX_HOME(and a Claude temp config dir) installs, registers only the merged Bash entry, and passes install-verify.Undeclared-coupling check
No other ticket is in progress on the map (no assignee on any open ticket besides this one). The mod's future guard porting (fog 11) reads
TRAILHEAD_MOD_GUARDSfrom this hook by design.ToRvaLDz commentedon Oct 11, 2026
PLAN-REVIEW
Reviewer: Codex CLI (
codex exec, read-only), 3 rounds (2 configured, a 3rd at the maintainer's request to run until convergence). Converged: no blocking concern left.Round 1
evaluate(cmd)readprocess.envimplicitly, so tests and runtime could diverge. Resolved:evaluate(cmd, { env }); every guard env read goes through it.--guards. Resolved: falls back to all five.Round 2
--guards=commit,secertsilently disabledsecret. Resolved: any unknown name invalidates the whole list (all five run).verifyInstallrequired every shipped hook file to be registered. Resolved: shipped runtime files and registered entrypoints checked separately.--guardsargument; test freshness boundaries (0, 5000, 5001 ms). Both added.Round 3
runGuards.getting-started.mdxdescribes Codex hooks. Accepted: added to the docs list.ToRvaLDz commentedon Oct 11, 2026
status: clean
critical: 0 · warning: 0 · info: 1
VERIFY
Findings ledger
Tests.
npm test: 24 suites ok, 0 failed (includingclaude plugin validate/claude plugin teston the executor run; re-run here with the mod checks skipped, same result). Newtrailhead-bash-guards.test.js: 88 checks. The five existing guard suites pass unchanged against their own script and through the merged hook (TRAILHEAD_GUARD_HOOK): commit 15, secret 34, install 52, search 28, body 47.Code review (commits d4d4bf0, d2e86ce, fc0b92a, afe071b): clean. Checked byte-identical standalone guard output, first-block-wins with an earlier advisory's stderr kept,
parseModGuardsbounds (0 and 5000 ms fresh, 5001 stale; malformed, future and stale skip none),--guardsfallback to all five on empty/malformed/any unknown name, lazy loading without search-guard on Codex, install migration and idempotent uninstall on both hosts, install-verify split between shipped files and registered entrypoints, docs with no em-dashes.Goal-backward verification: status: met. Every claim evaluated and satisfied: one merged hook with unchanged verdicts; existing guard tests pass through it; one Bash
PreToolUseregistration (3 processes per Bash call on Claude Code and Codex); freshTRAILHEAD_MOD_GUARDSskips exactly the listed guards, stale/malformed none; per-guard failure isolation with today's posture; Codex projection (--guards=commit,secret,install,body) and npm installer carry it (temp-dir installs of both pass install-verify and block a bad command); READMEs, hooks.md, getting-started and CHANGELOG updated, guardrail badge still counting protections.Acceptance. No user-facing UI: proven by the automated suites and by running the installed copies from temp Claude and Codex installs.
Noted outside this ticket: the README badge reads
guardrails-7 hookswhile the text says eight hooks; it predates this ticket (1725acd) and was left untouched.ToRvaLDz commentedon Oct 11, 2026
Resolved. The five Bash
PreToolUseguards (commit, secret, install, search, body) now run in one node process,plugins/trailhead/hooks/trailhead-bash-guards.js.refactor: expose a pure evaluate() from each Bash guard: each guard exportsevaluate(cmd, { env }); its standalone CLI is a thin wrapper with byte-identical behaviour.feat: run the five Bash guards in one hook process: fixed order, first block wins, per-guard failure isolation,--guards=subset with an all-five fallback,TRAILHEAD_MOD_GUARDS=<names>@<epoch-ms>skip withMOD_GUARDS_FRESH_MS = 5000.feat: register the merged Bash guard hook on Claude Code and Codex: one Bash entry inhooks.json, the npm installer and the Codex projection (Codex with--guards=commit,secret,install,body), legacy registrations stripped on install and uninstall, install-verify checks shipped files and registered entrypoints separately.docs: describe the merged Bash guard hook: READMEs, hooks.md, getting-started, CHANGELOG.fix: surface a guard that fails to load as a session advisory(review I1).Spawns per Bash call: 7 -> 3. Verification and findings ledger: see the VERIFY comment above.
ToRvaLDz commentedon Oct 11, 2026
Follow-up: 80418b7
docs: note that a guard failing to load is reporteddocuments the I1 fix (45bc345) in README.md, README.it.md and the site's hooks page.refactor: expose a pure evaluate() from each Bash guard
feat: run the five Bash guards in one hook process
feat: register the merged Bash guard hook on Claude Code and Codex
docs: describe the merged Bash guard hook
fix: surface a guard that fails to load as a session advisory
docs: note that a guard failing to load is reported