fix(research-loop-cap): resolve the data root in code so the loop can run
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
This commit is contained in:
parent
2e352a7dbb
commit
6dafdf2a2a
8 changed files with 260 additions and 61 deletions
|
|
@ -78,6 +78,58 @@ test('trekresearch — Phase 5 loop is gated on effort == high and names both pr
|
|||
assert.match(p5, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'shim invocations must use the ${CLAUDE_PLUGIN_ROOT} path form');
|
||||
});
|
||||
|
||||
// CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_ROOT are substituted in this command's
|
||||
// TEXT but are EMPTY in the Bash tool's process env. Every snippet below runs
|
||||
// in that env, so each needs a resolution that does not depend on it.
|
||||
test('trekresearch — the scope-marker snippets resolve a root instead of requiring CLAUDE_PLUGIN_DATA', () => {
|
||||
const p5 = phase5(read());
|
||||
const blocks = [...p5.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
const write = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('printf'));
|
||||
const remove = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('rm -f'));
|
||||
assert.ok(write, 'Phase 5 must carry the scope-marker write snippet');
|
||||
assert.ok(remove, 'Phase 5 must carry the scope-marker removal snippet');
|
||||
|
||||
for (const [name, block] of [['write', write], ['remove', remove]]) {
|
||||
assert.match(
|
||||
block,
|
||||
/\$\{CLAUDE_PLUGIN_DATA:-\$HOME\/\.claude\/voyage\}/,
|
||||
`the ${name} snippet must fall back to the same root research-loop-cap.mjs resolves`,
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/case .* in\s*\n?\s*\/\*\)/,
|
||||
`the ${name} snippet must guard on ONE absolute-path test — write and remove must not disagree on what counts as usable`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unset, ${CLAUDE_CODE_SESSION_ID} composes a marker named `.json`, which no
|
||||
// hook lookup and no TTL sweep ever matches or cleans up.
|
||||
assert.match(
|
||||
write,
|
||||
/-n "\$\{?CLAUDE_CODE_SESSION_ID/,
|
||||
'the write snippet must require a non-empty CLAUDE_CODE_SESSION_ID before composing the marker path',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — the per-turn gates separate "gate could not run" from "gate says no"', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(
|
||||
p5,
|
||||
/VOYAGE_ROOT/,
|
||||
'the gate snippet must resolve a plugin root rather than interpolating ${CLAUDE_PLUGIN_ROOT} straight into `node`',
|
||||
);
|
||||
assert.match(
|
||||
p5,
|
||||
/exit 2|could not run/i,
|
||||
'an unresolvable gate must be distinguishable from a denial — otherwise every query reads as a privacy violation no rewrite can clear',
|
||||
);
|
||||
assert.match(
|
||||
p5,
|
||||
/plugins\/cache/,
|
||||
'the fallback must name the plugin cache location it searches',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 declares the loop bound and all three exits', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /### Loop bound/, 'Phase 5 must carry a `### Loop bound` sub-heading');
|
||||
|
|
|
|||
|
|
@ -177,18 +177,50 @@ test('pre-agent-cap is inert when VOYAGE_STORM_ENABLED is not 1 (default-off)',
|
|||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fail-closed inside scope — in scope but the ledger is unreachable means
|
||||
// the hook cannot count, and a cap that cannot count must not grant.
|
||||
// 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 DENIES in scope when CLAUDE_PLUGIN_DATA is absent', async () => {
|
||||
const dir = fixture({ turns: 0 });
|
||||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
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: '',
|
||||
VOYAGE_CAP_SCOPE_DIR: dir,
|
||||
HOME: home,
|
||||
});
|
||||
assert.strictEqual(code, 2);
|
||||
assert.match(stderr, /CLAUDE_PLUGIN_DATA/, 'stderr must name the missing variable');
|
||||
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);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1165,6 +1165,26 @@ test('STORM: VOYAGE_STORM_ENABLED is documented as gating BOTH phases, not the l
|
|||
}
|
||||
});
|
||||
|
||||
// The variable is empty in the Bash tool env, so "missing CLAUDE_PLUGIN_DATA
|
||||
// denies" described a loop that could never spend turn 1. The root is resolved
|
||||
// in code now; a doc that still promises the deny describes a mechanism the
|
||||
// code does not have.
|
||||
test('STORM: no surface claims a missing CLAUDE_PLUGIN_DATA denies — the root falls back', () => {
|
||||
for (const f of ['docs/architecture.md', 'CLAUDE.md', 'README.md', 'docs/command-modes.md']) {
|
||||
const t = read(f);
|
||||
assert.doesNotMatch(
|
||||
t,
|
||||
/(missing|no|absent) `?CLAUDE_PLUGIN_DATA`?[^.\n]*(denies|fails closed)/i,
|
||||
`${f}: CLAUDE_PLUGIN_DATA absence no longer denies — it resolves to ~/.claude/voyage`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
read('docs/architecture.md'),
|
||||
/~\/\.claude\/voyage/,
|
||||
'docs/architecture.md must name the fallback data root the cap and the hook share',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM: README research-dimension prose stays at the existing 3–8 ceiling', () => {
|
||||
// Phase 4.5 discovers dimensions UNDER settings.json:16's maxDimensions: 8.
|
||||
// Rewriting this prose upward would raise a ceiling the brief asked us to hold.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
isStormEnabled,
|
||||
resolveMaxConvTurns,
|
||||
resolveLedgerPath,
|
||||
resolveDataRoot,
|
||||
MAX_CONV_TURNS,
|
||||
MAX_TOTAL_DIMENSIONS,
|
||||
} from '../../lib/util/research-loop-cap.mjs';
|
||||
|
|
@ -45,6 +46,25 @@ function runShim(args, env) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the shim in a BUILT env rather than an inherited one. Spreading
|
||||
* process.env means no test can express "CLAUDE_PLUGIN_DATA is absent" — the
|
||||
* exact condition that holds in every real run — so the shim's behaviour there
|
||||
* went uncovered while the module was denying turn 1.
|
||||
*/
|
||||
function runShimStripped(args, env = {}) {
|
||||
try {
|
||||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { PATH: process.env.PATH, ...env },
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (a) default-off --------------------------------------------------------
|
||||
|
||||
test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => {
|
||||
|
|
@ -76,14 +96,33 @@ test('allowTurn — enabled but effort !== high denies with budget 0', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---- (f) CLAUDE_PLUGIN_DATA unset => fail-closed deny -----------------------
|
||||
// ---- (f) CLAUDE_PLUGIN_DATA unset => documented fallback root ----------------
|
||||
//
|
||||
// CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool's process env (measured in a
|
||||
// live plugin-enabled session), and the Bash snippet in commands/trekresearch.md
|
||||
// is the module's only caller. Denying on its absence therefore denied turn 1
|
||||
// of every real run: the loop could never spend a turn, and the pre-registered
|
||||
// measurement could not be run at all. The root is resolved in code, not
|
||||
// demanded of the environment.
|
||||
|
||||
test('allowTurn — CLAUDE_PLUGIN_DATA unset denies even when enabled + high effort', () => {
|
||||
const env = { VOYAGE_STORM_ENABLED: '1' }; // no CLAUDE_PLUGIN_DATA
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'no_plugin_data_dir');
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => {
|
||||
withTmpDataDir((home) => {
|
||||
const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA');
|
||||
assert.equal(r.used, 1);
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
assert.ok(
|
||||
existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')),
|
||||
'the ledger must be written under the fallback root',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDataRoot — CLAUDE_PLUGIN_DATA wins; empty or unset falls back to ~/.claude/voyage', () => {
|
||||
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }), '/tmp/plugin-data');
|
||||
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
|
||||
assert.equal(resolveDataRoot({ HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
|
||||
});
|
||||
|
||||
// ---- (c) worst-case arithmetic ----------------------------------------------
|
||||
|
|
@ -180,9 +219,10 @@ test('isStormEnabled — only the literal string "1" enables', () => {
|
|||
assert.equal(isStormEnabled({}), false);
|
||||
});
|
||||
|
||||
test('resolveLedgerPath — null when CLAUDE_PLUGIN_DATA unset or empty', () => {
|
||||
assert.equal(resolveLedgerPath({}), null);
|
||||
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '' }), null);
|
||||
test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => {
|
||||
const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl');
|
||||
assert.equal(resolveLedgerPath({ HOME: '/home/x' }), expected);
|
||||
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), expected);
|
||||
});
|
||||
|
||||
test('resolveLedgerPath — joins CLAUDE_PLUGIN_DATA with the ledger filename', () => {
|
||||
|
|
@ -224,6 +264,19 @@ test('CLI shim — denies and exits 1 when disabled', () => {
|
|||
assert.equal(parsed.reason, 'storm_disabled');
|
||||
});
|
||||
|
||||
test('CLI shim — grants with CLAUDE_PLUGIN_DATA STRIPPED from the environment', () => {
|
||||
withTmpDataDir((home) => {
|
||||
const r = runShimStripped(
|
||||
['--run-id', 'shim-stripped', '--dimension', 'd1', '--effort', 'high'],
|
||||
{ VOYAGE_STORM_ENABLED: '1', HOME: home },
|
||||
);
|
||||
assert.equal(r.code, 0, `shim must grant without CLAUDE_PLUGIN_DATA; got: ${r.out}`);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.ok(existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')));
|
||||
});
|
||||
});
|
||||
|
||||
test('CLI shim — missing required args exits 1 with usage reason', () => {
|
||||
const r = runShim(['--run-id', 'shim-3']);
|
||||
assert.equal(r.code, 1);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue