Proposal (rationale)
The instructions module registers instructions-shell-backticks as a gate with
trigger = "pre-tool-use" and matcher = "Bash|PowerShell", and its table declares refuse patterns
with self-tests in both directions. F-054: a fresh tracked consumer receives the
registry entry and nothing else — no Claude hook configuration, no engine behind the shell-safety
name, and no degradation row for a harness that cannot run hooks. The README’s
modules/README.md says a hook “is emitted into harnesses that support it and reported as degraded
for those that do not”; neither half happens. The protection rift-forge paid for nine times is a
declaration nobody dispatches.
Decision
accepted — 2026-09-06 under WI-085. Implement the
engine centrally and dispatch it through the consumer’s pinned launcher; record the dispatch
contract as ADR-0010 (written with the implementation, on this item’s branch) so that ADR-0002’s
no-scripts boundary is resolved rather than quietly crossed.
Plan
Requirements
- A
shell-safetyengine evaluates a command string against the table’srefusepatterns; its four shipped fixtures execute undergates-self-tests-both-directions. - A
rungs hook <gate-id>command reads the harness payload from stdin, evaluates the named hook gate, exits 2 with the table’s message when a pattern matches, 0 otherwise, and never blocks on its own failure to read input or on an unknown tool. - Installing or upgrading
instructionsinto a repo whose harness matrix includesclaudemerges onePreToolUseentry into.claude/settings.jsonwhose command is the pinned launcher (node .ai/rungs.mjs hook instructions-shell-backticks), preserving every existing key and hook, and adding nothing on a repeat. - A harness that cannot express hooks (
copilot,cursor,agents-md) gets a row in.ai/render-report.mdsaying the protection is Claude-only, per ADR-0001’s degradation table. - The ejected runner keeps
hook <gate-id>working from its frozen table, so the settings entry does not point at a command ejection removed (WI-077’s retained surface widens tocheckandhook, recorded there).
Impacts
src/engines.ts(engine map), a new hook engine module,src/cli.ts(hookcommand and the install/upgrade phase),src/help.ts(command count moves 14 → 15, gated bydocs-version-claimsandmodule-commands-exist),src/render.ts(report rows),src/selftest.ts(hook fixtures execute),src/lifecycle.ts(upgrade registers hooks; eject keepshook),modules/instructions/module.toml(version 1.2.0 → 1.3.0), the site claims snapshot.- Cost: one launcher invocation per matching tool call. The launcher runs the pinned package
through
npm exec, which is slower than a local script; measured and recorded in Review rather than assumed.
Approach
Engine and CLI first, then the settings merge, then the render-report row, then eject. The settings
merge identifies its own entry by the exact command string, because JSON has no comment markers for
a managed block. The consumer’s file is never rewritten wholesale: parse, add the missing entry,
serialise with the file’s indentation. A malformed settings.json is refused with a message, not
overwritten.
Acceptance criteria / tests
runSelfTestson the fourinstructions-shell-backticksfixtures reportsok, ok, ok, ok, andrungs checkhere no longer lists them among the unrun.- A fresh tracked consumer built from the packed candidate carries the settings entry; piping the
motivating payload (
node -e "…\…`“) into the installed hook command exits 2 with the table's message;node scripts/edit.mjs,cat <<‘EOF’ > a.md` and a non-Bash tool exit 0. - Re-running
init/add/upgrade --applyleaves.claude/settings.jsonbyte-identical, and a pre-existing unrelated hook in that file survives the first install. - With
--copilotor a non-Claude matrix, the render report names the hook as not emitted for that harness; nothing is written under.claude/. - After
eject,node .ai/rungs.mjs hook instructions-shell-backticksstill rejects the motivating command and accepts the safe forms, with the package prefix removed. - Focused tests, full
npm test, all registered gates,rungs modules, and the site build pass.
Out of scope
- Copying this repository’s private
.claude/hooks/no-inline-interpreter-scripts.mjsinto consumers; it is a different rule with a different scope and stays local. - Hook support for harnesses that have no hook mechanism; a new dispatch target is a change to ADR-0001’s matrix.
- Timing optimisation of the launcher path beyond measuring and recording it.
Execution
Executed 2026-09-06 on feature/WI-086-consumer-hook-delivery, cut from main f7db44e (WI-077
landed). ADR-0010 records the
dispatch contract first; the code follows it.
src/hook-engine.ts—evaluateShellSafety(table, command)over the table’srefusepatterns; a pattern that does not compile is a finding, not a skip.ENGINES['shell-safety']exists so the name is implemented, and answers a file scan with a finding rather than examining nothing.src/hooks.ts—hookVerdict(2 blocks, 0 permits, 1 never blocks; the payload is read only after the gate is known to be a runnable hook),registerHooks(merge into.claude/settings.jsonkeyed by the exact command, preserving indentation and newline convention, adding nothing on a repeat),preflightHooks(a malformed settings file refuses the whole install before any write),hookRenderEntries(one report row per hook per harness, degraded where the harness has no hook mechanism).src/cli.ts— thehookcommand; a third install phase after gates; the render report inadd,renderand the hook registration inupgrade --apply.src/help.tsgains the command, so the count is fifteen anddocs/roadmap.mdsays so.src/selftest.ts— a hook engine’s fixture is its input command; the four shipped fixtures now execute (rungs checkhere: 45 unrun → 41).src/lifecycle.ts,src/ejected-runner.ts— the ejected launcher retainshook, the runner evaluates it from the frozen table, and the direct gate path refuses a hook with exit 2.modules/instructions1.2.0 → 1.3.0; the site claims snapshot regenerated.
Deviations, with reasons.
- The plan measured the launcher’s latency in Review; it is recorded there from actual runs.
- A harness matrix without Claude is reachable from the CLI only through an install record
(
--copilotadds Copilot beside Claude), so the degradation-only path is proven throughregisterHooks/hookRenderEntrieswith an explicit matrix rather than through a CLI flag.
Found on the way, recorded rather than folded in. The smoke run ejected after a
rungs add instructions on the initialised consumer and was refused with “launcher edited”. The
cause is not the hook: add after init rewrites .ai/rungs.toml with only that run’s modules
and drops every existing hash — F-061, high, pre-existing, out of this item’s
scope.
Review
Against each acceptance criterion, 2026-09-06, Windows 11, Node v22.22.3, npm 10.9.8:
- Fixtures execute.
runSelfTestson the fourinstructions-shell-backticksfixtures reportsok, ok, ok, ok(test/core.test.js, “the shell-safety hook engine…”);node src/cli.ts checkhere reports 41 unrun fixtures where it reported 45. - Consumer dispatch. The packed journey asserts the settings entry
{ matcher: "Bash|PowerShell", hooks: [{ type: "command", command: "node .ai/rungs.mjs hook instructions-shell-backticks" }] }after a tracked init, then dispatches through the installed CLI: the motivating command exits 2 with “Blocked by instructions-shell-backticks”, the quoted heredoc and a script file exit 0. The core regression adds a non-Bash tool (0), unreadable input (0), an unknown gate (1) and a runner gate (1). - Idempotence and preservation. A consumer settings file with its own permission and its own
Edithook, indented with four spaces, keeps both, keeps its indentation, gains one entry, and is byte-identical after a repeatregisterHooks, a dry run, and (in the smoke run)upgrade --apply. - Degradation. The render report carries
hook … | agents-md | **not emitted** | hook not emitted: agents-md has no hook mechanism…; acopilot, agents-mdmatrix yields two degradation rows, no target, and writes nothing under.claude/. A matrix without Claude is reachable from the CLI only via an install record, so this is proven through the functions with an explicit matrix. - After ejection. Through the ejected forwarder, with the package prefix renamed away: motivating
command 2, safe forms 0; the direct runner refuses the hook gate with exit 2; the registry keeps
kind = "declared"for it. Asserted in both the core regression and the packed journey. - Suite and gates. Serial
node --test --test-concurrency=1 test/*.test.js: 146 tests, 142 pass, 1 fail, 3 platform skips, 140 s. The one failure was a stale exclusion ofshell-safetyfrom the engine-map/selector equality, written when the engine did not exist; the exclusion is removed and that test passes alone.node src/cli.ts check: 30 pass.node src/cli.ts modules: audit clean,instructions1.3.0. Site claims regenerated.
Cost, measured (.scratch/hook-latency.mjs, three runs each, warm machine): the pinned
launcher path — npm exec of the published @rungs/cli@0.4.0 — takes 1,077 / 1,056 / 1,235 ms
per tool call (that version has no hook command, so it exits 1; the time is the launcher’s); the
CLI invoked directly takes 93 / 98 / 87 ms; the ejected forwarder 99 / 110 / 97 ms. So a consumer
pays roughly one second per matching tool call for the pinned path, which is ADR-0010’s revisit
trigger 1 sitting close to its threshold. Recorded here, not hidden; an inlined-script mode remains
the named alternative if that second proves too much.
Pending. The exact-SHA OS/Node matrix has not run: the branch is not pushed.