From 6fc52270a103e17ec69095c5a4225ef8ddf7efc4 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:15:21 -0400 Subject: [PATCH 1/9] docs: declare Node.js requirement in remaining hook-launcher carriers Refs #3708 Add a README Requirements entry for node in instruction-placement, testing and source-control, and a setup check row that probes `command -v node` and reports a missing node as FAIL in instruction-placement and testing. No version floor. Co-Authored-By: Claude Opus 5.5 --- plugins/instruction-placement/README.md | 5 +++++ plugins/instruction-placement/skills/setup/SKILL.md | 6 +++++- plugins/source-control/README.md | 13 ++++++++----- plugins/testing/README.md | 5 +++++ plugins/testing/skills/setup/SKILL.md | 5 +++++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/plugins/instruction-placement/README.md b/plugins/instruction-placement/README.md index 67686bfce6..218b0b4cab 100644 --- a/plugins/instruction-placement/README.md +++ b/plugins/instruction-placement/README.md @@ -178,6 +178,11 @@ Conditions that should change this plugin, recorded so they are acted on rather | A second consumer needs the findings artifact | Promote its contract to a documented cross-plugin seam **before** that consumer ships, per the convention registry. The contract's stability guarantees and the three promotion prerequisites are already written down in [`context/findings-artifact.md`](context/findings-artifact.md); the owner doc is deliberately not written yet, because an interface with one implementation is a guess | | The glob engine needs semantics bash cannot express cleanly | Reconsider the hand-rolled expander; it exists to avoid `eval` on repository content | +## Requirements + +- **Node.js** on `PATH`. Every hook row launches through `node hooks/exec-bash.mjs`, which + finds Bash. A missing `node` is a hook launch error, not a skip notice. + ## Configuration The options below are personal, enable-time dials. The one setting that is policy rather than taste, diff --git a/plugins/instruction-placement/skills/setup/SKILL.md b/plugins/instruction-placement/skills/setup/SKILL.md index 4cf1a23b32..b2b395cdc4 100644 --- a/plugins/instruction-placement/skills/setup/SKILL.md +++ b/plugins/instruction-placement/skills/setup/SKILL.md @@ -61,7 +61,11 @@ degradation rather than implying full behavior. static gates work without them and only `verify-load.sh` degrades. Say "optional, absent" rather than "missing". An absent optional prerequisite is not a failure. -**4. Effective configuration.** Print each value with its **source**, so a surprising number is +**4. Node.js.** Run `command -v node` via Bash. FAIL when absent: every hook launch goes through +`node hooks/exec-bash.mjs`, so a missing `node` is a hook launch error, not a skip notice. A hook +cannot report its own missing launcher, so this probe runs through Bash, not a hook. + +**5. Effective configuration.** Print each value with its **source**, so a surprising number is traceable: | Setting | Default | Source to report | diff --git a/plugins/source-control/README.md b/plugins/source-control/README.md index fb5c230c97..cec01a70df 100644 --- a/plugins/source-control/README.md +++ b/plugins/source-control/README.md @@ -299,11 +299,6 @@ fails when a gate feeds its payload to a reader by here-string. ## Works in any repo -- **Node.js on PATH.** Every hook row runs through `node hooks/exec-bash.mjs`, and Claude Code's - native binary neither ships nor uses Node - ([setup](https://code.claude.com/docs/en/setup), fetched 2026-09-29), so without `node` the - hooks do not launch and the PR-linkage and worktree gates are not enforced. The setup `check` - reports whether `node` resolves. - **Self-contained.** Everything else runs on `git`, `gh` (authenticated), `jq`, and Bash scripts bundled under `${CLAUDE_PLUGIN_ROOT}` (Git Bash on native Windows); `unzip` is additionally required by the CI-log fetch path @@ -325,6 +320,14 @@ fails when a gate feeds its payload to a reader by here-string. rules. Defaults (Conventional Commits, squash merge) apply only when the project declares nothing. +## Requirements + +- **Node.js** on `PATH`. Every hook row launches through `node hooks/exec-bash.mjs`, and Claude + Code's native binary neither ships nor uses Node + ([setup](https://code.claude.com/docs/en/setup), fetched 2026-09-29). Without `node` the hooks do + not launch and the PR-linkage and worktree gates are not enforced. The setup `check` reports + whether `node` resolves. + ## Install ```shell diff --git a/plugins/testing/README.md b/plugins/testing/README.md index ce4464e576..f6dafc0695 100644 --- a/plugins/testing/README.md +++ b/plugins/testing/README.md @@ -32,6 +32,11 @@ skills, one concern: proving behavior with tests. smoke-test playbook, and diagnosis loops ship inside the plugin and are referenced via `${CLAUDE_PLUGIN_ROOT}`. +## Requirements + +- **Node.js** on `PATH`. Every hook row launches through `node hooks/exec-bash.mjs`, which + finds Bash. A missing `node` is a hook launch error, not a skip notice. + ## Install ```shell diff --git a/plugins/testing/skills/setup/SKILL.md b/plugins/testing/skills/setup/SKILL.md index 7b85ef759a..5ce74e5499 100644 --- a/plugins/testing/skills/setup/SKILL.md +++ b/plugins/testing/skills/setup/SKILL.md @@ -92,6 +92,11 @@ It exits 0 with no finding, 1 when a test-lint rule is missing, 2 when a layer d `--enabled`, the entry says so on stderr and exits 0. Show it; the user merges it into their settings. +Then probe the hook launcher, which the script does not: run `command -v node` via Bash and report +`node` as a FAIL row when it is absent. Every hook row launches through `node hooks/exec-bash.mjs`, +so a missing `node` is a hook launch error, not a skip notice, and a hook cannot report its own +missing launcher. + ## `apply` 1. Run `check` and summarize the effective config before proposing a change. Nothing already From 277414da885e9f47dd0692ab5dc135cdd5c32f20 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:40:46 -0400 Subject: [PATCH 2/9] feat(guardrails,disk-hygiene): warn at session start when node is missing Refs #3708 Add a shell-form SessionStart row to guardrails and disk-hygiene that runs `command -v node` and, when node is absent, prints a JSON notice: systemMessage for the user and additionalContext for the model, both saying the plugin's guards cannot launch and enforce nothing. It needs no node, exits 0, and is silent when node is present. Cover both rows in the plugin tests. Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/hooks/hooks.json | 13 +++++++++++ .../hooks/run-python-hook.test.sh | 21 +++++++++++++++++ plugins/guardrails/hooks/exec-bash.test.sh | 23 ++++++++++++++++++- plugins/guardrails/hooks/hooks.json | 13 +++++++++++ scripts/check-killswitch-hoist.sh | 5 ++-- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index 05ac50f878..67663fb811 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -1,6 +1,19 @@ { "description": "Blocks a destructive disk-hygiene delete that falls outside its authorized roots, detects the guard silently failing to launch, and hands /disk-hygiene:clean the guard's interpreter and data root when it expands.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "shell": "bash", + "command": "command -v node >/dev/null 2>&1 || printf '%s\\n' '{\"systemMessage\":\"disk-hygiene: node is not on PATH, so its destructive-delete guard cannot launch and enforces nothing. Install Node.js and restart Claude Code.\",\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"WARNING: node is not on PATH, so the destructive-delete guard of the disk-hygiene plugin cannot launch and enforces nothing. Tell the user.\"}}'", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", diff --git a/plugins/disk-hygiene/hooks/run-python-hook.test.sh b/plugins/disk-hygiene/hooks/run-python-hook.test.sh index 3b903348c1..f0610cce8e 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.test.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.test.sh @@ -871,4 +871,25 @@ assert_eq "the Stop row skips a session that launched no guard" "1" \ select(([.command] + ((.args // []) | map(tostring)) | join(" ")) | contains("--skip-unless-marker guard-launch-monitor"))] | length' \ "$HOOKS_JSON")" +# --- the SessionStart node notice is shell form, needs no node, and never blocks --- +notice="$(jq -c '.hooks.SessionStart[].hooks[]' "$HOOKS_JSON")" +assert_eq "one SessionStart row" "1" "$(jq -s 'length' <<<"$notice")" +assert_eq "the SessionStart row is shell-form bash with no args" "true" \ + "$(jq '.type == "command" and .shell == "bash" and (has("args") | not) and (.command | startswith("node") | not)' <<<"$notice")" +notice_cmd="$(jq -r '.command' <<<"$notice")" +NONODE_DIR="$(mktemp -d)" +trap 'rm -rf "$FAKE_BIN" "$PROBE_DIR" "$PY_BIN" "$NONODE_DIR"' EXIT +ln -s "$(command -v bash)" "$NONODE_DIR/bash" +notice_rc=0 +notice_out="$(PATH="$NONODE_DIR" "$NONODE_DIR/bash" -c "$notice_cmd" 2>&1)" || notice_rc=$? +assert_eq "the notice row exits 0 without node" "0" "$notice_rc" +assert_eq "the notice tells the user the guard cannot launch" "true" \ + "$(jq '.systemMessage | contains("cannot launch and enforces nothing")' <<<"$notice_out")" +assert_eq "the notice tells the model" "true" \ + "$(jq '.hookSpecificOutput | .hookEventName == "SessionStart" and (.additionalContext | contains("enforces nothing"))' <<<"$notice_out")" +notice_rc=0 +notice_out="$(bash -c "$notice_cmd" 2>&1)" || notice_rc=$? +assert_eq "the notice row exits 0 with node" "0" "$notice_rc" +assert_eq "the notice row is silent with node" "" "$notice_out" + pass "all run-python-hook contract checks" diff --git a/plugins/guardrails/hooks/exec-bash.test.sh b/plugins/guardrails/hooks/exec-bash.test.sh index 9d6e88e2ff..56832af8f6 100755 --- a/plugins/guardrails/hooks/exec-bash.test.sh +++ b/plugins/guardrails/hooks/exec-bash.test.sh @@ -56,7 +56,7 @@ grep -q 'usage:' <<<"$err" || fail "missing script should name usage: $err" node "$HOOK_DIR/exec-bash.resolver.test.mjs" || fail "Windows resolver rejected Git Bash or accepted the relay" # --- hooks.json: every row is node exec form -------------------------------- -rows=$(jq -c '.hooks[][] | .hooks[]' "$HOOKS_JSON") +rows=$(jq -c '.hooks | del(.SessionStart) | .[][] | .hooks[]' "$HOOKS_JSON") dispatcher=0 workflow=0 while IFS= read -r row; do @@ -81,4 +81,25 @@ done <<<"$rows" [[ "$dispatcher" -ge 8 ]] || fail "expected the dispatcher rows, found $dispatcher" [[ "$workflow" -eq 1 ]] || fail "expected one workflow row, found $workflow" +# --- SessionStart node notice: shell form, needs no node, never blocks ------- +notice=$(jq -c '.hooks.SessionStart[].hooks[]' "$HOOKS_JSON") +[[ "$(jq -s 'length' <<<"$notice")" -eq 1 ]] || fail "expected one SessionStart row" +jq -e '.type == "command" and .shell == "bash" and (has("args") | not) and (.command | contains("node") and (startswith("node") | not))' \ + <<<"$notice" >/dev/null || fail "SessionStart row is not shell-form bash: $notice" +notice_cmd=$(jq -r '.command' <<<"$notice") +bash_path=$(command -v bash) +nonode_dir="$TEST_TMPDIR/nonode" +mkdir -p "$nonode_dir" +ln -s "$bash_path" "$nonode_dir/bash" +rc=0 +out=$(PATH="$nonode_dir" "$bash_path" -c "$notice_cmd" 2>&1) || rc=$? +[[ "$rc" -eq 0 ]] || fail "notice row exited $rc without node" +jq -e '.systemMessage | contains("guards cannot launch and enforce nothing")' <<<"$out" >/dev/null || + fail "user notice missing without node: $out" +jq -e '.hookSpecificOutput | .hookEventName == "SessionStart" and (.additionalContext | contains("enforce nothing"))' <<<"$out" >/dev/null || + fail "model context missing without node: $out" +rc=0 +out=$(bash -c "$notice_cmd" 2>&1) || rc=$? +[[ "$rc" -eq 0 && -z "$out" ]] || fail "notice row is not silent with node (rc=$rc): $out" + echo "exec-bash: stdin, exit 2, Git Bash resolution, and hooks.json shape passed ($dispatcher dispatcher rows)." diff --git a/plugins/guardrails/hooks/hooks.json b/plugins/guardrails/hooks/hooks.json index 3666751e6d..fec177eedd 100644 --- a/plugins/guardrails/hooks/hooks.json +++ b/plugins/guardrails/hooks/hooks.json @@ -1,6 +1,19 @@ { "description": "Runs the safety guards: secrets, hardcoded paths and Windows drive-root temp writes on Write and Edit; git hook-bypass, irreversible git operations, shell write-gate bypasses and commit conventions on Bash and PowerShell; recursive deletes aimed at a filesystem root, an empty or bare-variable operand, or a target outside the session's git tree, temp directories and scratchpad on Bash and PowerShell; and verifies CLI flags, skill references and cited paths after a write.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "shell": "bash", + "command": "command -v node >/dev/null 2>&1 || printf '%s\\n' '{\"systemMessage\":\"guardrails: node is not on PATH, so its guards cannot launch and enforce nothing. Install Node.js and restart Claude Code.\",\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"WARNING: node is not on PATH, so the guards of the guardrails plugin cannot launch and enforce nothing. Tell the user.\"}}'", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PreToolUse": [ { "matcher": "Write|Edit|MultiEdit|NotebookEdit", diff --git a/scripts/check-killswitch-hoist.sh b/scripts/check-killswitch-hoist.sh index 2daf013230..914dabc7ca 100755 --- a/scripts/check-killswitch-hoist.sh +++ b/scripts/check-killswitch-hoist.sh @@ -55,8 +55,9 @@ # `plugins/*/hooks/hooks.json` and implemented as shell scripts. Rule 2 covers # every event in the same files, with the same token walk. A hook implemented in another language sources no shell library, has no # `source` line to sit above, and is reported as NOT SCANNED rather than passed -# silently — today that is disk-hygiene's destructive_guard.py and -# context-budget's node handler. +# silently — today that is disk-hygiene's destructive_guard.py, context-budget's +# node handler, and the inline shell-form node-notice rows in guardrails and +# disk-hygiene. # # Exit 0 clean, 1 findings, 2 environment or usage; findings on stderr. That is # the whole family's contract, stated once in README.md, "The check-script From a3020f325ffe73f5ba9ff8a2ded00f8153ae803f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:46:40 -0400 Subject: [PATCH 3/9] docs(guardrails,disk-hygiene): document the SessionStart node notice row Refs #3708 Record the shell-form SessionStart row in each README with the hooks-page basis for why it prints JSON: systemMessage reaches the user and additionalContext reaches the model, while plain stdout reaches only the model and exit-2 stderr only the user. Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/README.md | 12 +++++++++--- plugins/guardrails/README.md | 10 ++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index 013b778db8..4e207e85bf 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -56,9 +56,15 @@ at preview. Backups remain the recovery boundary for user data. ## Requirements and platform support -- Node.js on `PATH`. Every hook registration runs `node hooks/exec-bash.mjs`, and Claude Code's +- Node.js on `PATH`. Every guard and detector registration runs `node hooks/exec-bash.mjs`, and Claude Code's native binary neither ships nor uses Node ([Setup](https://code.claude.com/docs/en/setup)), so - without `node` no hook launches and no guard is enforced. + without `node` no hook launches and no guard is enforced. A `SessionStart` row in shell form + (`"shell": "bash"`, no `args`) runs `command -v node` and needs no node itself. When node is + absent it exits 0 with JSON: `systemMessage` shows the user a warning and `additionalContext` + tells the model that the destructive-delete guard cannot launch and enforces nothing. It prints + nothing when node is present. Basis: https://code.claude.com/docs/en/hooks, "SessionStart" + (plain stdout reaches Claude only, and exit-2 stderr reaches the user only) and "JSON output" + (`systemMessage` is a warning shown to the user). - Bash that `hooks/exec-bash.mjs` can find. The file's header comment lists the candidates in order for each platform: on Windows, `CLAUDE_CODE_GIT_BASH_PATH`, the Git for Windows install roots, then `PATH`; elsewhere, `PATH` first. The WSL relay (`System32\bash.exe`) is never used. @@ -154,7 +160,7 @@ the call itself, the same way the guard's watchdog answers "could not decide": | No Python resolves: skill-scoped belt, any Bash or PowerShell call | Denied (exit 2), reason on stderr | | No Python resolves: plugin-level gate, command naming `hygiene.py` (or an empty payload) | Denied (exit 2), reason on stderr | | No Python resolves: plugin-level gate, any other command its `if` rows let through | **Proceeds unchecked**, with a `systemMessage` and `additionalContext` notice once per session | -| `node` missing or no bash found: every hook | **Proceeds unchecked.** The hook fails to launch, which is non-blocking: the user sees a hook error notice, the guard is not enforced, and the model is not told. With no bash, the notice's first line is the launcher's `exec-bash: