* chore(#3546): migrate hook advisory assertions onto typed output surfaces Add additive typed fields to 5 hook scripts' PreToolUse/PostToolUse advisory output alongside the existing additionalContext prose: - gsd-read-guard.js: code ('READ_BEFORE_EDIT'), fileName - gsd-context-monitor.js: severity ('warning'|'critical') - gsd-prompt-guard.js: findings ([{ruleId, match}], module-local RULE_IDS + renderFinding mapper mirroring gsd-read-injection-scanner.js's #3523 pattern) - gsd-read-injection-scanner.js: severity ('LOW'|'HIGH'), source (its findings array already existed from #3523) - gsd-workflow-guard.js: code ('WORKFLOW_ADVISORY') on the advisory leg, distinct from the existing force-add block leg's code additionalContext stays byte-identical in every hook (verified per-hook against the pristine HEAD version across a spread of payload shapes). Migrates all 20 assertion sites named in the issue off additionalContext.includes(...)/assert.match(...) substring-matching onto the new typed fields, per CONTRIBUTING.md's prohibition on raw text matching on test outputs. Closes #3546 * test: fix undersized commit-class timeout in gsd-statusline.test.cjs's commitN helper Surfaced by gsd-test on the #3546 checkpoint: `commitN()`'s loop called gitOrThrow(['add','-A']/['commit',...]) without a timeoutMs override, so each call used DEFAULT_GIT_TIMEOUT_MS (15s) -- a bound git-fixture.cjs's own doc comment says is sized for plumbing reads (rev-parse/branch/log), not write-heavy add/commit spawns. That file already documents the exact same defect class from a prior incident (PR #3323) and exports GIT_FIXTURE_TIMEOUT_MS (60s) for fixture-construction call sites - commitN just wasn't using it. Observed failure: `git commit -m filler 9` timed out under normal bench load, unrelated to any of this PR's own diff (hooks/*.js + 5 other test files). Not a flake: root-caused to the timeout bound being sized for the wrong call class, per this repo's no-flakes rule. * chore(#3546): backfill changeset PR number (#4167) --------- Co-authored-by: sim <sim@local>
651 lines
26 KiB
JavaScript
651 lines
26 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// Workflow .md / agent .md / command .md / reference .md files — their text
|
|
// IS what the runtime loads. Testing text content tests the deployed contract.
|
|
// Per CONTRIBUTING.md exception matrix.
|
|
|
|
/**
|
|
* Tests for gsd-read-guard.js PreToolUse hook.
|
|
*
|
|
* The read guard intercepts Write/Edit tool calls on existing files and injects
|
|
* advisory guidance telling the model to Read the file first. This prevents
|
|
* infinite retry loops when non-Claude models (e.g. MiniMax M2.5 on OpenCode)
|
|
* attempt to edit files without reading them, hitting the runtime's
|
|
* "You must read file before overwriting it" error repeatedly.
|
|
*
|
|
* The hook is advisory-only (does not block) so Claude Code behavior is unaffected.
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
|
|
|
/**
|
|
* Run the read guard hook with a given tool input payload.
|
|
* Returns { exitCode, stdout, stderr }.
|
|
*/
|
|
function runHook(payload, envOverrides = {}) {
|
|
const input = JSON.stringify(payload);
|
|
// Sanitize all Claude Code detection signals so positive-path tests work
|
|
// when the test runner itself is running inside Claude Code (#2344, #2520).
|
|
const env = {
|
|
...process.env,
|
|
CLAUDE_SESSION_ID: '',
|
|
CLAUDECODE: '',
|
|
CLAUDE_CODE_ENTRYPOINT: '',
|
|
CLAUDE_CODE_SSE_PORT: '',
|
|
CLAUDE_PROJECT_DIR: '',
|
|
...envOverrides,
|
|
};
|
|
const r = runHookSeam(HOOK_PATH, [], { input, env, timeoutMs: 5000 });
|
|
if (r.exitCode === 0) {
|
|
return { exitCode: 0, stdout: r.stdout.trim(), stderr: '' };
|
|
}
|
|
return {
|
|
exitCode: r.exitCode ?? 1,
|
|
stdout: r.stdout.trim(),
|
|
stderr: r.stderr.trim(),
|
|
};
|
|
}
|
|
|
|
describe('gsd-read-guard hook', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-read-guard-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// ─── Core: advisory on Write to existing file ───────────────────────────
|
|
|
|
test('injects read-first guidance when Write targets an existing file', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'console.log("hello");\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'Write',
|
|
tool_input: { file_path: filePath, content: 'console.log("world");\n' },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(result.stdout.length > 0, 'should produce output');
|
|
|
|
const output = JSON.parse(result.stdout);
|
|
assert.ok(output.hookSpecificOutput, 'should have hookSpecificOutput');
|
|
assert.ok(output.hookSpecificOutput.additionalContext, 'should have additionalContext');
|
|
assert.equal(
|
|
output.hookSpecificOutput.code,
|
|
'READ_BEFORE_EDIT',
|
|
'guidance should carry the READ_BEFORE_EDIT reason code'
|
|
);
|
|
});
|
|
|
|
test('injects read-first guidance when Edit targets an existing file', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(result.stdout.length > 0, 'should produce output');
|
|
|
|
const output = JSON.parse(result.stdout);
|
|
assert.equal(output.hookSpecificOutput.code, 'READ_BEFORE_EDIT');
|
|
});
|
|
|
|
// ─── No-op cases: should NOT inject guidance ────────────────────────────
|
|
|
|
test('does nothing for Write to a new file (file does not exist)', () => {
|
|
const filePath = path.join(tmpDir, 'brand-new.js');
|
|
// File does NOT exist
|
|
|
|
const result = runHook({
|
|
tool_name: 'Write',
|
|
tool_input: { file_path: filePath, content: 'new content' },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '', 'should produce no output for new files');
|
|
});
|
|
|
|
test('does nothing for non-Write/Edit tools', () => {
|
|
const result = runHook({
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'echo hello' },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '');
|
|
});
|
|
|
|
test('does nothing for Read tool', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'content');
|
|
|
|
const result = runHook({
|
|
tool_name: 'Read',
|
|
tool_input: { file_path: filePath },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '');
|
|
});
|
|
|
|
// ─── Error resilience ──────────────────────────────────────────────────
|
|
|
|
test('exits cleanly on invalid JSON input', () => {
|
|
try {
|
|
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
|
input: 'not json',
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
// Should exit 0 silently
|
|
assert.equal(stdout.trim(), '');
|
|
} catch (err) {
|
|
assert.equal(err.status, 0, 'should exit 0 on parse error');
|
|
}
|
|
});
|
|
|
|
test('exits cleanly when tool_input is missing', () => {
|
|
const result = runHook({ tool_name: 'Write' });
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '');
|
|
});
|
|
|
|
// ─── Guidance content quality ──────────────────────────────────────────
|
|
|
|
test('guidance message includes the filename', () => {
|
|
const filePath = path.join(tmpDir, 'myfile.ts');
|
|
fs.writeFileSync(filePath, 'export const foo = 1;\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'Write',
|
|
tool_input: { file_path: filePath, content: 'export const foo = 2;\n' },
|
|
});
|
|
|
|
const output = JSON.parse(result.stdout);
|
|
assert.equal(
|
|
output.hookSpecificOutput.fileName,
|
|
'myfile.ts',
|
|
'guidance should name the file being edited'
|
|
);
|
|
});
|
|
|
|
test('guidance message instructs to use Read tool before editing', () => {
|
|
const filePath = path.join(tmpDir, 'target.py');
|
|
fs.writeFileSync(filePath, 'x = 1\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: filePath, old_string: 'x = 1', new_string: 'x = 2' },
|
|
});
|
|
|
|
const output = JSON.parse(result.stdout);
|
|
const ctx = output.hookSpecificOutput.additionalContext;
|
|
assert.ok(ctx.includes('Read'), 'must mention Read tool');
|
|
assert.ok(
|
|
ctx.includes('before') || ctx.includes('first'),
|
|
'must indicate Read should come before the edit'
|
|
);
|
|
});
|
|
|
|
// ─── Build / install integration ───────────────────────────────────────
|
|
|
|
test('hook is registered in build-hooks.js HOOKS_TO_COPY', () => {
|
|
const buildHooksPath = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
const content = fs.readFileSync(buildHooksPath, 'utf8');
|
|
assert.ok(
|
|
content.includes('gsd-read-guard.js'),
|
|
'gsd-read-guard.js must be in HOOKS_TO_COPY so it ships in hooks/dist/'
|
|
);
|
|
});
|
|
|
|
test('hook is registered in install.js uninstall hook list', () => {
|
|
// Check the actual exported uninstall hook list instead of grepping
|
|
// install.js source text.
|
|
const { GSD_UNINSTALL_HOOKS } = require('../bin/install.js');
|
|
assert.ok(
|
|
GSD_UNINSTALL_HOOKS.includes('gsd-read-guard.js'),
|
|
'gsd-read-guard.js must be in the uninstall GSD_UNINSTALL_HOOKS list'
|
|
);
|
|
});
|
|
|
|
test('exits cleanly when tool_input.file_path is non-string', () => {
|
|
const result = runHook({
|
|
tool_name: 'Write',
|
|
tool_input: { file_path: 12345, content: 'data' },
|
|
});
|
|
// file_path is a number — || '' yields '' — hook exits silently
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '');
|
|
});
|
|
|
|
// ─── Claude Code runtime skip (#1984) ─────────────────────────────────
|
|
|
|
test('skips advisory on Claude Code runtime (CLAUDE_SESSION_ID set)', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHook(
|
|
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
|
{ CLAUDE_SESSION_ID: 'test-session-123' }
|
|
);
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '', 'should produce no output on Claude Code');
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2344-read-guard-claudecode-env.test.cjs — consolidation epic #1969 (B6 #1975)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2344-read-guard-claudecode-env (consolidation epic #1969 B6 #1975)", () => {
|
|
/**
|
|
* Regression test for bug #2344
|
|
*
|
|
* gsd-read-guard.js checked process.env.CLAUDE_SESSION_ID to detect the
|
|
* Claude Code runtime and skip its advisory. However, Claude Code CLI exports
|
|
* CLAUDECODE=1, not CLAUDE_SESSION_ID. The skip never fired, so the
|
|
* READ-BEFORE-EDIT advisory injected on every Edit/Write call inside Claude
|
|
* Code — producing noise in long-running sessions.
|
|
*
|
|
* Fix: check CLAUDECODE (and CLAUDE_SESSION_ID for back-compat) before
|
|
* emitting the advisory.
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
|
|
|
function runHook(payload, envOverrides = {}) {
|
|
const input = JSON.stringify(payload);
|
|
const env = {
|
|
...process.env,
|
|
CLAUDE_SESSION_ID: '',
|
|
CLAUDECODE: '',
|
|
CLAUDE_CODE_ENTRYPOINT: '',
|
|
CLAUDE_CODE_SSE_PORT: '',
|
|
CLAUDE_PROJECT_DIR: '',
|
|
...envOverrides,
|
|
};
|
|
const r = runHookSeam(HOOK_PATH, [], { input, env, timeoutMs: 5000 });
|
|
if (r.exitCode === 0) {
|
|
return { exitCode: 0, stdout: r.stdout.trim(), stderr: '' };
|
|
}
|
|
return {
|
|
exitCode: r.exitCode ?? 1,
|
|
stdout: r.stdout.trim(),
|
|
stderr: r.stderr.trim(),
|
|
};
|
|
}
|
|
|
|
describe('bug #2344: read guard skips on CLAUDECODE env var', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2344-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('skips advisory when CLAUDECODE=1 is set (Claude Code CLI env)', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHook(
|
|
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
|
{ CLAUDECODE: '1' }
|
|
);
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDECODE=1');
|
|
});
|
|
|
|
test('skips advisory when CLAUDE_SESSION_ID is set (back-compat)', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHook(
|
|
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
|
{ CLAUDE_SESSION_ID: 'test-session-123' }
|
|
);
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_SESSION_ID is set');
|
|
});
|
|
|
|
test('still injects advisory when neither CLAUDECODE nor CLAUDE_SESSION_ID is set', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHook(
|
|
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
|
{ CLAUDECODE: '', CLAUDE_SESSION_ID: '' }
|
|
);
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code runtimes');
|
|
const output = JSON.parse(result.stdout);
|
|
assert.equal(output.hookSpecificOutput?.code, 'READ_BEFORE_EDIT');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2520-read-guard-hook-subprocess-env.test.cjs — consolidation epic #1969 (B6 #1975)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2520-read-guard-hook-subprocess-env (consolidation epic #1969 B6 #1975)", () => {
|
|
/**
|
|
* Regression test for bug #2520
|
|
*
|
|
* The fix for #2344 added `|| process.env.CLAUDECODE` to the Claude Code
|
|
* skip check. That works in principle — CLAUDECODE=1 is propagated to Bash
|
|
* tool subprocesses — but it does NOT reach hook subprocesses on Claude Code
|
|
* v2.1.116. Claude Code applies a separate env filter when spawning
|
|
* PreToolUse hook commands; that filter drops bare CLAUDECODE and
|
|
* CLAUDE_SESSION_ID and keeps only CLAUDE_CODE_*-prefixed vars plus
|
|
* CLAUDE_PROJECT_DIR. `data.session_id` is, however, reliably delivered via
|
|
* the hook's stdin JSON payload (documented part of Claude Code's hook
|
|
* input schema).
|
|
*
|
|
* Fix: use `data.session_id` as the primary Claude Code signal, with
|
|
* CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SSE_PORT as env-var fallbacks, and
|
|
* keep legacy CLAUDECODE / CLAUDE_SESSION_ID for back-compat and
|
|
* future-proofing.
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
|
|
|
/**
|
|
* Spawn the hook with an env that mirrors the actual Claude Code hook
|
|
* subprocess env: CLAUDECODE and CLAUDE_SESSION_ID are stripped, only
|
|
* CLAUDE_CODE_*-prefixed vars (plus CLAUDE_PROJECT_DIR) remain. Extra env
|
|
* overrides can be supplied via `envOverrides`.
|
|
*/
|
|
function runHookInClaudeCodeSubprocess(payload, envOverrides = {}) {
|
|
const input = JSON.stringify(payload);
|
|
const baseEnv = { ...process.env };
|
|
// Strip env vars Claude Code does NOT propagate to hook subprocesses.
|
|
delete baseEnv.CLAUDECODE;
|
|
delete baseEnv.CLAUDE_SESSION_ID;
|
|
const env = {
|
|
...baseEnv,
|
|
// Env vars Claude Code DOES propagate to hook subprocesses (observed on
|
|
// Claude Code CLI 2.1.116).
|
|
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
|
CLAUDE_CODE_SSE_PORT: '51291',
|
|
CLAUDE_PROJECT_DIR: process.cwd(),
|
|
...envOverrides,
|
|
};
|
|
try {
|
|
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
|
input,
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env,
|
|
});
|
|
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
|
} catch (err) {
|
|
return {
|
|
exitCode: err.status ?? 1,
|
|
stdout: (err.stdout || '').toString().trim(),
|
|
stderr: (err.stderr || '').toString().trim(),
|
|
};
|
|
}
|
|
}
|
|
|
|
describe('bug #2520: read guard detects Claude Code without relying on CLAUDECODE env', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2520-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('skips advisory when stdin payload includes session_id (Claude Code hook-subprocess env)', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
// Isolate the stdin `session_id` signal by clearing the CLAUDE_CODE_*
|
|
// env fallbacks the helper normally provides. Without this the env
|
|
// fallback would rescue the skip even if session_id detection broke,
|
|
// hiding a regression of the primary signal.
|
|
const result = runHookInClaudeCodeSubprocess(
|
|
{
|
|
session_id: 'e7123e54-0977-45dd-848a-b9c8a45a5cd3',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
|
|
},
|
|
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
|
);
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(
|
|
result.stdout,
|
|
'',
|
|
'advisory must not fire when session_id is present on stdin (real Claude Code hook env)',
|
|
);
|
|
});
|
|
|
|
test('skips advisory when CLAUDE_CODE_ENTRYPOINT is set (env-var fallback, no session_id on stdin)', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHookInClaudeCodeSubprocess(
|
|
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
|
{ CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SSE_PORT: '' },
|
|
);
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_CODE_ENTRYPOINT is set');
|
|
});
|
|
|
|
test('still injects advisory when no Claude Code signal is present (non-Claude host)', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHookInClaudeCodeSubprocess(
|
|
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
|
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
|
);
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code hosts');
|
|
const output = JSON.parse(result.stdout);
|
|
assert.equal(output.hookSpecificOutput?.code, 'READ_BEFORE_EDIT');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// #2304 — Kimi tool vocabulary engages the read guard
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#2304: Kimi tool vocabulary is normalized by the read guard', () => {
|
|
// Payload shapes mirror kimi-cli's actual tool schemas
|
|
// (src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes
|
|
// `path`/`content`, StrReplaceFile takes `path` + `edit: Edit | list[Edit]`.
|
|
//
|
|
// SCOPE (#2547 finding 3): these cases omit `session_id`, so what they prove
|
|
// is that normalizeKimiPayload maps the Kimi tool VOCABULARY through to the
|
|
// Write/Edit branch — not that the advisory fires on a live Kimi turn. Every
|
|
// real kimi-cli payload carries a non-empty `session_id` (hooks/events.py
|
|
// `_base()` sets it unconditionally; kimisoul.py calls `set_session_id()` at
|
|
// the top of every turn), and the guard treats any non-empty `session_id` as
|
|
// "this is Claude Code, skip". Do NOT read a green here as evidence of
|
|
// production behaviour — the '#2547' describe below pins what actually
|
|
// happens against the production shape.
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2304-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('WriteFile on an existing file injects read-first guidance like Write', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'console.log("hello");\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'WriteFile',
|
|
tool_input: { path: filePath, content: 'console.log("world");\n' },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(result.stdout.length > 0, 'Kimi WriteFile should produce the advisory');
|
|
const output = JSON.parse(result.stdout);
|
|
assert.equal(output.hookSpecificOutput?.code, 'READ_BEFORE_EDIT');
|
|
});
|
|
|
|
test('StrReplaceFile on an existing file injects guidance like Edit', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'StrReplaceFile',
|
|
tool_input: { path: filePath, edit: { old: 'const x = 1;', new: 'const x = 2;' } },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(result.stdout.length > 0, 'Kimi StrReplaceFile should produce the advisory');
|
|
const output = JSON.parse(result.stdout);
|
|
assert.equal(output.hookSpecificOutput?.code, 'READ_BEFORE_EDIT');
|
|
});
|
|
|
|
test('module-qualified kimi_cli.tools.file:WriteFile is recognized', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'content\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'kimi_cli.tools.file:WriteFile',
|
|
tool_input: { path: filePath, content: 'replacement\n' },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(result.stdout.length > 0, 'module-qualified Kimi WriteFile should produce the advisory');
|
|
});
|
|
|
|
test('Kimi ReadFile stays out of scope (silent exit)', () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'content\n');
|
|
|
|
const result = runHook({
|
|
tool_name: 'kimi_cli.tools.file:ReadFile',
|
|
tool_input: { path: filePath },
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '', 'ReadFile is not a write tool — guard must stay silent');
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// #2547 finding 3 — the production Kimi payload shape (session_id present)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#2547: read guard against the production Kimi payload shape', () => {
|
|
// The #2304 cases above omit `session_id`. A live Kimi turn never does:
|
|
// - src/kimi_cli/hooks/events.py `_base()` returns
|
|
// {"hook_event_name", "session_id", "cwd"} — the field is unconditional;
|
|
// - src/kimi_cli/soul/kimisoul.py calls `set_session_id(session.id)` at the
|
|
// top of every turn, before tool dispatch, so the ContextVar holds a real
|
|
// UUID (its `default=""` only applies outside a turn).
|
|
//
|
|
// The guard's Claude Code check treats ANY non-empty `data.session_id` as
|
|
// "Claude Code already enforces read-before-edit, skip" (#2520), so on Kimi
|
|
// the advisory is DORMANT. These tests pin that real behaviour, which is what
|
|
// makes the #2304 cases above trustworthy as vocabulary-only coverage.
|
|
//
|
|
// This is a CHARACTERIZATION of a known gap, not an endorsement of it.
|
|
// Redesigning the runtime discrimination is explicitly OUT OF SCOPE for
|
|
// #2547. If a later change makes the advisory fire on Kimi, these tests are
|
|
// SUPPOSED to fail — update them then, rather than deleting the coverage.
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2547-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const LIVE_SESSION_ID = 'e7123e54-0977-45dd-848a-b9c8a45a5cd3';
|
|
|
|
for (const [label, toolInput, toolName] of [
|
|
['StrReplaceFile', (p) => ({ path: p, edit: { old: 'const x = 1;', new: 'const x = 2;' } }), 'StrReplaceFile'],
|
|
['WriteFile', (p) => ({ path: p, content: 'replacement\n' }), 'WriteFile'],
|
|
['module-qualified WriteFile', (p) => ({ path: p, content: 'replacement\n' }), 'kimi_cli.tools.file:WriteFile'],
|
|
]) {
|
|
test(`${label} with a populated session_id is dormant (known gap, #2547)`, () => {
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
|
|
const result = runHook({
|
|
session_id: LIVE_SESSION_ID,
|
|
tool_name: toolName,
|
|
tool_input: toolInput(filePath),
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.stdout, '',
|
|
'The advisory is currently skipped on Kimi because the guard reads any ' +
|
|
'non-empty session_id as Claude Code. If this now emits, the runtime ' +
|
|
'discrimination changed — update this test and the #2304 block above.');
|
|
});
|
|
}
|
|
|
|
test('the ONLY difference is session_id — dropping it makes the same payload fire', () => {
|
|
// The false-green proof, asserted rather than described: one field flips the
|
|
// #2304 cases from firing to silent, and the live shape is the silent one.
|
|
const filePath = path.join(tmpDir, 'existing.js');
|
|
fs.writeFileSync(filePath, 'const x = 1;\n');
|
|
const toolInput = { path: filePath, edit: { old: 'const x = 1;', new: 'const x = 2;' } };
|
|
|
|
const withoutSession = runHook({ tool_name: 'StrReplaceFile', tool_input: toolInput });
|
|
const withSession = runHook({
|
|
session_id: LIVE_SESSION_ID,
|
|
tool_name: 'StrReplaceFile',
|
|
tool_input: toolInput,
|
|
});
|
|
|
|
assert.ok(withoutSession.stdout.length > 0,
|
|
'test-shape payload (no session_id) fires — this is what #2304 asserts');
|
|
assert.equal(withSession.stdout, '',
|
|
'production-shape payload (session_id present) is silent — so a green in ' +
|
|
'the #2304 block is evidence about vocabulary, not about production');
|
|
});
|
|
});
|