voyage/tests/lib/no-claude-code-launch.test.mjs
Kjell Tore Guttormsen bb0fb361a5
fix(guard): refuse every asked-about call where no one can be asked
In the permission mode that shows no prompts, pre-bash-executor refused only a
merge into main and a push. A tag, a package publish and destructive git still
went through unattended, which is what the approval guard exists to stop. Now
every call it would ask about, and a command it could not read, is refused
there with the way forward (operator decision 2026-10-09). No new exemption:
the mode is still read in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:36:21 +02:00

149 lines
8.4 KiB
JavaScript

// tests/lib/no-claude-code-launch.test.mjs
// Nothing Voyage publishes may start Claude Code. The published tree is every
// tracked file (the catalog pins a release tag, so the tag's tree is what a
// user installs). A hit is any line that carries a way to start a Claude Code
// session or model run:
// cli the `claude` CLI with a flag or a prompt (-p, --print, --bg,
// --resume, --version, "prompt", $(...)); a user opening their
// own session is the PM way and carries no command line
// plugin-eval `claude plugin eval` (it runs one child session per case)
// spawn spawn/exec/execFile/fork of the `claude` binary from code
// sdk the Agent SDK, the claude-code npm package, the claude-code
// GitHub Action
// bypass permission-bypass flags, which only exist for launched sessions
// Plugin management (`claude plugin install|validate`, `marketplace add`)
// starts no session and is not a hit.
//
// Classes: (a) starts Claude Code, (b) tells a user or a session to start it,
// (c) historical mention (changelog, measurement records, recorded fixtures),
// (operator-instrument) a documented tool a PERSON runs, kept by operator
// decision 2026-10-02: the fixed test set run through Anthropic's plugin eval
// tool; Voyage starts nothing.
// (a) and (b) must be zero. A (c) or operator-instrument file stands only in
// EXEMPT, with its reason and its EXACT hit count: a new hit in an exempt file
// fails, and so does an entry that no longer hits.
//
// When this test fails, remove the launch (work that needs another session
// becomes an order plus a starter the user runs in a session they open); do
// NOT widen EXEMPT to hide it.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const SELF = 'tests/lib/no-claude-code-launch.test.mjs';
const PATTERNS = [
['cli', /(^|[^A-Za-z0-9_./@-])claude[ \t]+(-{1,2}[A-Za-z]|["'$])/],
['plugin-eval', /(^|[^A-Za-z0-9_./@-])claude[ \t]+plugin[ \t]+eval\b/],
['spawn', /\b(spawn|spawnSync|exec|execSync|execFile|execFileSync|fork)\(\s*['"`]claude\b/],
['sdk', /claude-agent-sdk|claude_agent_sdk|@anthropic-ai\/claude-code|claude-code-sdk|claude_code_sdk|claude-code-action/],
['bypass', /bypassPermissions|--dangerously-skip-permissions/],
];
// path -> [class, exact hit count, reason]
const EXEMPT = new Map([
[SELF, ['self', 17, "the gate's own pattern table and known-positive fixture"]],
['CHANGELOG.md', ['c', 15, 'release history; never rewritten']],
['docs/T1-cc26-delegated-orchestration.md', ['c', 4, 'measurement record (T1/CC-26)']],
['docs/T1-synthesis-poc-results.md', ['c', 1, 'measurement record (synthesis PoC)']],
['docs/cc-upgrade-2.1.181-decision-matrix.md', ['c', 3, 'decision record (CC 2.1.181 upgrade)']],
['docs/deep-research-engine-research.md', ['c', 6, 'research record (deep-research engine)']],
['docs/operations.md', ['c', 1, 'Path A decision record: why cache-first was rejected']],
['docs/spike-pretooluse-subagent-reach.md', ['c', 2, 'spike record (PreToolUse reach)']],
['docs/voyage-vs-cc-balance-analysis.md', ['c', 1, 'decision record (balance analysis)']],
['scripts/synthesis-measure.mjs', ['c', 2, 'measurement script; says why a child-session instrument was not used, launches nothing']],
['tests/fixtures/research-deep-research-adapted.md', ['c', 1, 'recorded research fixture']],
['tests/fixtures/synthesis/digest.json', ['c', 1, 'recorded synthesis fixture']],
['tests/fixtures/synthesis/exploration/architecture-mapper.md', ['c', 3, 'recorded exploration fixture']],
['tests/fixtures/synthesis/exploration/dependency-tracer.md', ['c', 2, 'recorded exploration fixture']],
['docs/proevesett.md', ['operator-instrument', 3, "the operator's own eval run through Anthropic's `claude plugin eval`; Voyage starts nothing - kept by operator decision 2026-10-02"]],
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]],
['tests/hooks/guards-review-regressions.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the hook under test; starts nothing']],
['hooks/scripts/pre-bash-executor.mjs', ['mode-read', 1, "reads the session's permission_mode from hook input so every call the guard would ask about (merge into main, push, tag/release, destructive git) is refused when no person can be asked; starts nothing - operator decision 2026-10-09"]],
['tests/commands/trekexecute-coupling.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the guard under test; starts nothing - operator decision 2026-10-09']],
]);
function trackedFiles(root) {
const out = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf-8' });
return out.split('\0').filter(Boolean);
}
// One "path:line:pattern" entry per hit line (first matching pattern wins).
function scan(root, files) {
const hits = [];
for (const rel of files) {
const buf = readFileSync(join(root, rel));
if (buf.includes(0)) continue; // binary
buf.toString('utf-8').split('\n').forEach((line, i) => {
const p = PATTERNS.find(([, re]) => re.test(line));
if (p) hits.push(`${rel}:${i + 1}:${p[0]}`);
});
}
return hits;
}
test('scan finds every launch form and passes the look-alikes (known-positive + known-negative)', () => {
const dir = mkdtempSync(join(tmpdir(), 'cc-launch-'));
try {
const positives = [
'claude -p "/trekexecute --session 1 plan.md"',
' GIT_OPTIONAL_LOCKS=0 claude --print "x"',
'run `claude --bg` in a pane',
'claude "$(cat session-1.md)"',
'claude --resume',
'claude plugin eval . --runs 1',
"const child = spawn('claude', argv);",
'execFileSync("claude", ["--version"])',
"import { query } from '@anthropic-ai/claude-agent-sdk';",
'npx @anthropic-ai/claude-code',
' --permission-mode bypassPermissions \\',
'claude --dangerously-skip-permissions',
'uses: anthropics/claude-code-action@v1',
];
const negatives = [
'Claude Code runs the command inline.',
'model: claude-opus-5-5',
'paths under .claude/projects/ and ${CLAUDE_PLUGIN_ROOT}',
'claude plugin install voyage@ktg-plugin-marketplace',
'claude plugin validate',
'Open a Claude Code session in the worktree and run `/trekexecute --session 1 plan.md`.',
'const bin = "claude";',
'the claude binary is not on PATH',
];
writeFileSync(join(dir, 'pos.md'), positives.join('\n') + '\n');
writeFileSync(join(dir, 'neg.md'), negatives.join('\n') + '\n');
const pos = scan(dir, ['pos.md']);
assert.equal(pos.length, positives.length, `every positive must hit:\n${pos.join('\n')}`);
assert.deepEqual(scan(dir, ['neg.md']), []);
assert.deepEqual(new Set(pos.map(h => h.split(':')[2])), new Set(PATTERNS.map(([k]) => k)),
'the known-positive fixture must exercise every pattern');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('no tracked file outside EXEMPT starts Claude Code or tells anyone to', () => {
const files = trackedFiles(ROOT);
assert.ok(files.length > 100, `git ls-files returned ${files.length} files; refusing to pass on an empty scan`);
const hits = scan(ROOT, files.filter(f => !EXEMPT.has(f)));
assert.deepEqual(hits, [], `${hits.length} launch hit(s) in (a)/(b):\n${hits.join('\n')}`);
});
test('every EXEMPT entry is tracked, has a reason, and hits exactly its recorded count', () => {
const files = new Set(trackedFiles(ROOT));
for (const [rel, [cls, count, reason]] of EXEMPT) {
assert.ok(files.has(rel), `EXEMPT names untracked ${rel}`);
assert.ok(['c', 'self', 'operator-instrument', 'mode-read'].includes(cls), `${rel}: unknown class ${cls}`);
assert.ok(reason && reason.length > 10, `${rel}: an exemption needs a reason`);
const n = scan(ROOT, [rel]).length;
assert.equal(n, count, `${rel}: ${n} hit(s), EXEMPT records ${count} - a new hit is a launch, a lost one is a stale entry`);
}
});