* enhance(#3911): give hooks an exit seam that needs no build ADR-3889 Phase 7 foundation. The 19 shipped enforcement hooks hold 91 of the epic's 128 terminators and cannot reach `terminateNow` today. The obvious route — requiring `gsd-core/bin/lib/cli-exit.cjs`, as gsd-agent-isolation-guard.js already does for two other modules — is rejected. That precedent carries its own warning (#3582): those files are tsc output, gitignored and absent on a raw plugin-marketplace or git-clone install, so the hook must first call ensureRuntimeBuild() to self-heal. Making the module a hook needs IN ORDER TO TERMINATE depend on a build inverts the dependency, and its failure mode is precisely the fail-open this phase exists to remove: a guard that cannot terminate cannot deny. `lint-hooks-runtime-build-seam` already encodes that concern, and Design B would have had to add an ensureRuntimeBuild() call to all 19 hooks to satisfy it. So `hooks/lib/` becomes a third emit location for cli-exit and a fifth for the registry, preserving the invariant `src/cli-exit.cts`'s own header states: it imports nothing but node:fs and its sibling registry, and the generator dual-emits that sibling alongside each copy so a relative require resolves next to whichever copy loaded it. Shipping needed no change — build-hooks.js already declares HOOKS_SUBDIRS_TO_COPY = ['lib']. Proven, not asserted: the two files are copied into an otherwise-empty tmpdir and a child process requires them and terminates — PASS exits 0, HOOK_DENY exits 2 with the payload on both stdout and stderr. That test fails the moment the hooks copy gains a require reaching outside hooks/lib/. Also fixed inline: the registry's fifth target let any `--write` test overwrite the real committed hooks/lib/exit-code-registry.js, because the test helper derived only three of the other output paths. It now redirects all five, and a regression test asserts every committed artifact is byte-identical after a redirected write. Install-tree goldens pick up the two new shipped paths across 11 runtimes — insertions only, no removals. lint:ci was green while they were stale, so this was found by regenerating rather than by a gate. Verification runs on the remote runner. Refs #3911 * enhance(#3911): declare a crash policy, and migrate the write guard Adds `hooks/lib/hook-exit.js` — the hook-facing vocabulary over `terminateNow`, hand-written because the cli-exit copy beside it is generated: allow(payload) exit 0 deny(payload, stderr?) exit 2 crash(onCrash, payload) whichever the hook DECLARED `crash()` takes the policy as a required argument with no default, which is the whole mechanism: fail-open by accident stops being expressible. A hook must name ALLOW or DENY at the call site, and an unrecognized value terminates INTERNAL rather than guessing. Fail-open stays legal; fail-open by omission does not. `gsd-write-guard.js` is the first hook migrated, all 12 sites, and it exposed a gap in the seam. `terminateNow`'s doc comment justified its fd-2 write by citing this hook's `emitBlock` — but modeled it as sending the same bytes to both streams, when `emitBlock` actually sends full JSON to stdout and only the bare `reason` string to stderr, because Kimi's hook bus feeds stderr verbatim back to the model. Migrating as written would have turned a readable sentence into a JSON blob for Kimi-backed agents. #3911 requires both "all 19 hooks terminate through terminateNow" and "no hook's effective default changes". Those are jointly satisfiable only by teaching the seam to carry a distinct stderr payload, so `terminateNow` gains an optional third argument: omitted, behavior is byte-for-byte what it was; a string is written raw, which is exactly the Kimi case. The doc comment's inaccurate claim about emitBlock is corrected in place. Proven rather than asserted: the pre-migration file is reconstructed from HEAD and driven with the same catastrophic-shrink payload as the migrated one — exit code, stdout and stderr all byte-identical. Verification runs on the remote runner. Refs #3911 * enhance(#3911): all 19 hooks terminate through the seam Migrates the remaining 18 enforcement hooks onto allow/deny/crash. An AST walk now reports zero `process.exit(` call sites across every `hooks/*.js` — down from the 91 the census measured. Each hook with an outer catch declares its policy once, at module top, with the reason that policy is right for that specific guard: a read guard that cannot scan must not block the read; a statusline that renders every prompt must degrade rather than crash; an injection scanner must not retroactively block a result already returned. Those sentences are the deliverable — they are what turns fail-open-by-accident into fail-open-on-purpose. No hook's effective default changed. Wiring exposed two defects, both fixed here rather than noted. A SECOND stdout/stderr-splitting site turned up in `gsd-workflow-guard.js`'s `emitForceAddBlock`, matching the pattern already known from the write guard — full JSON to stdout, bare reason to stderr for the Kimi bus. It uses the `stderrPayload` argument added in the previous commit, which is now carrying its second real caller rather than one special case. More seriously, `terminateNow` emitted both streams inside ONE try, so a payload that failed to serialize aborted before the stderr write ever ran. The two windsurf guards write nothing to stdout on a block and only a reason string to stderr, so `deny(undefined, reason)` exited 2 with EMPTY stderr — a deny that silently loses its reason, which is the exact "fails with success" class this epic exists to close. The streams are now emitted independently, each with its own guard, and `undefined` means "nothing to write for this stream" rather than an error. Regression tests inject a throwing write on one fd and assert the other still receives its payload; they fail against the single-try version. Byte-identity was proven per hook, not assumed: each pre-change file is reconstructed from HEAD and driven side by side with the migrated one across its normal path, its deny path, malformed stdin and empty stdin — exit code, stdout and stderr compared. Verification runs on the remote runner. Refs #3911 * enhance(#3911): harden the three shell hooks, and pin every hook's policy `gsd-phase-boundary.sh`, `gsd-session-state.sh` and `gsd-validate-commit.sh` gain `set -euo pipefail`. The expected hazard did not materialize, and that is worth recording: every intentionally-non-zero command in all three is already the condition of an `if`/`elif`, which `set -e` never fires on, and none of them reads a possibly-unset variable or pipes through a grep that may legitimately match nothing. No `|| true` guards were needed. Each hook was still checked command-by-command before the flags went in rather than after. Twenty-one before/after cases across the three hooks — disabled and enabled, planning and non-planning, missing STATE.md, malformed JSON, the Kimi payload shape, quoted and unquoted `-m`, valid and over-long Conventional Commits — all match on exit code, stdout and stderr. The hardening is shown to actually fire, not merely added: with a stubbed `node` that fails at the JSON-emit step, phase-boundary and session-state go from silently exiting 0 with empty stdout to failing visibly with the error surfaced. No such case could be constructed for `gsd-validate-commit.sh`, whose every statement already sits inside an if-condition — recorded as unproven rather than claimed. `tests/hooks-crash-policy.test.cjs` adds the per-hook coverage the issue asks for, table-driven over all 19 hooks rather than 76 hand-written cases: normal allow, deny where a deny path exists, crash-honors-the-declared-policy, and an unclosed-stdin case — the one `process.exitCode` structurally cannot serve. The deny assertions encode each hook's ACTUAL stream split rather than a uniform shape, since four of the six deliberately differ. A drift guard enumerates `hooks/*.js` and fails if a terminating hook is ever added without a row. Writing those tests surfaced two hooks that emit a block decision in their JSON body and exit 0. Both were checked rather than assumed, and neither is a fails-with-success: `gsd-read-injection-scanner.js` is PostToolUse, where the tool has already run and exit 2 has no meaning, and `gsd-cursor-subagent-start.js` follows Cursor's JSON-body protocol. They are deliberately left alone — a mechanical sweep to `deny()` would have broken exactly these two. Verification runs on the remote runner. Refs #3911 * fix(#3838): the commit validator says when it could not validate #3911 claims to subsume #3838. Measurement said otherwise, so this closes it for real rather than by assertion. `set -euo pipefail`, added earlier on this branch, does NOT fix #3838: bash exempts a command used as an `if` condition from `set -e`, and all three of the hook's swallow-and-pass sites are exactly that shape. Verified against the hardened hook with a node shim that fails only the classifier call — a non-conforming commit still exited 0 with empty stdout AND empty stderr, indistinguishable from "your commit conforms". That is the defect verbatim. All three sites named in #3838 now capture the real exit status instead of consuming it as a condition, and each distinguishes its genuine negative from "could not run": - the classifier: 0 = is a git commit, 1 = genuinely not one, anything else = could not classify. Its `node -e` now wraps the require and the call in try/catch and exits 3 on a throw, so a broken require chain can never be mistaken for `isGitSubcommand` legitimately returning false — which is the arm that matters, since `token-scanner.cjs` is a gitignored build artifact and a fresh checkout lands there. - the opt-in config read and the JSON command extraction get the same treatment. On "could not run" the hook emits a diagnostic to stderr naming which check failed and why, then exits 0. The issue confirms this is safe — it is a PreToolUse hook, so stderr does not disturb the JSON protocol — and ranks it the smallest sufficient fix. The gate still fails open, but it can no longer do so silently, which is the whole complaint: a validator that disables itself quietly costs more than one that is absent, because it is trusted. Both controls are unchanged and pinned by tests: a conforming commit still passes silently, a non-conforming one still exits 2 with its existing block payload. The defect test asserts stderr is non-empty and names the failure; it fails against the pre-fix hook. Verification runs on the remote runner. Refs #3911, #3838 * docs(#3911): document the hook crash-policy contract Reference and Explanation via a new docs/features fragment (FEATURES.md is generated from it), INVENTORY rows for the three new hooks/lib files, and an ARCHITECTURE note on the hooks section. How-To: docs/how-to/declare-a-hook-crash-policy.md, indexed from docs/README.md — a hook author now has to choose and declare a crash policy, which is more than one step and crosses into which harness protocol their hook speaks. It covers allow/deny/crash, writing an ON_CRASH reason that is actually useful, when a deny needs a distinct stderr payload, the two hooks whose harness reads a JSON-body decision and must NOT use deny(), and what to do when a check cannot run at all — with #3838 as the worked example. Refs #3911 * test(#3911): prove the seam actually ships, and stop hand-rolling temp cleanup Two review findings. The acceptance criterion 'hooks/dist/** stays in parity via the build seam (lint:hooks-runtime-build-seam)' was misstated and unmet: that lint checks something else — that a hook requiring a compiled gsd-core/bin/lib module also calls ensureRuntimeBuild(). Nothing exercised that the three new hooks/lib files reach hooks/dist/lib at all. That gap is not theoretical: #770 is a recorded ship-blocking bug where a new hook never shipped because a copy list missed it. The suite now builds dist through the repo's own ensureBuiltHooks(), byte-compares each shipped copy against its source, and spawns a child that requires the SHIPPED dist copy and denies — which is what catches a copy that exists but cannot resolve its sibling registry. gsd-validate-commit.sh hand-duplicated mktemp/run/rm three times; one idempotent trap on EXIT replaces them, guarded so cleanup cannot alter the exit status. Behavior-neutral across five cases, with temp-file counts taken before and after each run. Refs #3911 * fix(#3911): stage transitive hook lib requires, not just one level The remote run returned 7 failures across 3 real causes. The important one is a PRODUCTION bug this phase exposed rather than caused. `writeCursorHooksJson` scanned each hook script for `./lib/X` requires exactly one level deep and never re-scanned the lib files it staged for their own sibling requires. Nothing had a transitive lib dependency before, so the gap was invisible. Adding hook-exit.js -> cli-exit.js -> exit-code-registry.js made real Cursor installs ship a bundle that dies at require time with MODULE_NOT_FOUND. It now walks to a fixed point, and a real installed Cursor hook runs to completion. The staging harness in shared-hooks-dir-resolution hand-copied its fixture, so the injection scanner crashed at require time and its exit-1 was being read as a policy decision. Migrated to copyScriptWithDeps, which walks the require graph — the repo's recorded rule for this class, since adding another copyFileSync keeps it alive for the next person. The missing-lib-source test in cursor-hook-workspace-roots hardcoded which lib file it expected to be named in the abort message; the same throw now fires for a different file first. Its assertion is unchanged in substance — staging still must abort rather than ship a broken hook — only the name is no longer pinned. The last one was my own test asserting an uppercase reason code. Measured against origin/next: the pre-change hook emits the same lowercase 'config_unreadable', so the test was wrong, not the migration. Corrected to the real value rather than making the code match the test. Verification runs on the remote runner. Refs #3911 * chore(#3911): regenerate the cursor install-tree golden The staging fix means a Cursor install now correctly carries the two transitive lib files it was silently missing. Additive only — no path was removed. The golden diff is the evidence the packaging defect was real. Refs #3911 * chore(#3911): backfill the changeset PR number Refs #3911 * fix(#3911): a git probe that timed out is not a negative A macOS CI lane failed three deny cases at 2084ms, 2112ms and 2177ms — just past the 2000ms budget these hooks give their git probes. The three that passed took 72ms, 595ms and 651ms. Under shard contention `git rev-parse` overruns, the hook reads the non-zero result as "not a git repo", and allows with exit 0 and empty stdout AND empty stderr. Under load, the guards silently stop guarding. That is ADR-3889's thesis exactly, sitting inside the security hooks this phase is about. The repo had already recognized the class in one place — gsd-cursor-subagent-start.js fail-closed-denies on `git_timed_out` (#3045) — but nowhere else. `hooks/lib/git-probe.js` classifies a probe's outcome, distinguishing a real non-zero exit from ETIMEDOUT, a signal kill, and a spawn failure, rather than folding all four into `status !== 0`. Three guards route their eight git probes through it. The resolution is the same shape #3838 took, and the same one that issue endorsed as smallest-sufficient: fail open, but loudly. **No exit code changes on any path** — a developer on a loaded machine is still not blocked, which keeps #3911's declaration-pass contract intact for exit codes. What changes is that the hook now says on stderr which probe could not answer, instead of presenting silence as a clean verdict. Scope was checked across every hooks/*.js, not just the three that failed: gsd-agent-isolation-guard spawns no git; gsd-statusline's two probes gate only a cosmetic display segment, not an allow/deny decision, and are left alone. The C2 deny assertion was a real-race test — it demanded exit 2 while a slow git legitimately yields 0. It now requires the hook to either deny, or allow with a diagnostic naming the probe that could not run; a silent allow still fails, so the assertion is not vacuous. A deterministic regression stubs git on PATH to sleep past the budget rather than waiting for load to reproduce it. Verification runs on the remote runner. Refs #3911 * test(#3911): a PATH shim cannot intercept the hooks' git spawn on Windows The deterministic timeout regression stubbed git on PATH and asserted the guard reports rather than silently allows. It passes on Linux and macOS and failed on Windows in 83ms and 176ms — the stub was never invoked at all. Mechanism: the hooks call spawnSync('git', args) with no shell:true, so on Windows CreateProcess resolves git.exe only and never a PATH .cmd shim. The git.cmd branch could not have worked and is removed rather than left implying a Windows path that does. Adding shell:true to the hooks to serve a test would change product behavior and widen an injection surface, so the case is skipped on win32 only, with the mechanism written into the skip reason so a future reader does not 'fix' it that way. Linux and macOS keep the coverage, and macOS is where the underlying fail-open was actually caught. Refs #3911 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -21,6 +21,13 @@ const IO_PATH = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
|
||||
const SCRIPTS_CLI_EXIT_PATH = path.resolve(__dirname, '../scripts/lib/cli-exit.cjs');
|
||||
const EXIT_CODE_REGISTRY_PATH = path.resolve(__dirname, '../gsd-core/bin/lib/exit-code-registry.cjs');
|
||||
|
||||
// #3911 (ADR-3889 Phase 7): the THIRD emitted copy, for hooks/ consumers that
|
||||
// must terminate through terminateNow without depending on any build
|
||||
// artifact (gsd-core/bin/lib is gitignored tsc output, absent on a raw
|
||||
// plugin-marketplace or git-clone install).
|
||||
const HOOKS_CLI_EXIT_PATH = path.resolve(__dirname, '../hooks/lib/cli-exit.js');
|
||||
const HOOKS_EXIT_CODE_REGISTRY_PATH = path.resolve(__dirname, '../hooks/lib/exit-code-registry.js');
|
||||
|
||||
const { EXIT_CODES } = require(EXIT_CODE_REGISTRY_PATH);
|
||||
const REGISTERED_NAMES = EXIT_CODES.map((e) => e.name);
|
||||
const VERSIONS = ['v1', 'v2'];
|
||||
@@ -1147,6 +1154,126 @@ describe('#3906: terminateNow', () => {
|
||||
assert.equal(r.stderr, '');
|
||||
});
|
||||
|
||||
test('#3911: HOOK_DENY with NO stderrPayload arg — backward-compatible default: fd1 and fd2 both get the serialized payload', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'blocked-default' });`,
|
||||
]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { reason: 'blocked-default' });
|
||||
assert.deepEqual(JSON.parse(r.stderr), { reason: 'blocked-default' });
|
||||
});
|
||||
|
||||
test('#3911: HOOK_DENY with a STRING stderrPayload — fd1 gets JSON, fd2 gets the raw string verbatim', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { decision: 'block', reason: 'shrink too large' }, 'shrink too large');`,
|
||||
]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { decision: 'block', reason: 'shrink too large' });
|
||||
assert.equal(r.stderr, 'shrink too large', `expected raw string on stderr, got: ${r.stderr}`);
|
||||
assert.throws(() => JSON.parse(r.stderr), SyntaxError,
|
||||
'a raw reason string must NOT itself be JSON-parseable as an object');
|
||||
});
|
||||
|
||||
test('#3911: HOOK_DENY with an OBJECT stderrPayload — fd2 gets that object serialized, not the fd1 payload', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { full: 'stdout-payload' }, { distinct: 'stderr-payload' });`,
|
||||
]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { full: 'stdout-payload' });
|
||||
assert.deepEqual(JSON.parse(r.stderr), { distinct: 'stderr-payload' });
|
||||
});
|
||||
|
||||
test('#3911: a PASS outcome with a stderrPayload writes NOTHING to stderr — stderr is a deny-only channel', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('PASS', { ok: true }, 'should never reach stderr');`,
|
||||
]);
|
||||
assert.equal(r.status, 0);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { ok: true });
|
||||
assert.equal(r.stderr, '', `expected empty stderr for a non-deny outcome; got: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('#3911: terminateNow(PASS, undefined) exits 0 with empty stdout', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('PASS', undefined);`,
|
||||
]);
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.equal(r.stdout, '', `expected empty stdout, got: ${r.stdout}`);
|
||||
assert.equal(r.stderr, '', `expected empty stderr, got: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('#3911: terminateNow(HOOK_DENY, undefined) exits 2 with EMPTY stdout and EMPTY stderr', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', undefined);`,
|
||||
]);
|
||||
assert.equal(r.status, 2, `stderr: ${r.stderr}`);
|
||||
assert.equal(r.stdout, '', `expected empty stdout, got: ${r.stdout}`);
|
||||
assert.equal(r.stderr, '', `expected empty stderr, got: ${r.stderr}`);
|
||||
});
|
||||
|
||||
// The defect this whole file is regressing (#3911): `deny(undefined,
|
||||
// 'some reason')` used to exit 2 with EMPTY stderr because the fd1 write of
|
||||
// `undefined` threw (JSON.stringify(undefined) -> undefined,
|
||||
// Buffer.from(undefined,'utf8') throws) and the SHARED try/catch aborted
|
||||
// before the fd2 write of `stderrPayload` ever ran.
|
||||
test('#3911: terminateNow(HOOK_DENY, undefined, "reason text") exits 2 with EMPTY stdout and stderr EXACTLY "reason text"', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', undefined, 'reason text');`,
|
||||
]);
|
||||
assert.equal(r.status, 2, `stderr: ${r.stderr}`);
|
||||
assert.equal(r.stdout, '', `expected empty stdout, got: ${r.stdout}`);
|
||||
assert.equal(r.stderr, 'reason text', `expected exactly "reason text" on stderr, got: ${r.stderr}`);
|
||||
});
|
||||
|
||||
// ── #3911 regression: the two stream emissions are INDEPENDENT ───────────
|
||||
// These two tests are the direct regression coverage for the defect: a
|
||||
// shared try/catch around both writes meant a failure serializing/writing
|
||||
// fd 1 aborted before fd 2 (or vice versa) ever ran. Both must FAIL against
|
||||
// the pre-fix single-try-block implementation.
|
||||
test('#3911 regression: fd 1 write throws but fd 2 STILL receives its payload, exit code unchanged', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const fs = require('node:fs');`,
|
||||
`const origWriteSync = fs.writeSync;`,
|
||||
`fs.writeSync = (fd, ...rest) => {`,
|
||||
` if (fd === 1) throw new Error('injected fd1 failure');`,
|
||||
` return origWriteSync(fd, ...rest);`,
|
||||
`};`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'fd1-throws' });`,
|
||||
]);
|
||||
assert.equal(r.status, 2, `exit code must be unchanged by the fd1 failure; stderr: ${r.stderr}`);
|
||||
assert.equal(r.stdout, '', 'fd1 write failed, so stdout must be empty (not a partial payload)');
|
||||
assert.deepEqual(
|
||||
JSON.parse(r.stderr), { reason: 'fd1-throws' },
|
||||
`fd2 must still receive its payload despite the fd1 failure; got: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#3911 regression: fd 2 write throws but fd 1 STILL receives its payload, exit code unchanged', () => {
|
||||
const r = spawnTerminateNow([
|
||||
`const fs = require('node:fs');`,
|
||||
`const origWriteSync = fs.writeSync;`,
|
||||
`fs.writeSync = (fd, ...rest) => {`,
|
||||
` if (fd === 2) throw new Error('injected fd2 failure');`,
|
||||
` return origWriteSync(fd, ...rest);`,
|
||||
`};`,
|
||||
`const c = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'fd2-throws' });`,
|
||||
]);
|
||||
assert.equal(r.status, 2, `exit code must be unchanged by the fd2 failure; stderr: ${r.stderr}`);
|
||||
assert.deepEqual(
|
||||
JSON.parse(r.stdout), { reason: 'fd2-throws' },
|
||||
`fd1 must still receive its payload despite the fd2 failure; got: ${r.stdout}`,
|
||||
);
|
||||
assert.equal(r.stderr, '', 'fd2 write failed, so stderr must be empty (not a partial payload)');
|
||||
});
|
||||
|
||||
// Cross-platform IO-failure injection: a monkeypatched THROWING fs.writeSync,
|
||||
// restored implicitly by process exit — never chmod (CONTRIBUTING.md /
|
||||
// CLAUDE.md: mode-bit tricks are bypassed by root/CI and leak resources).
|
||||
@@ -1366,6 +1493,61 @@ describe('#3906: cross-copy — the built copy and the scripts copy agree', () =
|
||||
}
|
||||
});
|
||||
|
||||
// #3911 (ADR-3889 Phase 7) A6: EXTENDS the two-copy parity above to the
|
||||
// THIRD emitted copy (hooks/lib/cli-exit.js + hooks/lib/exit-code-registry.js)
|
||||
// rather than duplicating it. Outcome names are enumerated from the hooks
|
||||
// registry itself — never hardcoded — so a future registry addition is
|
||||
// covered automatically and a hooks-registry omission fails loudly here
|
||||
// instead of silently under-testing the hooks copy.
|
||||
test('the hooks copy agrees with both existing copies for every registered outcome/version', () => {
|
||||
const built = require(BUILT_CLI_EXIT_PATH);
|
||||
const scripts = require(SCRIPTS_CLI_EXIT_PATH);
|
||||
const hooks = require(HOOKS_CLI_EXIT_PATH);
|
||||
const { EXIT_CODES: hooksExitCodes } = require(HOOKS_EXIT_CODE_REGISTRY_PATH);
|
||||
const hooksNames = hooksExitCodes.map((e) => e.name);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
[...hooksNames].sort(), [...REGISTERED_NAMES].sort(),
|
||||
'the hooks registry must declare the exact same outcome set as the primary registry',
|
||||
);
|
||||
|
||||
for (const version of VERSIONS) {
|
||||
for (const outcome of ['PASS', 'FAIL', ...REGISTERED_NAMES]) {
|
||||
const fromBuilt = built.projectOutcome(outcome, version);
|
||||
const fromScripts = scripts.projectOutcome(outcome, version);
|
||||
const fromHooks = hooks.projectOutcome(outcome, version);
|
||||
assert.equal(fromScripts, fromBuilt, `scripts vs built disagree for ${outcome}/${version}`);
|
||||
assert.equal(fromHooks, fromBuilt, `hooks vs built disagree for ${outcome}/${version}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// #3911 A6: the json-error-mode cell is a `globalThis`-backed shared cell
|
||||
// (see the #3906 "ONE json-error-mode cell" tests above for the built/scripts
|
||||
// pair) — prove the hooks copy reads and writes the SAME cell, not a third,
|
||||
// independent module-level flag that would silently diverge.
|
||||
test('the json-error-mode cell is genuinely shared across all three copies (built, scripts, hooks)', () => {
|
||||
const built = require(BUILT_CLI_EXIT_PATH);
|
||||
const scripts = require(SCRIPTS_CLI_EXIT_PATH);
|
||||
const hooks = require(HOOKS_CLI_EXIT_PATH);
|
||||
const saved = built.getJsonErrorMode();
|
||||
try {
|
||||
built.setJsonErrorMode(true);
|
||||
assert.equal(scripts.getJsonErrorMode(), true, 'scripts must observe the mode set through built');
|
||||
assert.equal(hooks.getJsonErrorMode(), true, 'hooks must observe the mode set through built');
|
||||
|
||||
hooks.setJsonErrorMode(false);
|
||||
assert.equal(built.getJsonErrorMode(), false, 'built must observe the mode set through hooks');
|
||||
assert.equal(scripts.getJsonErrorMode(), false, 'scripts must observe the mode set through hooks');
|
||||
|
||||
scripts.setJsonErrorMode(true);
|
||||
assert.equal(built.getJsonErrorMode(), true, 'built must observe the mode set through scripts');
|
||||
assert.equal(hooks.getJsonErrorMode(), true, 'hooks must observe the mode set through scripts');
|
||||
} finally {
|
||||
built.setJsonErrorMode(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('scripts/lib/cli-exit.cjs + its sibling exit-code-registry.cjs load standalone, no gsd-core sibling', (t) => {
|
||||
const dir = createTempDir('gsd-3906-standalone-');
|
||||
t.after(() => cleanup(dir));
|
||||
@@ -1414,3 +1596,168 @@ describe('#3906: cross-copy — the built copy and the scripts copy agree', () =
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3911 (ADR-3889 Phase 7, issue #3911): the hooks copy is load-bearing ──
|
||||
//
|
||||
// The WHOLE POINT of hooks/lib/cli-exit.js is that a shipped enforcement hook
|
||||
// can terminate through terminateNow WITHOUT depending on any build
|
||||
// artifact. gsd-core/bin/lib is gitignored tsc output and is ABSENT on a raw
|
||||
// plugin-marketplace or git-clone install. These tests prove that property
|
||||
// hermetically: by copying ONLY hooks/lib/cli-exit.js and its sibling
|
||||
// hooks/lib/exit-code-registry.js into a fresh, otherwise-empty tmpdir (no
|
||||
// gsd-core sibling, no node_modules) and requiring the copy from a CHILD
|
||||
// process rooted there.
|
||||
describe('#3911: hooks/lib/cli-exit.js loads and terminates with no build present (A4, load-bearing)', () => {
|
||||
/** Copy both hooks/lib artifacts into a fresh, otherwise-empty tmpdir. */
|
||||
function makeStandaloneHooksCopy(t) {
|
||||
const dir = createTempDir('gsd-3911-hooks-standalone-');
|
||||
t.after(() => cleanup(dir));
|
||||
const copiedExit = path.join(dir, 'cli-exit.js');
|
||||
const copiedRegistry = path.join(dir, 'exit-code-registry.js');
|
||||
fs.copyFileSync(HOOKS_CLI_EXIT_PATH, copiedExit);
|
||||
fs.copyFileSync(HOOKS_EXIT_CODE_REGISTRY_PATH, copiedRegistry);
|
||||
return { dir, copiedExit, copiedRegistry };
|
||||
}
|
||||
|
||||
// This test must FAIL if hooks/lib/cli-exit.js ever gains a require
|
||||
// reaching outside hooks/lib/ (e.g. back into gsd-core/bin/lib, or a
|
||||
// node_modules package): the tmpdir contains NOTHING else, so any such
|
||||
// require resolves to MODULE_NOT_FOUND and the child crashes before
|
||||
// terminateNow ever runs, failing every assertion below.
|
||||
test('terminateNow(PASS) exits 0 with the payload on stdout, from a build-free tmpdir', (t) => {
|
||||
const { dir, copiedExit } = makeStandaloneHooksCopy(t);
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(copiedExit)});`,
|
||||
`c.terminateNow('PASS', { ok: true, from: 'hooks-standalone' });`,
|
||||
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
|
||||
assert.ok(
|
||||
!r.stderr.includes('MODULE_NOT_FOUND'),
|
||||
`the hooks copy must not require anything outside hooks/lib/; got: ${r.stderr.slice(0, 400)}`,
|
||||
);
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { ok: true, from: 'hooks-standalone' });
|
||||
});
|
||||
|
||||
test('terminateNow(HOOK_DENY) exits 2 with the payload on BOTH stdout and stderr, from a build-free tmpdir', (t) => {
|
||||
const { dir, copiedExit } = makeStandaloneHooksCopy(t);
|
||||
const r = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(copiedExit)});`,
|
||||
`c.terminateNow('HOOK_DENY', { reason: 'blocked-by-guard', from: 'hooks-standalone' });`,
|
||||
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
|
||||
assert.ok(
|
||||
!r.stderr.includes('MODULE_NOT_FOUND'),
|
||||
`the hooks copy must not require anything outside hooks/lib/; got: ${r.stderr.slice(0, 400)}`,
|
||||
);
|
||||
assert.equal(r.status, 2, `stderr: ${r.stderr}`);
|
||||
const expected = { reason: 'blocked-by-guard', from: 'hooks-standalone' };
|
||||
assert.deepEqual(JSON.parse(r.stdout), expected);
|
||||
assert.deepEqual(JSON.parse(r.stderr), expected);
|
||||
});
|
||||
|
||||
// A5: sibling resolution. hooks/lib/cli-exit.js requires its OWN sibling
|
||||
// (./exit-code-registry.js), not some other copy sitting elsewhere on the
|
||||
// machine — proven by DELETING the sibling from the tmpdir and observing
|
||||
// the require actually fail, then restoring it and observing success again.
|
||||
test('resolves its OWN sibling exit-code-registry.js, not some other copy (A5)', (t) => {
|
||||
const { dir, copiedExit, copiedRegistry } = makeStandaloneHooksCopy(t);
|
||||
const registryBytes = fs.readFileSync(copiedRegistry);
|
||||
|
||||
// Sanity: with the sibling present, the copy loads clean.
|
||||
const before = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(copiedExit)});`,
|
||||
`process.stdout.write(JSON.stringify({ hasTerminateNow: typeof c.terminateNow }));`,
|
||||
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
assert.equal(before.status, 0, `stderr: ${before.stderr}`);
|
||||
assert.deepEqual(JSON.parse(before.stdout), { hasTerminateNow: 'function' });
|
||||
|
||||
// Prove the failure: delete the sibling, require must fail. Single-file
|
||||
// removal of a fixture we immediately restore below (not directory
|
||||
// teardown; cleanup(dir) still runs via t.after() for the whole tmpdir).
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- see comment above
|
||||
fs.rmSync(copiedRegistry);
|
||||
try {
|
||||
const missing = toLegacyResult(runNode(['-e', [
|
||||
`require(${JSON.stringify(copiedExit)});`,
|
||||
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
assert.notEqual(missing.status, 0, 'require must fail once the sibling registry is removed');
|
||||
assert.ok(
|
||||
missing.stderr.includes('MODULE_NOT_FOUND') || missing.stderr.includes('Cannot find module'),
|
||||
`expected a module-resolution failure naming the missing sibling; got: ${missing.stderr.slice(0, 400)}`,
|
||||
);
|
||||
} finally {
|
||||
// Restore in a finally so a failing assertion above cannot leave the
|
||||
// tmpdir fixture (owned by this test, not a committed artifact) broken
|
||||
// for any later step in this same test.
|
||||
fs.writeFileSync(copiedRegistry, registryBytes);
|
||||
}
|
||||
|
||||
// Confirm restoration actually fixes it — the negative-space check is
|
||||
// meaningless without this positive control.
|
||||
const after = toLegacyResult(runNode(['-e', [
|
||||
`const c = require(${JSON.stringify(copiedExit)});`,
|
||||
`process.stdout.write(JSON.stringify({ hasTerminateNow: typeof c.terminateNow }));`,
|
||||
].join('\n')], { cwd: dir, timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
assert.equal(after.status, 0, `stderr: ${after.stderr}`);
|
||||
assert.deepEqual(JSON.parse(after.stdout), { hasTerminateNow: 'function' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3911 A3: the --check generator guards can actually fail ──────────────
|
||||
//
|
||||
// CONTEXT.md's prove-it-can-fail rule: a guard that has never been observed
|
||||
// to fail is not a guard. For BOTH new committed artifacts, corrupt the
|
||||
// committed file, run the generator's --check, assert it fails and names the
|
||||
// file, then restore in a `finally` (so a failing assertion here can never
|
||||
// leave a committed artifact corrupted) and re-run --check to confirm the
|
||||
// restore actually cleared the guard.
|
||||
describe('#3911: the --check guards for the new hooks/lib artifacts can actually fail (A3)', () => {
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const GEN_HOOKS_CLI_EXIT = path.join(REPO_ROOT, 'scripts', 'gen-hooks-cli-exit.cjs');
|
||||
const GEN_EXIT_CODE_REGISTRY = path.join(REPO_ROOT, 'scripts', 'gen-exit-code-registry.cjs');
|
||||
// gen-hooks-cli-exit.cjs --check runs a real tsc compile of the whole
|
||||
// project to a throwaway outDir (see its own COMPILE_TIMEOUT_MS=60000) —
|
||||
// this needs a longer bound than a plain probe.
|
||||
const CHECK_TIMEOUT_MS = 90000;
|
||||
|
||||
test('gen-hooks-cli-exit.cjs --check fails on a corrupted hooks/lib/cli-exit.js, names the file, and clears on restore', () => {
|
||||
const original = fs.readFileSync(HOOKS_CLI_EXIT_PATH);
|
||||
let corrupted = false;
|
||||
try {
|
||||
fs.appendFileSync(HOOKS_CLI_EXIT_PATH, '\n// corrupted-by-A3-test\n');
|
||||
corrupted = true;
|
||||
const r = toLegacyResult(runNode([GEN_HOOKS_CLI_EXIT, '--check'], { timeoutMs: CHECK_TIMEOUT_MS }));
|
||||
assert.notEqual(r.status, 0, `--check must fail on a corrupted committed artifact; stderr: ${r.stderr}`);
|
||||
assert.ok(
|
||||
r.stderr.includes('cli-exit.js'),
|
||||
`expected the failure to name the drifted file; got: ${r.stderr.slice(0, 400)}`,
|
||||
);
|
||||
} finally {
|
||||
if (corrupted) fs.writeFileSync(HOOKS_CLI_EXIT_PATH, original);
|
||||
}
|
||||
|
||||
const restored = toLegacyResult(runNode([GEN_HOOKS_CLI_EXIT, '--check'], { timeoutMs: CHECK_TIMEOUT_MS }));
|
||||
assert.equal(restored.status, 0, `--check must pass again once the artifact is restored; stderr: ${restored.stderr}`);
|
||||
});
|
||||
|
||||
test('gen-exit-code-registry.cjs --check fails on a corrupted hooks/lib/exit-code-registry.js, names the file, and clears on restore', () => {
|
||||
const original = fs.readFileSync(HOOKS_EXIT_CODE_REGISTRY_PATH);
|
||||
let corrupted = false;
|
||||
try {
|
||||
fs.appendFileSync(HOOKS_EXIT_CODE_REGISTRY_PATH, '\n// corrupted-by-A3-test\n');
|
||||
corrupted = true;
|
||||
const r = toLegacyResult(runNode([GEN_EXIT_CODE_REGISTRY, '--check'], { timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
assert.notEqual(r.status, 0, `--check must fail on a corrupted committed artifact; stderr: ${r.stderr}`);
|
||||
assert.ok(
|
||||
r.stderr.includes('exit-code-registry.js'),
|
||||
`expected the failure to name the drifted file; got: ${r.stderr.slice(0, 400)}`,
|
||||
);
|
||||
} finally {
|
||||
if (corrupted) fs.writeFileSync(HOOKS_EXIT_CODE_REGISTRY_PATH, original);
|
||||
}
|
||||
|
||||
const restored = toLegacyResult(runNode([GEN_EXIT_CODE_REGISTRY, '--check'], { timeoutMs: PROBE_TIMEOUT_MS }));
|
||||
assert.equal(restored.status, 0, `--check must pass again once the artifact is restored; stderr: ${restored.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,9 +301,17 @@ describe('#2587: cursor hooks resolve the workspace from workspace_roots, not cw
|
||||
for (const hook of RESOLVING_HOOKS) {
|
||||
fs.copyFileSync(hook, path.join(srcHooks, path.basename(hook)));
|
||||
}
|
||||
// Any one of the RESOLVING_HOOKS' required lib/ helpers is a valid trip
|
||||
// wire here — this fixture supplies NONE of them, so whichever helper the
|
||||
// scan discovers first is reported missing. Coupling this assertion to one
|
||||
// specific filename (formerly 'cursor-workspace.js') breaks every time the
|
||||
// discovery order shifts, e.g. #3911 adding an earlier './lib/hook-exit.js'
|
||||
// require to these same hook scripts. The invariant under test is "some
|
||||
// required lib helper missing from hooks/lib -> the install aborts", not
|
||||
// "this exact helper is named first".
|
||||
assert.throws(
|
||||
() => hooksSurface.writeCursorHooksJson(target, fakeSrc, {}),
|
||||
/cursor-workspace\.js.*missing|missing.*cursor-workspace\.js/s,
|
||||
/hooks\/lib\/[A-Za-z0-9._-]+\.js is required by a staged Cursor hook but is missing/,
|
||||
'a missing lib source must abort the install, not ship a broken hook',
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -34,6 +34,10 @@ const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const GEN_SCRIPT = path.join(REPO_ROOT, 'scripts', 'gen-exit-code-registry.cjs');
|
||||
const REAL_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.json');
|
||||
const REAL_ARTIFACT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs');
|
||||
const REAL_SCRIPTS_ARTIFACT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs');
|
||||
const REAL_HOOKS_ARTIFACT_PATH = path.join(REPO_ROOT, 'hooks', 'lib', 'exit-code-registry.js');
|
||||
const REAL_DTS_ARTIFACT_PATH = path.join(REPO_ROOT, 'src', 'exit-code-registry.d.cts');
|
||||
const REAL_SH_ARTIFACT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.sh');
|
||||
|
||||
const generator = require(GEN_SCRIPT);
|
||||
const registry = require(REAL_ARTIFACT_PATH);
|
||||
@@ -53,21 +57,23 @@ function makeEntry(overrides) {
|
||||
}
|
||||
|
||||
/**
|
||||
* #3906 (ADR-3889 Phase 2): the generator now emits THREE artifacts — a
|
||||
* #3906 (ADR-3889 Phase 2): the generator now emits FIVE artifacts — a
|
||||
* primary (gsd-core/bin/lib), a secondary (scripts/lib), and the ambient
|
||||
* `.d.cts` type declaration (src/exit-code-registry.d.cts). #3908 (Phase 4)
|
||||
* added a FOURTH: the shell-sourceable fragment (gsd-core/bin/shared/
|
||||
* exit-codes.sh). Every existing call site below only overrides the PRIMARY
|
||||
* path via `--out`; without matching `--scripts-out`/`--dts-out`/`--sh-out`
|
||||
* overrides, a `--write` here would clobber the real committed
|
||||
* `scripts/lib/exit-code-registry.cjs`, `src/exit-code-registry.d.cts`, and
|
||||
* `gsd-core/bin/shared/exit-codes.sh` — dangerous since test files in this
|
||||
* repo run in parallel. Rather than touch every call site, this single seam
|
||||
* derives co-located, per-call-unique secondary/dts/sh paths from whatever
|
||||
* `--out` value the test already supplies, whenever the caller has not
|
||||
* already supplied its own `--scripts-out`/`--dts-out`/`--sh-out`. Calls
|
||||
* with no explicit `--out` (the "real committed set" checks) are left
|
||||
* untouched.
|
||||
* exit-codes.sh). #3911 (Phase 7) added a FIFTH: the hooks/lib/ copy
|
||||
* (hooks/lib/exit-code-registry.js). Every existing call site below only
|
||||
* overrides the PRIMARY path via `--out`; without matching
|
||||
* `--scripts-out`/`--hooks-out`/`--dts-out`/`--sh-out` overrides, a
|
||||
* `--write` here would clobber the real committed
|
||||
* `scripts/lib/exit-code-registry.cjs`, `hooks/lib/exit-code-registry.js`,
|
||||
* `src/exit-code-registry.d.cts`, and `gsd-core/bin/shared/exit-codes.sh` —
|
||||
* dangerous since test files in this repo run in parallel. Rather than
|
||||
* touch every call site, this single seam derives co-located,
|
||||
* per-call-unique secondary/hooks/dts/sh paths from whatever `--out` value
|
||||
* the test already supplies, whenever the caller has not already supplied
|
||||
* its own `--scripts-out`/`--hooks-out`/`--dts-out`/`--sh-out`. Calls with
|
||||
* no explicit `--out` (the "real committed set" checks) are left untouched.
|
||||
*/
|
||||
function ensureScriptsOut(args) {
|
||||
const outIdx = args.indexOf('--out');
|
||||
@@ -75,6 +81,7 @@ function ensureScriptsOut(args) {
|
||||
const outValue = args[outIdx + 1];
|
||||
const extra = [];
|
||||
if (!args.includes('--scripts-out')) extra.push('--scripts-out', `${outValue}.secondary.cjs`);
|
||||
if (!args.includes('--hooks-out')) extra.push('--hooks-out', `${outValue}.hooks.js`);
|
||||
if (!args.includes('--dts-out')) extra.push('--dts-out', `${outValue}.d.cts`);
|
||||
if (!args.includes('--sh-out')) extra.push('--sh-out', `${outValue}.sh`);
|
||||
return extra.length === 0 ? args : [...args, ...extra];
|
||||
@@ -562,6 +569,37 @@ describe('gen-exit-code-registry: CLI', () => {
|
||||
assert.equal(report.ok, false);
|
||||
assert.equal(report.reason, generator.REASON.EMPTY_DECLARATION);
|
||||
});
|
||||
|
||||
// Regression (#3911 follow-up): ensureScriptsOut derived --scripts-out/
|
||||
// --dts-out/--sh-out from --out but did not derive --hooks-out, so any
|
||||
// --write test here silently clobbered the real committed
|
||||
// hooks/lib/exit-code-registry.js. Assert over ALL FIVE committed
|
||||
// artifacts so the next added target is covered by construction.
|
||||
test('a --write run redirected to a tmpdir leaves every committed artifact untouched', () => {
|
||||
const before = {
|
||||
out: fs.readFileSync(REAL_ARTIFACT_PATH, 'utf8'),
|
||||
scripts: fs.readFileSync(REAL_SCRIPTS_ARTIFACT_PATH, 'utf8'),
|
||||
hooks: fs.readFileSync(REAL_HOOKS_ARTIFACT_PATH, 'utf8'),
|
||||
dts: fs.readFileSync(REAL_DTS_ARTIFACT_PATH, 'utf8'),
|
||||
sh: fs.readFileSync(REAL_SH_ARTIFACT_PATH, 'utf8'),
|
||||
};
|
||||
|
||||
const decl = validDeclarationPath(tmpDir, 'l-decl.json');
|
||||
const out = path.join(tmpDir, 'l-out.cjs');
|
||||
const write = runGen(['--write', '--declaration', decl, '--out', out]);
|
||||
assert.equal(write.exitCode, 0, write.stderr);
|
||||
|
||||
assert.equal(fs.readFileSync(REAL_ARTIFACT_PATH, 'utf8'), before.out, 'primary artifact must be untouched');
|
||||
assert.equal(fs.readFileSync(REAL_SCRIPTS_ARTIFACT_PATH, 'utf8'), before.scripts, 'scripts artifact must be untouched');
|
||||
assert.equal(fs.readFileSync(REAL_HOOKS_ARTIFACT_PATH, 'utf8'), before.hooks, 'hooks artifact must be untouched');
|
||||
assert.equal(fs.readFileSync(REAL_DTS_ARTIFACT_PATH, 'utf8'), before.dts, '.d.cts artifact must be untouched');
|
||||
assert.equal(fs.readFileSync(REAL_SH_ARTIFACT_PATH, 'utf8'), before.sh, '.sh artifact must be untouched');
|
||||
|
||||
assert.ok(fs.existsSync(`${out}.hooks.js`), 'expected the redirected hooks copy to land in the tmpdir');
|
||||
assert.ok(fs.existsSync(`${out}.secondary.cjs`), 'expected the redirected scripts copy to land in the tmpdir');
|
||||
assert.ok(fs.existsSync(`${out}.d.cts`), 'expected the redirected .d.cts copy to land in the tmpdir');
|
||||
assert.ok(fs.existsSync(`${out}.sh`), 'expected the redirected .sh copy to land in the tmpdir');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Generator CLI: positive controls (each guard actually FAILS the build) ────
|
||||
|
||||
4
tests/fixtures/install-tree/antigravity.json
vendored
4
tests/fixtures/install-tree/antigravity.json
vendored
@@ -404,9 +404,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/augment.json
vendored
4
tests/fixtures/install-tree/augment.json
vendored
@@ -475,9 +475,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
@@ -475,9 +475,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/claude.json
vendored
4
tests/fixtures/install-tree/claude.json
vendored
@@ -404,9 +404,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/codebuddy.json
vendored
4
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -475,9 +475,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
3
tests/fixtures/install-tree/cursor.json
vendored
3
tests/fixtures/install-tree/cursor.json
vendored
@@ -383,7 +383,10 @@
|
||||
"hooks/gsd-cursor-stop.js",
|
||||
"hooks/gsd-cursor-subagent-start.js",
|
||||
"hooks/gsd-cursor-subagent-stop.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/package.json",
|
||||
|
||||
4
tests/fixtures/install-tree/hermes.json
vendored
4
tests/fixtures/install-tree/hermes.json
vendored
@@ -404,9 +404,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/kilo.json
vendored
4
tests/fixtures/install-tree/kilo.json
vendored
@@ -475,9 +475,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/kimi-code.json
vendored
4
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -405,9 +405,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/opencode.json
vendored
4
tests/fixtures/install-tree/opencode.json
vendored
@@ -475,9 +475,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/pi.json
vendored
4
tests/fixtures/install-tree/pi.json
vendored
@@ -371,9 +371,13 @@
|
||||
"gsd-hooks/gsd-workflow-guard.js",
|
||||
"gsd-hooks/gsd-worktree-path-guard.js",
|
||||
"gsd-hooks/gsd-write-guard.js",
|
||||
"gsd-hooks/lib/cli-exit.js",
|
||||
"gsd-hooks/lib/cursor-workspace.js",
|
||||
"gsd-hooks/lib/exit-code-registry.js",
|
||||
"gsd-hooks/lib/git-cmd.js",
|
||||
"gsd-hooks/lib/git-probe.js",
|
||||
"gsd-hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"gsd-hooks/lib/hook-exit.js",
|
||||
"gsd-hooks/lib/injection-patterns.js",
|
||||
"gsd-hooks/lib/isolation-deny-reason.js",
|
||||
"gsd-hooks/lib/isolation-sentinel.js",
|
||||
|
||||
4
tests/fixtures/install-tree/qwen.json
vendored
4
tests/fixtures/install-tree/qwen.json
vendored
@@ -404,9 +404,13 @@
|
||||
"hooks/gsd-workflow-guard.js",
|
||||
"hooks/gsd-worktree-path-guard.js",
|
||||
"hooks/gsd-write-guard.js",
|
||||
"hooks/lib/cli-exit.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/exit-code-registry.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/git-probe.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/hook-exit.js",
|
||||
"hooks/lib/injection-patterns.js",
|
||||
"hooks/lib/isolation-deny-reason.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
|
||||
171
tests/gsd-validate-commit-crash-policy.test.cjs
Normal file
171
tests/gsd-validate-commit-crash-policy.test.cjs
Normal file
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* gsd-validate-commit-crash-policy.test.cjs — regression coverage for #3838
|
||||
* (subsumed-but-still-present under #3911): hooks/gsd-validate-commit.sh has
|
||||
* three "swallow-and-pass" sites — the opt-in config read, the JSON command
|
||||
* extraction, and the isGitSubcommand classifier — each of which used a
|
||||
* failing subprocess call directly as an `if`/`$(...)` condition. `set -e`
|
||||
* never fires on a command used as an `if` condition, so a failure there was
|
||||
* indistinguishable from "genuinely not applicable" and silently disabled
|
||||
* the whole validator: a non-conforming commit exited 0 with no output,
|
||||
* identical to "your commit is fine".
|
||||
*
|
||||
* This is a sibling file to tests/hooks-crash-policy.test.cjs rather than an
|
||||
* addition to its table: that file's TABLE and drift guard are scoped
|
||||
* exclusively to hooks/*.js (the hook-exit.js allow/deny/crash migration,
|
||||
* #3911 phase 7); gsd-validate-commit.sh is a bash script with its own
|
||||
* ad hoc exit-code contract that predates and is orthogonal to that
|
||||
* migration, so it does not belong in that table or its drift guard.
|
||||
*
|
||||
* Every case here spawns the real hook via bash (tests/helpers/process-seam.cjs
|
||||
* `runHook` with `interpreter: 'bash'`) against a real fixture project —
|
||||
* no source-file grep, no mocked node internals.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runHook, OUTCOME } = require('./helpers/process-seam.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-validate-commit.sh');
|
||||
|
||||
const CONFORMING_COMMIT_PAYLOAD = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "feat: add thing"' },
|
||||
});
|
||||
const NONCONFORMING_COMMIT_PAYLOAD = JSON.stringify({
|
||||
tool_input: { command: 'git commit -m "wibble wobble"' },
|
||||
});
|
||||
|
||||
const cleanupPaths = [];
|
||||
function tempDir(prefix) {
|
||||
const dir = createTempDir(prefix);
|
||||
cleanupPaths.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
let enabledProject;
|
||||
// A PATH directory whose `node` shim fails ONLY the classifier's `node -e`
|
||||
// invocation (detected by the presence of `isGitSubcommand` in argv, which
|
||||
// only that one of the hook's three node calls ever passes), and otherwise
|
||||
// execs the real node binary the test runner itself is running under. This
|
||||
// reproduces the exact defect-triggering shape from #3838's repro ("node
|
||||
// cannot run [for this call]") without touching the other two call sites.
|
||||
let classifierBrokenPathDir;
|
||||
|
||||
before(() => {
|
||||
enabledProject = tempDir('gsd-validate-commit-ok-');
|
||||
fs.mkdirSync(path.join(enabledProject, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(enabledProject, '.planning', 'config.json'),
|
||||
JSON.stringify({ hooks: { community: true } }),
|
||||
);
|
||||
|
||||
classifierBrokenPathDir = tempDir('gsd-validate-commit-node-shim-');
|
||||
const shimPath = path.join(classifierBrokenPathDir, 'node');
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
`REAL_NODE=${JSON.stringify(process.execPath)}`,
|
||||
'for a in "$@"; do',
|
||||
' if [[ "$a" == *isGitSubcommand* ]]; then',
|
||||
' echo "test-shim: classifier node call intentionally broken (simulated node crash)" >&2',
|
||||
' exit 127',
|
||||
' fi',
|
||||
'done',
|
||||
'exec "$REAL_NODE" "$@"',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(shimPath, 0o755);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const p of cleanupPaths) cleanup(p);
|
||||
});
|
||||
|
||||
function runValidateCommit({ payload, cwd, env } = {}) {
|
||||
return runHook(HOOK_PATH, [], {
|
||||
interpreter: 'bash',
|
||||
cwd,
|
||||
env: { ...process.env, ...TEST_ENV_BASE, ...env },
|
||||
input: payload,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controls — pin that the fix does not weaken or break the working paths.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('gsd-validate-commit.sh: controls (unchanged behavior)', () => {
|
||||
test('CONTROL A: node available, conforming commit -> exit 0, no block payload', () => {
|
||||
const r = runValidateCommit({ payload: CONFORMING_COMMIT_PAYLOAD, cwd: enabledProject });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
||||
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
assert.equal(r.stdout.trim(), '');
|
||||
});
|
||||
|
||||
test('CONTROL B: node available, non-conforming commit -> exit 2 with block payload', () => {
|
||||
const r = runValidateCommit({ payload: NONCONFORMING_COMMIT_PAYLOAD, cwd: enabledProject });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
||||
assert.equal(r.exitCode, 2, `stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.decision, 'block');
|
||||
assert.equal(out.code, 'CONVENTIONAL_COMMITS_VIOLATION');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defect arms — each of the three swallow-and-pass sites, exercised with a
|
||||
// non-conforming commit so a silent pass is unambiguous: the validator MUST
|
||||
// NOT report success by omission when it could not actually run.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('gsd-validate-commit.sh: #3838 could-not-run sites are surfaced, not swallowed', () => {
|
||||
test('DEFECT (classifier): node cannot run isGitSubcommand -> not a silent pass', () => {
|
||||
const r = runValidateCommit({
|
||||
payload: NONCONFORMING_COMMIT_PAYLOAD,
|
||||
cwd: enabledProject,
|
||||
env: { PATH: `${classifierBrokenPathDir}:${process.env.PATH}` },
|
||||
});
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
||||
// The hook must still exit 0 (PreToolUse "fail open"), but it must not
|
||||
// be the pre-#3838-fix silent exit 0 with empty stdout AND empty stderr
|
||||
// — that shape is indistinguishable from "your commit is fine".
|
||||
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
assert.notEqual(r.stderr.trim(), '', 'expected a non-empty stderr diagnostic; got silence (the #3838 defect shape)');
|
||||
assert.match(r.stderr, /classif/i, 'diagnostic should name the classifier check');
|
||||
assert.equal(r.stdout.trim(), '', 'no block payload is expected when validation could not run');
|
||||
});
|
||||
|
||||
test('DEFECT (config read): malformed .planning/config.json -> not a silent pass', () => {
|
||||
const brokenConfigProject = tempDir('gsd-validate-commit-badconfig-');
|
||||
fs.mkdirSync(path.join(brokenConfigProject, '.planning'), { recursive: true });
|
||||
// Syntactically invalid JSON -> require() throws a SyntaxError distinct
|
||||
// from "file legitimately says community:false/absent".
|
||||
fs.writeFileSync(path.join(brokenConfigProject, '.planning', 'config.json'), '{ this is not json');
|
||||
|
||||
const r = runValidateCommit({ payload: NONCONFORMING_COMMIT_PAYLOAD, cwd: brokenConfigProject });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
||||
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
assert.notEqual(r.stderr.trim(), '', 'expected a non-empty stderr diagnostic; got silence');
|
||||
assert.match(r.stderr, /config/i, 'diagnostic should name the config-read check');
|
||||
});
|
||||
|
||||
test('DEFECT (command extraction): malformed JSON on stdin -> not a silent pass', () => {
|
||||
// Malformed top-level JSON on stdin makes JSON.parse(d) throw inside the
|
||||
// command-extraction node call — distinct from "tool_input.command is
|
||||
// genuinely absent from a well-formed payload" (which legitimately
|
||||
// yields CMD='' and is not a git commit).
|
||||
const r = runValidateCommit({ payload: '{not valid json at all', cwd: enabledProject });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
||||
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
assert.notEqual(r.stderr.trim(), '', 'expected a non-empty stderr diagnostic; got silence');
|
||||
assert.match(r.stderr, /command/i, 'diagnostic should name the command-extraction check');
|
||||
});
|
||||
});
|
||||
697
tests/hooks-crash-policy.test.cjs
Normal file
697
tests/hooks-crash-policy.test.cjs
Normal file
@@ -0,0 +1,697 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* hooks-crash-policy.test.cjs — table-driven coverage of ADR-3889 Phase 7
|
||||
* (#3911): every enforcement hook under hooks/*.js now terminates through
|
||||
* hooks/lib/hook-exit.js (allow/deny/crash) instead of a raw process.exit().
|
||||
*
|
||||
* Rather than ~76 hand-written tests (19 hooks x 4 cases), this file drives
|
||||
* ONE table — one row per hook, each row derived by reading that hook's own
|
||||
* source (never guessed) — through four generic cases:
|
||||
*
|
||||
* C1 allow — normal input -> exit 0.
|
||||
* C2 deny — normal input that trips the hook's block path
|
||||
* (only the 6 hooks that HAVE one) -> exit 2,
|
||||
* asserting the actual stream(s) that hook uses.
|
||||
* C3 crash honors policy — an input that makes the hook's own outer catch
|
||||
* fire, asserting the exit code matches its
|
||||
* DECLARED HOOK_ON_CRASH policy. Hooks that never
|
||||
* call crash(ON_CRASH, ...) at all (no declared
|
||||
* policy) are t.skip()'d with an explicit reason
|
||||
* — never silently passed via a bare return.
|
||||
* C4 stdin never closes — spawn with no stdin input and never end it;
|
||||
* assert the process still terminates (via its
|
||||
* own bounded stdin-timeout -> allow()) instead
|
||||
* of hanging on the parent's outer spawn timeout.
|
||||
*
|
||||
* The table is the single source of truth: a guard test at the bottom
|
||||
* enumerates hooks/*.js and fails if a new terminating hook is added without
|
||||
* a row here.
|
||||
*
|
||||
* Crash trigger: malformed JSON on stdin ('{not json'). Every one of the 9
|
||||
* hooks that declares an ON_CRASH policy parses its stdin payload as the
|
||||
* FIRST statement inside its outer try — `JSON.parse(input)` (or the Kimi-
|
||||
* normalized `normalizeKimiPayload(JSON.parse(input))`) — so a syntax error
|
||||
* there throws before any applicability logic runs and is a real, hook-
|
||||
* authored crash, not a synthetic fault injected by this suite.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { createTempDir, cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
||||
const { runHook: runHookSeam, runNode, OUTCOME } = require('./helpers/process-seam.cjs');
|
||||
const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
|
||||
const { ensureBuiltHooks } = require('../scripts/run-tests.cjs');
|
||||
|
||||
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
||||
|
||||
// Hook scripts that ship under hooks/ but never terminate through
|
||||
// hooks/lib/hook-exit.js at all — they are long-running/update-check helpers,
|
||||
// not PreToolUse/PostToolUse/SessionStart enforcement hooks, so #3911's
|
||||
// migration (and this table) does not apply to them.
|
||||
const NON_TERMINATING_HOOKS = new Set([
|
||||
'gsd-check-update.js',
|
||||
'gsd-check-update-worker.js',
|
||||
'gsd-update-banner.js',
|
||||
]);
|
||||
|
||||
function hookPath(name) {
|
||||
return path.join(HOOKS_DIR, name);
|
||||
}
|
||||
|
||||
function baseEnv(extra = {}) {
|
||||
return { ...TEST_ENV_BASE, ...extra };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a hook with a payload on stdin (or, for C4, no `input` key at all —
|
||||
* see below). Thin wrapper over the process-seam so every case in this file
|
||||
* shares one spawn path and one required timeout.
|
||||
*/
|
||||
function runHook(name, { payload, cwd, env, timeoutMs = 15000 } = {}) {
|
||||
const opts = { env: baseEnv(env), timeoutMs };
|
||||
if (cwd !== undefined) opts.cwd = cwd;
|
||||
if (payload !== undefined) {
|
||||
opts.input = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
}
|
||||
return runHookSeam(hookPath(name), [], opts);
|
||||
}
|
||||
|
||||
const MALFORMED_JSON = '{not json';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let fixtures = {};
|
||||
const cleanupPaths = [];
|
||||
|
||||
function tempDir(prefix) {
|
||||
const dir = createTempDir(prefix);
|
||||
cleanupPaths.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
// --- worktree fixture: a linked git worktree on an agent-* branch, used by
|
||||
// gsd-worktree-path-guard.js's deny case (absolute path escaping the active
|
||||
// worktree's git root) and gsd-windsurf-pre-write.js's deny case (same
|
||||
// escape shape, different protocol). ---------------------------------------
|
||||
const mainRepo = tempDir('hooks-crash-main-');
|
||||
gitOrThrow(['init', '-q', mainRepo], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['-C', mainRepo, 'config', 'user.email', 'hooks-crash@test.local'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['-C', mainRepo, 'config', 'user.name', 'hooks-crash'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
fs.writeFileSync(path.join(mainRepo, 'README.md'), 'hello\n');
|
||||
gitOrThrow(['-C', mainRepo, 'add', '-A'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['-C', mainRepo, 'commit', '-m', 'seed'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
const worktreePath = path.join(os.tmpdir(), `hooks-crash-wt-${process.pid}-${Date.now()}`);
|
||||
gitOrThrow(['-C', mainRepo, 'worktree', 'add', '-b', 'agent-test', worktreePath], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
cleanupPaths.push(worktreePath);
|
||||
|
||||
// --- gsd-write-guard.js: a curated ROADMAP.md big enough to trip the 40%
|
||||
// shrink-ratio guard (well above the FLOOR_LINES=40 exemption). -------------
|
||||
const wgProject = tempDir('hooks-crash-wg-');
|
||||
fs.mkdirSync(path.join(wgProject, '.planning'), { recursive: true });
|
||||
const roadmapPath = path.join(wgProject, '.planning', 'ROADMAP.md');
|
||||
fs.writeFileSync(roadmapPath, Array.from({ length: 292 }, (_, i) => `line ${i + 1}`).join('\n') + '\n');
|
||||
|
||||
// --- gsd-workflow-guard.js: a repo on an agent-* branch with
|
||||
// hooks.workflow_guard enabled, so `git add -f` trips the ONE hard-block. --
|
||||
const wfRepo = tempDir('hooks-crash-wf-');
|
||||
gitOrThrow(['init', '-q', '-b', 'agent-test', wfRepo], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['-C', wfRepo, 'config', 'user.email', 'hooks-crash@test.local'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['-C', wfRepo, 'config', 'user.name', 'hooks-crash'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
fs.mkdirSync(path.join(wfRepo, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(wfRepo, '.planning', 'config.json'), JSON.stringify({ hooks: { workflow_guard: true } }));
|
||||
fs.writeFileSync(path.join(wfRepo, 'seed.txt'), 'seed\n');
|
||||
gitOrThrow(['-C', wfRepo, 'add', '-A'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['-C', wfRepo, 'commit', '-m', 'seed'], { timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
|
||||
// --- gsd-agent-isolation-guard.js: `.planning/config.json` as a DIRECTORY
|
||||
// (EISDIR) — the guard's documented "cannot verify -> DENY" fail-closed
|
||||
// path (mirrors tests/gsd-agent-isolation-guard.test.cjs's own fixture). ---
|
||||
const aigProject = tempDir('hooks-crash-aig-');
|
||||
fs.mkdirSync(path.join(aigProject, '.planning', 'config.json'), { recursive: true });
|
||||
|
||||
fixtures = { mainRepo, worktreePath, wgProject, roadmapPath, wfRepo, aigProject };
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const p of cleanupPaths) cleanup(p);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The table — one row per enforcement hook, derived from reading its source.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABLE = [
|
||||
{
|
||||
file: 'gsd-worktree-path-guard.js',
|
||||
stdinTimeoutMs: 3000,
|
||||
declaredOnCrash: 'allow',
|
||||
// #3911: this hook's deny path depends on several bounded (2000ms)
|
||||
// spawnSync(git, ...) probes. Under load, a probe can time out before it
|
||||
// answers — the hook still allows (exit 0, unchanged), but now with a
|
||||
// stderr diagnostic instead of the pre-#3911 silent allow. See the C2
|
||||
// loop below and the dedicated stub-git regression suite.
|
||||
gitProbeMayRace: true,
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
deny: () => ({
|
||||
payload: { tool_name: 'Write', tool_input: { file_path: path.join(fixtures.mainRepo, 'README.md') } },
|
||||
cwd: fixtures.worktreePath,
|
||||
}),
|
||||
assertDeny: (r) => {
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.decision, 'block');
|
||||
assert.match(r.stderr, /differs from the active worktree root/);
|
||||
assert.equal(r.stderr, out.reason, 'stderr must carry the plain reason string (deny stderrPayload)');
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'gsd-write-guard.js',
|
||||
stdinTimeoutMs: 3000,
|
||||
declaredOnCrash: 'allow',
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
deny: () => ({
|
||||
payload: { tool_name: 'Write', tool_input: { file_path: fixtures.roadmapPath, content: 'short\n' } },
|
||||
env: { GSD_ALLOW_PLANNING_SHRINK: undefined },
|
||||
}),
|
||||
assertDeny: (r) => {
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.decision, 'block');
|
||||
assert.equal(out.oldLines, 292);
|
||||
assert.match(r.stderr, /shrink/);
|
||||
assert.equal(r.stderr, out.reason);
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'gsd-workflow-guard.js',
|
||||
stdinTimeoutMs: 3000,
|
||||
declaredOnCrash: 'allow',
|
||||
// #3911: the force-add block depends on a bounded (2000ms) spawnSync(git
|
||||
// branch --show-current) probe — see gitProbeMayRace note on the
|
||||
// gsd-worktree-path-guard.js row above.
|
||||
gitProbeMayRace: true,
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
deny: () => ({
|
||||
payload: { tool_name: 'Bash', cwd: fixtures.wfRepo, tool_input: { command: 'git add -f secret.txt' } },
|
||||
}),
|
||||
assertDeny: (r) => {
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.decision, 'block');
|
||||
assert.equal(out.code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
|
||||
assert.match(r.stderr, /force-add|force/i);
|
||||
assert.equal(r.stderr, out.reason);
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'gsd-context-monitor.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: 'allow',
|
||||
allow: () => ({ payload: {} }), // no session_id -> allow(undefined)
|
||||
},
|
||||
{
|
||||
file: 'gsd-config-reload.js',
|
||||
stdinTimeoutMs: 8000,
|
||||
declaredOnCrash: 'allow',
|
||||
allow: () => ({ payload: { file_path: '/tmp/not-a-gsd-config.json' } }), // basename mismatch -> allow
|
||||
},
|
||||
{
|
||||
file: 'gsd-read-injection-scanner.js',
|
||||
stdinTimeoutMs: 5000,
|
||||
declaredOnCrash: 'allow',
|
||||
allow: () => ({ payload: { tool_name: 'Bash' } }), // not in SCANNED_TOOLS -> allow
|
||||
},
|
||||
{
|
||||
file: 'gsd-prompt-guard.js',
|
||||
stdinTimeoutMs: 3000,
|
||||
declaredOnCrash: 'allow',
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-read-guard.js',
|
||||
stdinTimeoutMs: 3000,
|
||||
declaredOnCrash: 'allow',
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-agent-isolation-guard.js',
|
||||
stdinTimeoutMs: 3000,
|
||||
declaredOnCrash: 'allow',
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
deny: () => ({
|
||||
payload: { tool_name: 'Task', tool_input: { subagent_type: 'gsd-executor' } },
|
||||
cwd: fixtures.aigProject,
|
||||
env: { GSD_RUNTIME: undefined },
|
||||
}),
|
||||
assertDeny: (r) => {
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.decision, 'block');
|
||||
// REASON_CODE.CONFIG_UNREADABLE (hooks/lib/isolation-deny-reason.js) has
|
||||
// always been the lowercase string 'config_unreadable' — untouched by
|
||||
// the #3911 crash-policy migration. The uppercase literal here was
|
||||
// simply wrong.
|
||||
assert.equal(out.reason_code, 'config_unreadable');
|
||||
assert.match(r.stderr, /could not read or resolve/);
|
||||
assert.equal(r.stderr, out.reason);
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'gsd-windsurf-pre-command.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null, // catch calls allow(undefined) directly — no HOOK_ON_CRASH declared
|
||||
allow: () => ({ payload: { tool_info: { command_line: 'ls -la' } } }),
|
||||
deny: () => ({ payload: { tool_info: { command_line: 'rm -rf /' } } }),
|
||||
assertDeny: (r) => {
|
||||
assert.equal(r.stdout, '', 'deny(undefined, reason) must skip the fd 1 write entirely');
|
||||
assert.match(r.stderr, /rm -rf targeting the filesystem root/);
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'gsd-windsurf-pre-write.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null, // catch calls allow(undefined) directly — no HOOK_ON_CRASH declared
|
||||
// #3911: this hook's deny path depends on bounded (2000ms) spawnSync(git,
|
||||
// ...) probes, same shape as gsd-worktree-path-guard.js above.
|
||||
gitProbeMayRace: true,
|
||||
allow: () => ({ payload: { tool_info: { file_path: 'nonexistent.txt' } }, cwd: os.tmpdir() }),
|
||||
deny: () => ({
|
||||
payload: { tool_info: { file_path: path.join(fixtures.mainRepo, 'README.md') } },
|
||||
cwd: fixtures.worktreePath,
|
||||
}),
|
||||
assertDeny: (r) => {
|
||||
assert.equal(r.stdout, '', 'deny(undefined, reason) must skip the fd 1 write entirely');
|
||||
assert.match(r.stderr, /resolves to git root/);
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'gsd-statusline.js',
|
||||
stdinTimeoutMs: 3000,
|
||||
declaredOnCrash: 'allow',
|
||||
crashSkipReason:
|
||||
'the crash() call this hook declares ON_CRASH for guards only the require.main ' +
|
||||
"self-heal block (ensureRuntimeBuild() failing on an unbuilt gsd-core/bin/lib tree) — " +
|
||||
"the per-request stdin handler's own catch is a silent fail with no crash() call at all. " +
|
||||
'Forcing the self-heal path to fail would require corrupting the built lib tree or actually ' +
|
||||
'invoking a build, both out of scope for a behavioral spawn test.',
|
||||
allow: () => ({ payload: {} }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-ensure-canonical-path.js',
|
||||
stdinTimeoutMs: null, // never reads stdin at all — see the C4 note below
|
||||
declaredOnCrash: null, // no HOOK_ON_CRASH import/usage; allow(undefined) is unconditional
|
||||
allow: () => ({ payload: undefined }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-cursor-post-tool.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null,
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-cursor-pre-tool.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null,
|
||||
allow: () => ({ payload: { tool_name: 'Read' } }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-cursor-session-start.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null,
|
||||
allow: () => ({ payload: {} }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-cursor-stop.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null,
|
||||
allow: () => ({ payload: {} }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-cursor-subagent-start.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null,
|
||||
allow: () => ({ payload: {} }),
|
||||
},
|
||||
{
|
||||
file: 'gsd-cursor-subagent-stop.js',
|
||||
stdinTimeoutMs: 10000,
|
||||
declaredOnCrash: null,
|
||||
allow: () => ({ payload: {} }),
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C1 — allow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hooks-crash-policy: C1 normal allow -> exit 0', () => {
|
||||
for (const row of TABLE) {
|
||||
test(`${row.file}: allow input -> exit 0`, () => {
|
||||
const { payload, cwd, env } = row.allow();
|
||||
const r = runHook(row.file, { payload, cwd, env });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `expected a clean exit; got ${r.outcome} stderr=${r.stderr}`);
|
||||
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C2 — deny (only the 6 hooks with a real block path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hooks-crash-policy: C2 normal deny -> exit 2, correct stream(s)', () => {
|
||||
const denyRows = TABLE.filter((row) => typeof row.deny === 'function');
|
||||
|
||||
test('exactly 6 hooks in this table declare a deny case', () => {
|
||||
assert.equal(denyRows.length, 6, denyRows.map((r) => r.file).join(', '));
|
||||
});
|
||||
|
||||
for (const row of denyRows) {
|
||||
test(`${row.file}: deny input -> exit 2` + (row.gitProbeMayRace ? ' (or an undetermined-probe allow with a diagnostic, #3911)' : ''), () => {
|
||||
const { payload, cwd, env } = row.deny();
|
||||
const r = runHook(row.file, { payload, cwd, env });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `expected a clean exit; got ${r.outcome} stderr=${r.stderr}`);
|
||||
|
||||
if (!row.gitProbeMayRace) {
|
||||
assert.equal(r.exitCode, 2, `stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
row.assertDeny(r);
|
||||
return;
|
||||
}
|
||||
|
||||
// #3911: this row's deny path depends on a bounded spawnSync(git, ...)
|
||||
// probe that can, under load, time out before it answers — the ORIGINAL
|
||||
// real-race defect this test used to have (asserting exit 2 unconditionally
|
||||
// even though a slow git legitimately yields exit 0). A clean deny is
|
||||
// still the expected common case and is asserted identically to every
|
||||
// other row. The ONLY other acceptable outcome is an allow that carries a
|
||||
// non-empty stderr diagnostic naming the probe that could not run — a
|
||||
// SILENT allow (exit 0 with EMPTY stdout AND EMPTY stderr) is the actual
|
||||
// #3911 defect and MUST still fail this test.
|
||||
if (r.exitCode === 2) {
|
||||
row.assertDeny(r);
|
||||
return;
|
||||
}
|
||||
assert.equal(
|
||||
r.exitCode, 0,
|
||||
`expected either a clean deny (exit 2) or an undetermined-probe allow (exit 0); ` +
|
||||
`got exitCode=${r.exitCode}. stdout=${r.stdout} stderr=${r.stderr}`
|
||||
);
|
||||
assert.notEqual(
|
||||
r.stderr, '',
|
||||
`a git-probe timeout must emit a stderr diagnostic naming the probe (#3911) — got a ` +
|
||||
`SILENT allow (empty stdout AND empty stderr), which is the exact defect this test exists ` +
|
||||
`to catch. stdout=${r.stdout}`
|
||||
);
|
||||
assert.match(
|
||||
r.stderr, /git probe/,
|
||||
`stderr diagnostic must name the git probe that could not run; got: ${r.stderr}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C3 — crash honors the DECLARED policy (malformed JSON forces the outer
|
||||
// catch). Hooks with no declared policy are t.skip()'d, never bare-returned.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hooks-crash-policy: C3 crash -> declared ON_CRASH policy', () => {
|
||||
const EXPECTED_EXIT = { allow: 0, deny: 2 };
|
||||
|
||||
for (const row of TABLE) {
|
||||
test(`${row.file}: malformed-JSON crash honors declared policy`, (t) => {
|
||||
if (!row.declaredOnCrash) {
|
||||
t.skip(
|
||||
`${row.file} never calls crash(ON_CRASH, ...) — its outer catch calls allow()/nothing ` +
|
||||
'directly, so it declares no HOOK_ON_CRASH policy for this case to verify.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (row.crashSkipReason) {
|
||||
t.skip(row.crashSkipReason);
|
||||
return;
|
||||
}
|
||||
const r = runHook(row.file, { payload: MALFORMED_JSON });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `expected a clean exit; got ${r.outcome} stderr=${r.stderr}`);
|
||||
assert.equal(
|
||||
r.exitCode,
|
||||
EXPECTED_EXIT[row.declaredOnCrash],
|
||||
`declared ON_CRASH=${row.declaredOnCrash} -> expected exit ${EXPECTED_EXIT[row.declaredOnCrash]}; ` +
|
||||
`stdout=${r.stdout} stderr=${r.stderr}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C4 — stdin never closes: no `input` is supplied to the seam at all, so the
|
||||
// child's stdin pipe is left open. Only the hook's OWN bounded stdin-timeout
|
||||
// (-> allow() -> terminateNow -> a real process.exit) can end it; a bare
|
||||
// `process.exitCode = N` inside that timer would never actually terminate a
|
||||
// process still blocked reading stdin. Never assert on elapsed time — only
|
||||
// that it terminated, and with what code.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hooks-crash-policy: C4 stdin never closes -> bounded termination, not a hang', () => {
|
||||
for (const row of TABLE) {
|
||||
test(`${row.file}: unclosed stdin still terminates`, () => {
|
||||
const outerTimeoutMs = (row.stdinTimeoutMs ?? 3000) + 10000;
|
||||
// No `payload` key at all -> the seam omits `options.input` -> the
|
||||
// child's stdin is never written to or closed by the parent.
|
||||
const r = runHook(row.file, { timeoutMs: outerTimeoutMs });
|
||||
assert.equal(
|
||||
r.outcome, OUTCOME.EXITED,
|
||||
`expected the hook's own stdin-timeout to terminate it before the outer ` +
|
||||
`${outerTimeoutMs}ms spawn bound; got ${r.outcome} (a TIMED_OUT/KILLED outcome here means ` +
|
||||
`the hook hung on stdin instead of self-terminating). stderr=${r.stderr}`
|
||||
);
|
||||
assert.equal(r.exitCode, 0, `expected the stdin-timeout's allow() fallback (exit 0); stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #3911 regression — deterministic git-probe timeout (no load required).
|
||||
//
|
||||
// The C2 loop above tolerates a raced timeout but cannot FORCE one: on a
|
||||
// quiet machine the git probes in gsd-worktree-path-guard.js,
|
||||
// gsd-workflow-guard.js, and gsd-windsurf-pre-write.js always answer well
|
||||
// inside their 2000ms budget, so C2 alone would never actually exercise the
|
||||
// undetermined-probe branch. This suite forces the timeout deterministically
|
||||
// by putting a stub `git` on PATH that sleeps past every affected hook's own
|
||||
// spawnSync timeout (2000ms) before exiting — the hook's own bounded budget,
|
||||
// not real system load, is what triggers ETIMEDOUT, so this is reproducible
|
||||
// on any machine. Never asserts on elapsed time — only on exit code and the
|
||||
// stderr diagnostic's presence/content.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hooks-crash-policy: #3911 git-probe timeout forced via a stub git -> allow WITH a diagnostic', () => {
|
||||
// Exceeds every affected hook's own spawnSync(git, ...) timeout (2000ms) —
|
||||
// the hook's timeout fires and kills the stub first, so this value only
|
||||
// needs to outlast 2000ms; it is never itself asserted on.
|
||||
const GIT_STUB_SLEEP_MS = 3000;
|
||||
|
||||
let stubDir;
|
||||
|
||||
before(() => {
|
||||
stubDir = tempDir('hooks-crash-git-stub-');
|
||||
// Not built on win32: the affected hooks spawn `git` via spawnSync with
|
||||
// no `shell: true` (see hooks/gsd-worktree-path-guard.js's SPAWNOPT-based
|
||||
// `spawnSync('git', args, { ...SPAWNOPT, cwd })` call), so Windows'
|
||||
// CreateProcess resolves `git.exe` only and never a PATH `.cmd`/`.bat`
|
||||
// shim — a stub written here could never be exercised. See the
|
||||
// win32-only t.skip() on each case below.
|
||||
if (process.platform !== 'win32') {
|
||||
const shPath = path.join(stubDir, 'git');
|
||||
fs.writeFileSync(shPath, `#!/bin/sh\nsleep ${(GIT_STUB_SLEEP_MS / 1000).toFixed(3)}\nexit 0\n`);
|
||||
fs.chmodSync(shPath, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
// Every affected hook resolves 'git' with NO explicit `env` override on its
|
||||
// own spawnSync call (see hooks/*.js's `SPAWNOPT`/`currentBranch`), so it
|
||||
// inherits the HOOK PROCESS's own `process.env.PATH` — which is exactly the
|
||||
// `env` this test hands the hook via runHook()/baseEnv(). Setting PATH to
|
||||
// ONLY the stub dir guarantees the hook's internal git spawn resolves to
|
||||
// the stub, not a real git binary that might legitimately be fast.
|
||||
const CASES = [
|
||||
{
|
||||
file: 'gsd-worktree-path-guard.js',
|
||||
build: () => ({
|
||||
payload: { tool_name: 'Write', tool_input: { file_path: path.join(os.tmpdir(), 'gsd-3911-stub-target.txt') } },
|
||||
}),
|
||||
},
|
||||
{
|
||||
file: 'gsd-workflow-guard.js',
|
||||
build: () => {
|
||||
const wfProject = tempDir('hooks-crash-wf-stub-');
|
||||
fs.mkdirSync(path.join(wfProject, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(wfProject, '.planning', 'config.json'),
|
||||
JSON.stringify({ hooks: { workflow_guard: true } })
|
||||
);
|
||||
return {
|
||||
payload: { tool_name: 'Bash', cwd: wfProject, tool_input: { command: 'git add -f secret.txt' } },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'gsd-windsurf-pre-write.js',
|
||||
build: () => ({
|
||||
payload: { tool_info: { file_path: 'gsd-3911-stub-target.txt' } },
|
||||
cwd: os.tmpdir(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of CASES) {
|
||||
test(`${c.file}: git timing out still allows, WITH a stderr diagnostic naming the probe (not silent)`, (t) => {
|
||||
if (process.platform === 'win32') {
|
||||
// See hooks/gsd-worktree-path-guard.js's spawnSync('git', args, { ...SPAWNOPT, cwd })
|
||||
// call (no `shell: true`): CreateProcess resolves git.exe only.
|
||||
t.skip(
|
||||
'win32: the hooks spawn git via spawnSync without shell:true, so CreateProcess ' +
|
||||
'resolves git.exe only and never a PATH .cmd shim — the probe cannot be intercepted ' +
|
||||
'here. Covered on linux and darwin.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { payload, cwd } = c.build();
|
||||
const r = runHook(c.file, {
|
||||
payload,
|
||||
cwd,
|
||||
// The stub dir must resolve FIRST (git() calls carry no explicit `env`
|
||||
// override, so they inherit this exact PATH) — but the stub script
|
||||
// itself still needs a working `sh`/`sleep`, so the real PATH is
|
||||
// appended after it, never before (a real `git` earlier in PATH would
|
||||
// defeat the stub entirely).
|
||||
env: { PATH: [stubDir, process.env.PATH].filter(Boolean).join(path.delimiter) },
|
||||
timeoutMs: GIT_STUB_SLEEP_MS + 15000,
|
||||
});
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `expected a clean exit; got ${r.outcome} stderr=${r.stderr}`);
|
||||
assert.equal(
|
||||
r.exitCode, 0,
|
||||
`a git-probe timeout must still ALLOW (exit code unchanged — #3911 requires no hook's ` +
|
||||
`effective default changes); stdout=${r.stdout} stderr=${r.stderr}`
|
||||
);
|
||||
assert.notEqual(
|
||||
r.stderr, '',
|
||||
`a git-probe timeout must emit a stderr diagnostic instead of the pre-#3911 SILENT allow ` +
|
||||
`(empty stdout AND empty stderr). stdout=${r.stdout}`
|
||||
);
|
||||
assert.match(
|
||||
r.stderr, /git probe/,
|
||||
`stderr diagnostic must name the git probe that could not run; got: ${r.stderr}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hooks/dist parity (#3911 review finding) — lint:hooks-runtime-build-seam
|
||||
// only checks that a hook requiring a compiled gsd-core/bin/lib/*.cjs module
|
||||
// also calls ensureRuntimeBuild(); it never compares hooks/dist/** against
|
||||
// hooks/**. Nothing else in the suite proves the three files P7 adds under
|
||||
// hooks/lib/ (cli-exit.js, exit-code-registry.js, hook-exit.js) actually
|
||||
// reach hooks/dist/lib/ — the exact shape of #770 (a new hook file silently
|
||||
// missing from a copy list). This is behavioral, not a source-grep: it
|
||||
// builds the real hooks/dist via the same ensureBuiltHooks() chokepoint
|
||||
// scripts/run-tests.cjs uses, byte-compares the shipped copies, and spawns a
|
||||
// child that actually requires and calls the SHIPPED copy from its dist
|
||||
// location (proving it can resolve its sibling registry there, not just
|
||||
// that the bytes exist).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hooks-crash-policy: hooks/dist/lib parity (#3911 review finding)', () => {
|
||||
const HOOKS_LIB_DIR = path.join(HOOKS_DIR, 'lib');
|
||||
const DIST_LIB_DIR = path.join(HOOKS_DIR, 'dist', 'lib');
|
||||
const SEAM_FILES = ['cli-exit.js', 'exit-code-registry.js', 'hook-exit.js'];
|
||||
|
||||
let buildFailure = null;
|
||||
|
||||
before(() => {
|
||||
try {
|
||||
// No overrides: this deliberately builds/verifies the REAL hooks/dist,
|
||||
// the same gitignored-but-real artifact the installer ships from — a
|
||||
// temp destination would not prove anything about what users get.
|
||||
ensureBuiltHooks();
|
||||
} catch (e) {
|
||||
buildFailure = e;
|
||||
}
|
||||
});
|
||||
|
||||
for (const name of SEAM_FILES) {
|
||||
test(`hooks/dist/lib/${name} exists and is byte-identical to hooks/lib/${name}`, (t) => {
|
||||
if (buildFailure) {
|
||||
t.skip(`ensureBuiltHooks() failed to populate hooks/dist: ${buildFailure.message}`);
|
||||
return;
|
||||
}
|
||||
const srcPath = path.join(HOOKS_LIB_DIR, name);
|
||||
const distPath = path.join(DIST_LIB_DIR, name);
|
||||
if (!fs.existsSync(distPath)) {
|
||||
t.skip(`${distPath} does not exist after ensureBuiltHooks() — build seam did not ship it`);
|
||||
return;
|
||||
}
|
||||
const srcBytes = fs.readFileSync(srcPath);
|
||||
const distBytes = fs.readFileSync(distPath);
|
||||
assert.ok(
|
||||
srcBytes.equals(distBytes),
|
||||
`hooks/dist/lib/${name} is not byte-identical to hooks/lib/${name} — the build seam shipped a stale or divergent copy`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped hooks/dist/lib/cli-exit.js is functional from its dist location (resolves its sibling registry)', (t) => {
|
||||
if (buildFailure) {
|
||||
t.skip(`ensureBuiltHooks() failed to populate hooks/dist: ${buildFailure.message}`);
|
||||
return;
|
||||
}
|
||||
const distCliExitPath = path.join(DIST_LIB_DIR, 'cli-exit.js');
|
||||
if (!fs.existsSync(distCliExitPath)) {
|
||||
t.skip(`${distCliExitPath} does not exist after ensureBuiltHooks() — build seam did not ship it`);
|
||||
return;
|
||||
}
|
||||
// Spawn a child that requires the SHIPPED copy from ITS OWN dist
|
||||
// location and drives the one sanctioned process.exit() call site
|
||||
// (terminateNow) with a HOOK_DENY outcome. A dist copy that exists but
|
||||
// whose sibling require('./exit-code-registry.js') cannot resolve from
|
||||
// hooks/dist/lib/ (e.g. only cli-exit.js shipped, not the whole
|
||||
// directory) would throw here instead of exiting 2 — this is the case a
|
||||
// byte-comparison alone cannot catch.
|
||||
const script = [
|
||||
`const { terminateNow } = require(${JSON.stringify(distCliExitPath)});`,
|
||||
`terminateNow('HOOK_DENY', { x: 1 });`,
|
||||
].join('\n');
|
||||
const r = runNode(['-e', script], { timeoutMs: 15000 });
|
||||
assert.equal(r.outcome, OUTCOME.EXITED, `expected a clean exit; got ${r.outcome} stderr=${r.stderr}`);
|
||||
assert.equal(r.exitCode, 2, `expected HOOK_DENY's registered exit code 2; stdout=${r.stdout} stderr=${r.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drift guard — the table must not silently fall behind hooks/*.js.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hooks-crash-policy: table drift guard', () => {
|
||||
test('every terminating hook file under hooks/ has a table row, and vice versa', () => {
|
||||
const onDisk = fs.readdirSync(HOOKS_DIR)
|
||||
.filter((name) => name.endsWith('.js') && !NON_TERMINATING_HOOKS.has(name))
|
||||
.sort();
|
||||
const inTable = TABLE.map((row) => row.file).sort();
|
||||
assert.deepEqual(
|
||||
inTable, onDisk,
|
||||
'hooks/*.js and this table have drifted — add/remove a row so every ' +
|
||||
'enforcement hook (excluding the declared NON_TERMINATING_HOOKS) is covered.'
|
||||
);
|
||||
});
|
||||
|
||||
test('NON_TERMINATING_HOOKS names actually exist on disk (no stale exclusion)', () => {
|
||||
for (const name of NON_TERMINATING_HOOKS) {
|
||||
assert.ok(fs.existsSync(hookPath(name)), `${name} is excluded but no longer exists under hooks/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ const path = require('node:path');
|
||||
|
||||
const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
@@ -586,21 +587,21 @@ describe('GROUP C: bundle-directory-name-agnostic hook scripts', () => {
|
||||
t.after(() => cleanup(tmpRoot));
|
||||
|
||||
const bundleDir = path.join(tmpRoot, 'gsd-hooks');
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
// Stage via copyScriptWithDeps (walks the real require graph — see its doc
|
||||
// comment / CLAUDE.md "no new copyFileSync line") into a throwaway staging
|
||||
// root, then relocate the staged hooks/ subtree onto `gsd-hooks` — a
|
||||
// NON-"hooks"-named directory is exactly the condition this test exists to
|
||||
// exercise (#3023), so the staged tree cannot simply be used at its
|
||||
// repo-relative `hooks/` location. A hand-copied dependency list is what
|
||||
// caused this exact fixture to miss the scanner's #3911 `./lib/hook-exit.js`
|
||||
// require (which itself pulls in cli-exit.js + exit-code-registry.js) —
|
||||
// deriving the list instead of re-declaring it means a future require added
|
||||
// to the scanner (or any of its deps) cannot be silently omitted here again.
|
||||
const stageRoot = createTempDir('fix-3023-scanner-stage-');
|
||||
t.after(() => cleanup(stageRoot));
|
||||
copyScriptWithDeps(REPO_ROOT, stageRoot, 'hooks/gsd-read-injection-scanner.js');
|
||||
fs.cpSync(path.join(stageRoot, 'hooks'), bundleDir, { recursive: true });
|
||||
const scannerPath = path.join(bundleDir, 'gsd-read-injection-scanner.js');
|
||||
fs.copyFileSync(path.join(REPO_ROOT, 'hooks', 'gsd-read-injection-scanner.js'), scannerPath);
|
||||
// #3504: the scanner now requires hooks/lib/injection-patterns.js. Every
|
||||
// real staging surface ships lib/ alongside the hook (installSharedHooksBundle
|
||||
// copies dist recursively AND stages hooks/lib from the GSD_HOOK_LIB_FILES
|
||||
// allowlist into the same shared dir), so this lone-file emulation must
|
||||
// stage the dependency the same way — a missing lib file is a packaging
|
||||
// bug that fails loud at hook load (#2587), which is exactly what the
|
||||
// un-staged version of this fixture now demonstrates.
|
||||
fs.mkdirSync(path.join(bundleDir, 'lib'), { recursive: true });
|
||||
fs.copyFileSync(
|
||||
path.join(REPO_ROOT, 'hooks', 'lib', 'injection-patterns.js'),
|
||||
path.join(bundleDir, 'lib', 'injection-patterns.js'),
|
||||
);
|
||||
|
||||
// Node canonicalizes a module's __dirname via the REAL (symlink-resolved)
|
||||
// path, so a payload path must be built from the same realpath — on macOS
|
||||
|
||||
Reference in New Issue
Block a user