Files
msd-core/tests/portable-node-runner.install.test.cjs
Cody Anderson 77e2472ca0 enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook (#4236)
* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration

Add hooks/gsd-secret-read-guard.js, a blocking PreToolUse guard on
Read|Grep|Bash that denies reads of .env, .env.<suffix> and .secrets
(the .env.example/.sample/.template/.dist templates stay readable).
Read checks file_path; Grep checks an explicit path and judges the glob
per brace alternative; Bash runs a two-pass token scan (quotes, comments,
redirects with fd digits, separators, $( )/backtick/<( ) recursion,
heredoc bodies never scanned as commands, nested bash -c/eval rescans,
git <ref>:<path> shapes) with a closed non-reading exemption set for
existence checks. Fail-open crash policy; 1 MiB commands are denied as
command-too-large; more than 64 glob alternatives as glob-too-complex.

Why: Claude Code 2.1.259 makes every `cd DIR && grep …` compound prompt
for approval whenever any Read() deny rule exists, even in auto mode. A
hook denial is not a permission rule and never arms that check. The
installer-written deny rules are retired in the follow-up commit.

Registration: hooks.json (Read|Grep|Bash, timeout 5), build-hooks
HOOKS_TO_COPY, managed-hooks-registry, runtime-hooks-surface (blocking
guard with BLOCKING_GUARD_TIMEOUT_S; Kimi ReadFile|Grep|Shell),
shell-command-projection managed sets, installer-migration-report,
OpenCode/Kilo plugin (grep tool mapping, include -> glob, dispatch),
docs tables in five locales, ADR-766 always-on list, regen:derived
fixtures, and a new table-driven unit suite.

* test(#4221): pin the secret-read guard in existing hook gates

Register gsd-secret-read-guard.js in every existing hook gate: the
hooks-crash-policy table (deny row; 6 -> 7 deny cases), plugin-manifest
REQUIRED_HOOKS and its Read|Grep|Bash group, docs-hooks-table-parity
EXPECTED_SURFACE_HOOKS, install.test MANAGED_JS_HOOKS, install-minimal-
hooks JS_HOOKS/BLOCKING_GUARDS, portable-node-runner GUARD_HOOKS,
kilo-upgrades PLUGIN_GUARD_HOOKS, the Kimi normalization-parity and
typed-payload floors, the OpenCode adapter (grep mapping, include ->
glob, three dispatch tests) and a Kimi TOML matcher assertion.

* fix(#4221): retire installer Read() deny rules (legacy filter)

Rename GSD_CLAUDE_DENY_PERMISSIONS to GSD_CLAUDE_LEGACY_DENY_PERMISSIONS
and stop adding the three Read(.env) / Read(.env.*) / Read(.secrets)
strings. mergeClaudePermissions now only filters them out of an existing
permissions.deny: an absent deny key stays absent, a malformed one is
still repaired to [], and an array emptied by the filter is deleted so
no `"deny": []` residue is left. Uninstall filters the same legacy list
and, symmetric with the Antigravity branch, drops an emptied allow or
deny key and an emptied permissions object.

Unlike the #2278 allow-side migration there is no surviving current
deny list, so the constant is renamed rather than mirrored. Removal is
byte-exact: a hand-written identical rule is indistinguishable from the
installer's and is removed too (the manifest never recorded permission
strings). USER-GUIDE and CONTEXT.md updated.

* test(#4221): flip install-regressions deny-rule assertions to the retired shape

The fresh-merge, non-destructive merge, idempotency, end-to-end install,
reinstall and uninstall assertions now expect no Read(.env*) deny rules
and no permissions.deny key on a fresh install; the deny:null repair case
is kept. A new describe block covers the legacy filter: retired strings
removed with a user entry kept, partial sets, near-miss strings
untouched, idempotency, GSD-only deny array deleted, a pre-existing
empty deny preserved, and uninstall symmetry for allow/deny/permissions.

* chore(#4221): add changeset fragment for PR #4236

* fix(#4221): case-fold names; scan shell stdin and xargs pipes

Review round 1 (trek-e):

- Blocker: secret-name matching is now case-insensitive in the Read,
  Grep (path and glob) and Bash paths, so `.ENV` / `.Secrets` on a
  case-insensitive filesystem are recognized as the same secret file.
- Major: a shell interpreter's script is now scanned wherever it comes
  from. The tokenizer keeps heredoc bodies as per-segment tokens and
  records separator operators; pass 2 groups by segment id and resolves
  bash/sh/zsh/dash/ksh/su invocation mode: `-c` (including combined
  `-lc`) scans the script operand, a file operand is checked as a file
  (a `<( )` operand's echo/printf output is reconstructed), otherwise
  stdin is the script and heredocs, here-strings and a piped echo/printf
  source are scanned. `eval` joins all its operands; `source`/`.` handle
  process substitution. Data heredocs (`cat <<EOF`, the commit-message
  shape) stay unscanned.
- Major: `… | xargs <cmd>` checks the upstream segment's operands as
  file names when the sub-command reads (`echo .env | xargs cat`,
  `find . -name .env | xargs cat`); `-a`/`--arg-file` suppresses the
  inference; a shell sub-command's `-c` script is scanned.

Header, USER-GUIDE bullet and changeset updated; documented gaps now
include piped scripts from non-echo sources and `exec`/`timeout`
wrappers. 60 new suite cases pin the block and allow shapes.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:00:08 -04:00

557 lines
24 KiB
JavaScript

const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runNode, runHook } = require('./helpers/process-seam.cjs');
const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
const REPO_ROOT = path.join(__dirname, '..');
const RESOLVER_HOOK = 'gsd-node-runner.sh';
const FOREIGN_NODE = '/nonexistent-envA/bin/node';
const STUB_HOOK_BODY = ['#!/usr/bin/env node', 'process.stdout.write("hook-ran-ok");', ''].join('\n');
const MANAGED_JS_HOOKS = [
'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-statusline.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
];
const GUARD_HOOKS = [
'gsd-write-guard.js',
'gsd-agent-isolation-guard.js',
'gsd-worktree-path-guard.js',
'gsd-secret-read-guard.js',
];
// Every quoted absolute-node token (POSIX-form as emitted, .exe for win32
// projections) becomes the foreign environment's nonexistent path — the exact
// string a shared config root holds in environment B.
function foreignizeNodeTokens(command) {
return command.replace(/"(\/[^"]*\/node(\.exe)?)"/g, `"${FOREIGN_NODE}"`);
}
function isAbsoluteNodeTokenQuoted(token) {
return /^"(\/[^"]*\/node(\.exe)?)?"$/.test(token);
}
function makeHookTree(t, homeDirName) {
const home = createTempDir(homeDirName);
const configDir = path.join(home, '.claude');
fs.mkdirSync(path.join(configDir, 'hooks'), { recursive: true });
for (const hook of [...MANAGED_JS_HOOKS, ...GUARD_HOOKS]) {
fs.writeFileSync(path.join(configDir, 'hooks', hook), STUB_HOOK_BODY);
}
fs.writeFileSync(path.join(configDir, 'hooks', 'gsd-session-state.sh'), '#!/bin/sh\nexit 0\n');
const repoResolver = path.join(REPO_ROOT, 'hooks', RESOLVER_HOOK);
if (fs.existsSync(repoResolver)) {
fs.copyFileSync(repoResolver, path.join(configDir, 'hooks', RESOLVER_HOOK));
}
t.after(() => cleanup(home));
return { home, configDir };
}
function withHome(t, home) {
const origHome = process.env.HOME;
const origProfile = process.env.USERPROFILE;
process.env.HOME = home;
process.env.USERPROFILE = home;
t.after(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = origProfile;
});
}
function makeNodeStub(t, label) {
const dir = createTempDir(`gsd3662-stub-${label}`);
const stubPath = path.join(dir, 'node');
fs.writeFileSync(stubPath, ['#!/bin/sh', `echo "STUB-NODE:$1"`, ''].join('\n'));
fs.chmodSync(stubPath, 0o755);
t.after(() => cleanup(dir));
return stubPath;
}
function posixRealNodeDir() {
return process.execPath.replace(/\\/g, '/').replace(/\/[^/]*$/, '');
}
function writeCommandFile(t, command, name) {
const file = path.join(createTempDir('gsd3662-cmd'), `${name}.cmd`);
fs.writeFileSync(file, command + '\n');
t.after(() => cleanup(path.dirname(file)));
return file;
}
// Local class departure (CONTRIBUTING "Class-norm timeouts"): each execution
// row spans a bash spawn + a node child against a tiny script; 20s absorbs
// cold CI-runner startup for both without approaching the 600s ceiling. The
// existing classes (PROBE/GIT/BUILD/INSTALL) each describe a single-process
// shape, not this bash+node pair.
const RUN_TIMEOUT_MS = 20000;
// POSIX-sh execution semantics (the emitted command grammar) are proven on the
// POSIX lanes; win32 lanes prove the structural projection (see the win32
// describe below) — Git Bash availability is not guaranteed on every runner.
function skipOnWin32(t, reason) {
if (process.platform === 'win32') {
t.skip(reason);
return true;
}
return false;
}
describe('#3662 runtime-resolving managed hook runners', () => {
describe('emitted commands resolve node at run time (criteria 1+2)', () => {
test('portable hook command resolves node when baked path is absent', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'portable-run');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
portableHooks: true,
runtime: 'claude',
});
assert.ok(typeof emitted === 'string' && emitted.length > 0);
const foreign = foreignizeNodeTokens(emitted);
const file = writeCommandFile(t, foreign, 'portable');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${foreign}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
test('plain hook command resolves node when baked path is absent', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'plain-run');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
});
assert.ok(typeof emitted === 'string' && emitted.length > 0);
const foreign = foreignizeNodeTokens(emitted);
const file = writeCommandFile(t, foreign, 'plain');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${foreign}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
test('chain prefers the baked absolute runner first', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'chain-order');
withHome(t, home);
const stub = makeNodeStub(t, 'chain-order');
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
execPath: stub,
});
assert.ok(typeof emitted === 'string', `no command emitted: ${emitted}`);
const file = writeCommandFile(t, emitted, 'chain-order');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /^STUB-NODE:\S+gsd-statusline\.js/);
});
test('resolver prefers its first argument', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'resolver-order');
withHome(t, home);
const stub = makeNodeStub(t, 'resolver-order');
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
portableHooks: true,
runtime: 'claude',
execPath: stub,
});
assert.ok(typeof emitted === 'string', `no command emitted: ${emitted}`);
const file = writeCommandFile(t, emitted, 'resolver-order');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /^STUB-NODE:\S+gsd-statusline\.js/);
});
test('chain resolves with the real execPath under a minimal PATH', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane; win32 structural coverage below')) return;
const { home, configDir } = makeHookTree(t, 'minimal-path');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
});
assert.ok(typeof emitted === 'string');
const file = writeCommandFile(t, emitted, 'minimal-path');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: '/usr/bin:/bin' },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
test('no bare node token in any emitted hook command', (t) => {
const { home, configDir } = makeHookTree(t, 'bare-node');
withHome(t, home);
for (const hook of MANAGED_JS_HOOKS) {
for (const portableHooks of [false, true]) {
const emitted = hooksSurface.buildHookCommand(configDir, hook, {
portableHooks,
runtime: 'claude',
});
assert.ok(typeof emitted === 'string', `${hook} (portable=${portableHooks}) emitted nothing`);
assert.ok(
!/(^|\s)["']?node["']?(\s|$)/.test(emitted),
`${hook} (portable=${portableHooks}) carries a bare node token: ${emitted}`,
);
}
}
});
test('every managed js hook emits a resolving runner', (t) => {
const { home, configDir } = makeHookTree(t, 'all-managed');
withHome(t, home);
for (const hook of MANAGED_JS_HOOKS) {
const plain = hooksSurface.buildHookCommand(configDir, hook, { runtime: 'claude' });
assert.ok(
typeof plain === 'string' && plain.startsWith('"$(for n in '),
`${hook} plain runner is not the resolve chain: ${plain}`,
);
const portable = hooksSurface.buildHookCommand(configDir, hook, {
portableHooks: true,
runtime: 'claude',
});
assert.ok(
typeof portable === 'string' && portable.includes(RESOLVER_HOOK),
`${hook} portable command does not route through the resolver: ${portable}`,
);
}
});
});
describe('update convergence — the mixed state cannot persist (criterion 3)', () => {
function settingsWith(entries) {
return {
hooks: {
PreToolUse: [
{
matcher: 'Write',
hooks: entries.map((command) => ({ type: 'command', command })),
},
],
},
};
}
test('update converges the mixed-state foreign runners from the issue', () => {
const settings = settingsWith([
`"${FOREIGN_NODE}" "/home/u/.claude/hooks/gsd-write-guard.js"`,
`"/usr/bin/node" "/home/u/.claude/hooks/gsd-read-guard.js"`,
]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, true, 'rewriter reported no change over a mixed-state install');
for (const entry of settings.hooks.PreToolUse[0].hooks) {
assert.ok(
entry.command.startsWith('"$(for n in '),
`entry not converged to the resolve chain: ${entry.command}`,
);
assert.ok(
!isAbsoluteNodeTokenQuoted(entry.command.split(' ')[0] || ''),
`absolute runner survived: ${entry.command}`,
);
}
});
test('rewriter converges the three guard hooks', () => {
const settings = settingsWith(
GUARD_HOOKS.map((hook) => `"${FOREIGN_NODE}" "/home/u/.claude/hooks/${hook}"`),
);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, true);
for (const entry of settings.hooks.PreToolUse[0].hooks) {
assert.ok(
entry.command.startsWith('"$(for n in '),
`guard entry not converged: ${entry.command}`,
);
}
});
test('user and args-form entries are never rewritten', () => {
const userCommand = `"/usr/bin/node" "/home/u/own-tools/not-gsd.js"`;
const argsFormCommand = `"${FOREIGN_NODE}" "/home/u/.claude/hooks/gsd-statusline.js"`;
const settings = {
hooks: {
PreToolUse: [
{
matcher: 'Write',
hooks: [
{ type: 'command', command: userCommand },
{ type: 'command', command: argsFormCommand, args: ['extra'] },
],
},
],
},
};
hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, hooksSurface.buildNodeRunnerChainToken(), {
platform: 'darwin',
runtime: 'claude',
});
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, userCommand);
assert.equal(settings.hooks.PreToolUse[0].hooks[1].command, argsFormCommand);
});
test('unmanaged basenames are not converged', () => {
const command = `"${FOREIGN_NODE}" "/home/u/.claude/hooks/my-own-hook.js"`;
const settings = settingsWith([command]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, false);
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, command);
});
test('legacy bare-node entries are still converged', () => {
const settings = settingsWith([`node "/home/u/.claude/hooks/gsd-statusline.js"`]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, true);
assert.ok(
settings.hooks.PreToolUse[0].hooks[0].command.startsWith('"$(for n in '),
`bare-node entry not converged: ${settings.hooks.PreToolUse[0].hooks[0].command}`,
);
});
test('rewriter is idempotent and leaves resolving shapes alone', () => {
const chainCommand = `"$(for n in "${FOREIGN_NODE}" "$(command -v node)" /usr/local/bin/node /usr/bin/node; do [ -x "$n" ] && { [ "\${n#/}" != "$n" ] || [ "\${n#?:}" != "$n" ]; } && printf '%s' "$n" && break; done)" "/home/u/.claude/hooks/gsd-statusline.js"`;
const resolverCommand = `bash "/home/u/.claude/hooks/${RESOLVER_HOOK}" "${FOREIGN_NODE}" "/home/u/.claude/hooks/gsd-statusline.js"`;
const settings = settingsWith([chainCommand, resolverCommand]);
const changed = hooksSurface.rewriteLegacyManagedNodeHookCommands(
settings,
hooksSurface.buildNodeRunnerChainToken(),
{ platform: 'darwin', runtime: 'claude' },
);
assert.equal(changed, false, 'already-resolving entries were churned');
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, chainCommand);
assert.equal(settings.hooks.PreToolUse[0].hooks[1].command, resolverCommand);
});
});
describe('resolver script (hooks/gsd-node-runner.sh)', () => {
test('installer stages the node resolver script', () => {
const repoResolver = path.join(REPO_ROOT, 'hooks', RESOLVER_HOOK);
assert.ok(fs.existsSync(repoResolver), `${RESOLVER_HOOK} missing from hooks/`);
assert.ok(
HOOKS_TO_COPY.includes(RESOLVER_HOOK),
`${RESOLVER_HOOK} missing from HOOKS_TO_COPY (build-hooks.js)`,
);
const build = runNode([path.join(REPO_ROOT, 'scripts', 'build-hooks.js')], {
timeoutMs: BUILD_TIMEOUT_MS,
});
assert.equal(build.exitCode, 0, `build-hooks failed: ${build.stderr}`);
assert.ok(
fs.existsSync(path.join(REPO_ROOT, 'hooks', 'dist', RESOLVER_HOOK)),
`${RESOLVER_HOOK} not copied to hooks/dist/`,
);
});
test('resolver fails visibly when no node can be found', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'resolver-none');
const resolver = path.join(configDir, 'hooks', RESOLVER_HOOK);
assert.ok(fs.existsSync(resolver), 'resolver not staged into fixture');
const emptyBin = createTempDir('gsd3662-emptybin');
t.after(() => cleanup(emptyBin));
const result = runHook(resolver, [FOREIGN_NODE, path.join(configDir, 'hooks', 'gsd-statusline.js')], {
interpreter: 'bash',
cwd: home,
env: {
...process.env,
HOME: home,
PATH: `${emptyBin}:/usr/bin:/bin`,
GSD_NODE_RUNNER_NO_FALLBACKS: '1',
},
timeoutMs: RUN_TIMEOUT_MS,
});
assert.notEqual(result.exitCode, 0, 'resolver silently succeeded with no node anywhere');
assert.ok(result.stderr.length > 0, 'resolver produced no stderr diagnostic');
assert.ok(!/\bnode\b\s+\S*gsd-statusline/.test(result.stderr + result.stdout), 'resolver leaked a bare-node invocation');
});
});
describe('unchanged surfaces (criterion 5)', () => {
test('sh hook commands are unchanged', (t) => {
const { home, configDir } = makeHookTree(t, 'sh-pins');
withHome(t, home);
const plain = hooksSurface.buildHookCommand(configDir, 'gsd-session-state.sh', {
runtime: 'claude',
});
// The pre-#3662 .sh shapes, byte for byte: claude-on-win32 omits the
// bash runner entirely (shellHookOmitsBashRunner, #580/#3393) and emits
// the bare script token; POSIX prefixes resolveBashRunner's answer.
const bareSh = process.platform === 'win32';
const shRunner = hooksSurface.resolveBashRunner({ platform: process.platform }) || 'bash';
const plainScript = JSON.stringify(path.join(configDir, 'hooks', 'gsd-session-state.sh').replace(/\\/g, '/'));
assert.equal(plain, bareSh ? plainScript : `${shRunner} ${plainScript}`);
const portable = hooksSurface.buildHookCommand(configDir, 'gsd-session-state.sh', {
portableHooks: true,
runtime: 'claude',
});
assert.equal(portable, bareSh ? '"$HOME/.claude/hooks/gsd-session-state.sh"' : `${shRunner} "$HOME/.claude/hooks/gsd-session-state.sh"`);
});
test('chain embeds shell-hostile baked paths safely', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'hostile-path');
withHome(t, home);
const hostileDir = createTempDir('gsd3662-hostile');
const weird = path.join(hostileDir, 'sp ace$dollar');
fs.mkdirSync(weird, { recursive: true });
t.after(() => cleanup(hostileDir));
const stubPath = path.join(weird, 'node');
fs.writeFileSync(stubPath, ['#!/bin/sh', 'echo "STUB-NODE:$1"', ''].join('\n'));
fs.chmodSync(stubPath, 0o755);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
execPath: stubPath,
});
assert.ok(typeof emitted === 'string');
const file = writeCommandFile(t, emitted, 'hostile');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${emitted}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /^STUB-NODE:\S*gsd-statusline\.js/, 'script path split or expanded by the shell');
});
test('chain stays valid sh when no candidate resolves', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'sh-valid');
withHome(t, home);
const emitted = hooksSurface.buildHookCommand(configDir, 'gsd-statusline.js', {
runtime: 'claude',
execPath: FOREIGN_NODE,
});
assert.ok(typeof emitted === 'string');
const foreign = foreignizeNodeTokens(emitted);
const file = writeCommandFile(t, foreign, 'sh-valid');
const dir = createTempDir('gsd3662-shn');
const checker = path.join(dir, 'syntax-check.sh');
fs.writeFileSync(checker, ['#!/bin/sh', 'exec bash -n "$1"', ''].join('\n'));
fs.chmodSync(checker, 0o755);
t.after(() => cleanup(dir));
const result = runHook(checker, [file], {
interpreter: 'bash',
env: { ...process.env, HOME: home, PATH: '/usr/bin:/bin' },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `bash -n rejected the emitted command (${foreign}): ${result.stderr}`);
});
test('win32 projection keeps forward-slash paths in the chain', () => {
const emitted = hooksSurface.buildHookCommand('C:\\Users\\u\\.claude', 'gsd-statusline.js', {
runtime: 'claude',
platform: 'win32',
execPath: 'C:\\Program Files\\nodejs\\node.exe',
});
assert.ok(typeof emitted === 'string', `no command emitted: ${emitted}`);
assert.ok(emitted.includes('"$(for n in '), `win32 chain shape missing: ${emitted}`);
assert.ok(emitted.includes('C:/Program Files/nodejs/node.exe'), `forward-slash baked path missing: ${emitted}`);
assert.ok(!emitted.includes('\\'), `backslash survived win32 projection: ${emitted}`);
});
});
describe('managed-set parity (RULESET.GENERATIVE-FIX)', () => {
// #3662: the guard basenames live in TWO parallel surfaces — the runner
// rewriter's gate (MANAGED_HOOK_BASENAMES_BY_SURFACE, via
// isManagedHookBasename) and the command recognizer
// (MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE, via isManagedHookCommand —
// uninstall + settings-migration recognition). The two lists must agree
// on every JS hook; this row fails the moment one gains a basename the
// other lacks.
test('every managed js basename is recognized by both managed sets', () => {
const projection = require('../gsd-core/bin/lib/shell-command-projection.cjs');
for (const basename of [...MANAGED_JS_HOOKS, ...GUARD_HOOKS]) {
const scriptPath = `/home/u/.claude/hooks/${basename}`;
assert.ok(
projection.isManagedHookBasename(scriptPath, { surface: 'settings-json' }),
`${basename} missing from the runner-rewriter gate set (isManagedHookBasename)`,
);
const command = `"/usr/bin/node" "${scriptPath}"`;
assert.ok(
projection.isManagedHookCommand(command, { surface: 'settings-json' }),
`${basename} missing from the command recognizer set (isManagedHookCommand)`,
);
}
});
});
describe('kimi config.toml surface', () => {
test('kimi toml hook commands resolve node at run time', (t) => {
if (skipOnWin32(t, 'POSIX sh execution lane')) return;
const { home, configDir } = makeHookTree(t, 'kimi-run');
withHome(t, home);
const block = hooksSurface.buildKimiHooksTomlBlock(configDir, {
hookOpts: { portableHooks: false, runtime: 'kimi' },
});
assert.ok(typeof block === 'string' && block.length > 0, 'kimi hooks block not built');
const commands = [...block.matchAll(/^command = "(.*)"$/gm)].map((m) =>
m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'),
);
assert.ok(commands.length > 0, 'no command entries found in kimi block');
const jsCommand = commands.find((c) => c.includes('gsd-prompt-guard.js'));
assert.ok(jsCommand, `no gsd-prompt-guard.js command in block: ${commands.join(' | ')}`);
const foreign = foreignizeNodeTokens(jsCommand);
const file = writeCommandFile(t, foreign, 'kimi');
const result = runHook(file, [], {
interpreter: 'bash',
cwd: home,
env: { ...process.env, HOME: home, PATH: `${posixRealNodeDir()}:/usr/bin:/bin` },
timeoutMs: RUN_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `command: ${foreign}\nstderr: ${result.stderr}`);
assert.equal(result.stdout, 'hook-ran-ok');
});
});
});