Files
msd-core/tests/read-guard.test.cjs
Jakub Zych 1622b66907
Some checks failed
Tests / PR mergeability (push) Successful in 17s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 17s
Tests / lint-tests (push) Failing after 1m56s
Tests / plugin-validate (push) Successful in 1m2s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 21s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 18s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 20s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 17s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 7s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
test: remove Kimi tool-vocabulary hook tests
The Kimi payload normalization they exercised went away with the Kimi runtime.
The Claude-vocabulary force-add block test from the same suite is kept.
2026-10-06 20:09:18 +02:00

515 lines
20 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 msd-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.MSD_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', 'msd-read-guard.js');
// Bounds a single direct invocation of the advisory-only, no-subprocess
// msd-read-guard.js hook; tighter than the shared QUICK_SPAWN_TIMEOUT_MS norm
// (10000ms) despite a similar "no fan-out" class — kept as its own constant
// since this migration never widens a bound without a fresh bench citation.
const READ_GUARD_HOOK_TIMEOUT_MS = 5000;
/**
* 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: READ_GUARD_HOOK_TIMEOUT_MS });
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('msd-read-guard hook', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('msd-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: READ_GUARD_HOOK_TIMEOUT_MS,
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('msd-read-guard.js'),
'msd-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 { MSD_UNINSTALL_HOOKS } = require('../bin/install.js');
assert.ok(
MSD_UNINSTALL_HOOKS.includes('msd-read-guard.js'),
'msd-read-guard.js must be in the uninstall MSD_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
*
* msd-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.MSD_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', 'msd-read-guard.js');
// Bounds a single direct invocation of the advisory-only, no-subprocess
// msd-read-guard.js hook; tighter than the shared QUICK_SPAWN_TIMEOUT_MS norm
// (10000ms) despite a similar "no fan-out" class — kept as its own constant
// since this migration never widens a bound without a fresh bench citation.
const READ_GUARD_HOOK_TIMEOUT_MS = 5000;
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: READ_GUARD_HOOK_TIMEOUT_MS });
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('msd-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.MSD_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', 'msd-read-guard.js');
// Bounds a single direct invocation of the advisory-only, no-subprocess
// msd-read-guard.js hook; tighter than the shared QUICK_SPAWN_TIMEOUT_MS norm
// (10000ms) despite a similar "no fan-out" class — kept as its own constant
// since this migration never widens a bound without a fresh bench citation.
const READ_GUARD_HOOK_TIMEOUT_MS = 5000;
/**
* 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: READ_GUARD_HOOK_TIMEOUT_MS,
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('msd-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');
});
});
});
}