CLAUDE_PLUGIN_DATA is empty in the Bash tool's process env, and the Phase 5
bash snippet is the cap's only caller. resolveLedgerPath() returned null there
and allowTurn() failed closed, so the budget gate denied turn 1 of every real
run: the loop this delivery exists to bound could never spend a turn, and the
pre-registered measurement could not be run at all.
resolveDataRoot() is now the single root for everything the loop writes --
CLAUDE_PLUGIN_DATA when the harness sets it, ~/.claude/voyage when it does
not. Three consumers resolve through it, which is the point: the cap ledger,
the PreToolUse hook's scope-marker lookup, and the command's bash snippets.
A writer and a reader that resolved the root separately are what made the
enforcement hook allow unconditionally in every real run while CLAUDE.md and
docs/architecture.md called it enforcing.
Same root cause, same commit:
- Marker write and remove now share ONE absolute-path guard and one root; the
write requires a non-empty CLAUDE_CODE_SESSION_ID before composing the path
(unset, the marker was named `.json`, which no lookup matches and no TTL
sweep cleans up).
- The per-turn gates resolve VOYAGE_ROOT with a plugin-cache fallback and
reserve exit 2 for "gate could not run". Interpolating an empty
${CLAUDE_PLUGIN_ROOT} ran `node /lib/...` -> exit 1, which the contract read
as "privacy gate says no" -- an unsatisfiable rewrite loop no query could
clear.
Two now-unreachable deny branches are removed rather than left as dead safety
claims (allowTurn's no_plugin_data_dir; the hook's uncountable-ledger deny).
The fail-closed stance stays where it is still real: a ledger that cannot be
WRITTEN denies the turn.
Verified end-to-end through the real bash snippets and the real hook with both
variables stripped and HOME sandboxed: marker written under the fallback root,
8 turns spent, 9th denied, hook exits 2, and exits 0 again after removal.
Note: the fallback exit-2 branch fires against the installed v5.9.1 cache,
which predates lib/util/research-loop-cap.mjs -- correct behaviour, and it
clears when the plugin is reinstalled.
Review findings 2670c10a, fbd6d534, 93550dfb.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
265 lines
11 KiB
JavaScript
265 lines
11 KiB
JavaScript
// tests/hooks/agent-cap.test.mjs
|
||
// Step 10 — pins hooks/scripts/pre-agent-cap.mjs, the PreToolUse enforcement
|
||
// of the /trekresearch Phase 5 loop bound.
|
||
//
|
||
// The spike (docs/spike-pretooluse-subagent-reach.md, RESULT: FIRES) proved a
|
||
// plugin PreToolUse hook observes sub-agent tool calls, so the cap can be
|
||
// enforced rather than merely documented. This file pins the two properties
|
||
// that matter in opposite directions:
|
||
//
|
||
// (a) it DENIES (exit 2) once the ledger shows the budget spent, and
|
||
// (b) it does NOT over-block — an unrelated session, unparsable stdin, a
|
||
// stale marker, or the kill switch all exit 0.
|
||
//
|
||
// Pattern: tests/hooks/bash-guard.test.mjs (child process via runHook).
|
||
|
||
import { test } from 'node:test';
|
||
import { strict as assert } from 'node:assert';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..', '..');
|
||
const CAP_HOOK = join(ROOT, 'hooks', 'scripts', 'pre-agent-cap.mjs');
|
||
const HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json');
|
||
|
||
const SESSION = 'sess-abc123';
|
||
const RUN_ID = 'run-xyz789';
|
||
|
||
/**
|
||
* Build a throwaway CLAUDE_PLUGIN_DATA dir holding a scope marker for
|
||
* `sessionId` and `turns` ledger entries for RUN_ID.
|
||
*/
|
||
function fixture({ turns = 0, sessionId = SESSION, startedAt = new Date() } = {}) {
|
||
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-'));
|
||
mkdirSync(join(dir, 'trekresearch-loop-scope'), { recursive: true });
|
||
writeFileSync(
|
||
join(dir, 'trekresearch-loop-scope', `${sessionId}.json`),
|
||
JSON.stringify({ runId: RUN_ID, startedAt: startedAt.toISOString() }),
|
||
);
|
||
const ledger = Array.from({ length: turns }, (_, i) =>
|
||
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high' }),
|
||
).join('\n');
|
||
writeFileSync(join(dir, 'trekresearch-loop-ledger.jsonl'), ledger ? ledger + '\n' : '');
|
||
return dir;
|
||
}
|
||
|
||
// TREKRESEARCH_MAX_CONV_TURNS=1 => budget = 1 * MAX_TOTAL_DIMENSIONS (8).
|
||
const CAPPED_ENV = { VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||
const BUDGET = 8;
|
||
|
||
function searchInput(sessionId = SESSION) {
|
||
return {
|
||
session_id: sessionId,
|
||
hook_event_name: 'PreToolUse',
|
||
tool_name: 'WebSearch',
|
||
tool_input: { query: 'claude code hooks reference' },
|
||
agent_id: 'aa6d19525a4680fe0',
|
||
agent_type: 'general-purpose',
|
||
};
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// DENY — budget spent
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap DENIES a loop turn once the ledger shows the budget spent', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 2);
|
||
assert.match(stderr, /loop cap/i, 'stderr must name the cap it enforced');
|
||
assert.match(stderr, new RegExp(`${BUDGET}`), 'stderr must state the budget');
|
||
});
|
||
|
||
test('pre-agent-cap DENIES above the budget too (used > budget)', async () => {
|
||
const dir = fixture({ turns: BUDGET + 5 });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 2);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// ALLOW — under the cap
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap ALLOWS a loop turn under the budget', async () => {
|
||
const dir = fixture({ turns: BUDGET - 1 });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// DOES NOT OVER-BLOCK — the property that keeps this hook safe to wire
|
||
// globally. A broken PreToolUse hook would brick every session on the box.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap ALLOWS an unrelated session even when a loop is exhausted', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput('some-other-session'), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0, 'no scope marker for this session_id => out of scope');
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS when no scope marker directory exists at all', async () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-empty-'));
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS on unparsable stdin', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, 'not json at all', {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap ALLOWS when the input carries no session_id', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const input = searchInput();
|
||
delete input.session_id;
|
||
const { code } = await runHookWithEnv(CAP_HOOK, input, {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// TTL / auto-reset — a marker left behind by a crashed run must not deny
|
||
// tool calls forever.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap ALLOWS when the scope marker is older than the TTL', async () => {
|
||
const dir = fixture({ turns: BUDGET, startedAt: new Date(Date.now() - 48 * 3600 * 1000) });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
VOYAGE_CAP_SCOPE_TTL_MS: '1000',
|
||
});
|
||
assert.strictEqual(code, 0, 'a stale marker must auto-reset, not deny forever');
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Kill switch + default-off
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap kill switch VOYAGE_DISABLE_CAP_HOOK=1 allows an exhausted loop', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
VOYAGE_DISABLE_CAP_HOOK: '1',
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
test('pre-agent-cap is inert when VOYAGE_STORM_ENABLED is not 1 (default-off)', async () => {
|
||
const dir = fixture({ turns: BUDGET });
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
VOYAGE_STORM_ENABLED: '0',
|
||
CLAUDE_PLUGIN_DATA: dir,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// The measured environment — CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool
|
||
// env, so that is the environment every real run happens in. The writer (the
|
||
// Phase 5 bash snippet) and the reader (this hook) must land on the SAME
|
||
// fallback root, or the hook allows unconditionally while claiming to enforce.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap enforces via the fallback root when CLAUDE_PLUGIN_DATA is absent', async () => {
|
||
const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
|
||
const root = join(home, '.claude', 'voyage');
|
||
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
|
||
writeFileSync(
|
||
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
|
||
);
|
||
writeFileSync(
|
||
join(root, 'trekresearch-loop-ledger.jsonl'),
|
||
Array.from({ length: BUDGET }, (_, i) =>
|
||
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high' }),
|
||
).join('\n') + '\n',
|
||
);
|
||
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: '',
|
||
HOME: home,
|
||
});
|
||
assert.strictEqual(code, 2, 'the hook must find marker AND ledger under the fallback root and deny');
|
||
});
|
||
|
||
test('pre-agent-cap allows under budget in the fallback root — the fallback is not a blanket deny', async () => {
|
||
const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
|
||
const root = join(home, '.claude', 'voyage');
|
||
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
|
||
writeFileSync(
|
||
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
|
||
);
|
||
writeFileSync(join(root, 'trekresearch-loop-ledger.jsonl'), '');
|
||
|
||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||
...CAPPED_ENV,
|
||
CLAUDE_PLUGIN_DATA: '',
|
||
HOME: home,
|
||
});
|
||
assert.strictEqual(code, 0);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Append-only counting — the hook reads the ledger, it never writes it.
|
||
// Writing per tool call would make the cap count its own enforcement.
|
||
// -----------------------------------------------------------------------
|
||
test('pre-agent-cap never writes to the ledger', async () => {
|
||
const dir = fixture({ turns: 2 });
|
||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||
const before = readFileSync(ledgerPath, 'utf-8');
|
||
await runHookWithEnv(CAP_HOOK, searchInput(), { ...CAPPED_ENV, CLAUDE_PLUGIN_DATA: dir });
|
||
assert.strictEqual(readFileSync(ledgerPath, 'utf-8'), before);
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Wiring — pattern from tests/hooks/hooks-json-stop-wired.test.mjs
|
||
// -----------------------------------------------------------------------
|
||
function invocationOf(h) {
|
||
return [h.command || '', ...(h.args || [])].join(' ').trim();
|
||
}
|
||
|
||
test('hooks.json wires pre-agent-cap.mjs on PreToolUse with ${CLAUDE_PLUGIN_ROOT}', () => {
|
||
const cfg = JSON.parse(readFileSync(HOOKS_JSON, 'utf8'));
|
||
const invocations = (cfg.hooks.PreToolUse || []).flatMap((entry) =>
|
||
(entry.hooks || []).map(invocationOf),
|
||
);
|
||
const capInvocation = invocations.find((cmd) => cmd.includes('pre-agent-cap.mjs'));
|
||
assert.ok(capInvocation, `no PreToolUse hook references pre-agent-cap.mjs. Found: ${JSON.stringify(invocations)}`);
|
||
assert.match(capInvocation, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'relative paths fail in headless sessions');
|
||
assert.match(capInvocation, /^node\s+/);
|
||
});
|
||
|
||
test('hooks.json matcher for pre-agent-cap covers the loop’s outbound surface', () => {
|
||
const cfg = JSON.parse(readFileSync(HOOKS_JSON, 'utf8'));
|
||
const entry = (cfg.hooks.PreToolUse || []).find((e) =>
|
||
(e.hooks || []).some((h) => invocationOf(h).includes('pre-agent-cap.mjs')),
|
||
);
|
||
assert.ok(entry, 'pre-agent-cap entry missing from PreToolUse');
|
||
for (const tool of ['WebSearch', 'WebFetch', 'Task']) {
|
||
assert.match(entry.matcher, new RegExp(tool), `matcher must cover ${tool}`);
|
||
}
|
||
});
|