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
|
|
@ -462,12 +462,18 @@ unconditionally for every session that does not. That is what keeps a globally
|
|||
wired PreToolUse hook from denying tool calls in unrelated sessions. Write the
|
||||
marker once, immediately before the first turn:
|
||||
|
||||
`CLAUDE_PLUGIN_DATA` is **empty in the Bash tool's process env** even in a
|
||||
plugin-enabled session, so the root is resolved with the same fallback
|
||||
`research-loop-cap.mjs` uses — `~/.claude/voyage`. Reader and writer must
|
||||
resolve identically; a marker written where the hook does not look leaves the
|
||||
hook allowing unconditionally while the docs call it enforcing.
|
||||
|
||||
```bash
|
||||
# Arms the PreToolUse cap for THIS session only.
|
||||
# CLAUDE_CODE_SESSION_ID is the same id the hook reads as `session_id`.
|
||||
DATA="${CLAUDE_PLUGIN_DATA:-}"
|
||||
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
|
||||
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
|
||||
if [ -n "$SCOPE_DIR" ] && mkdir -p "$SCOPE_DIR" 2>/dev/null; then
|
||||
if [ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && mkdir -p "$SCOPE_DIR" 2>/dev/null; then
|
||||
printf '{"runId":"%s","startedAt":"%s"}\n' "{run_id}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
|
||||
else
|
||||
|
|
@ -475,6 +481,10 @@ else
|
|||
fi
|
||||
```
|
||||
|
||||
An empty `CLAUDE_CODE_SESSION_ID` is checked before the path is composed, not
|
||||
after: unset, the marker becomes `.json`, which no hook lookup matches and no
|
||||
TTL sweep ever cleans up.
|
||||
|
||||
`runId` MUST be the same `{run_id}` passed to `research-loop-cap.mjs --run-id`.
|
||||
The hook counts ledger lines carrying that id, so a marker written with any
|
||||
other id counts zero turns and enforces nothing.
|
||||
|
|
@ -493,9 +503,13 @@ crashed session runs no cleanup at all, and is covered instead by the hook's
|
|||
TTL (default 6h, `VOYAGE_CAP_SCOPE_TTL_MS`), which auto-resets a stale marker.
|
||||
|
||||
```bash
|
||||
# Removal — idempotent, safe to repeat.
|
||||
[ -n "${CLAUDE_PLUGIN_DATA:-}" ] && \
|
||||
rm -f "${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/${CLAUDE_CODE_SESSION_ID}.json"
|
||||
# Removal — idempotent, safe to repeat. Same root, same absolute-path guard as
|
||||
# the write: a remove that accepts a root the write rejected (or vice versa)
|
||||
# leaves markers the loop believes it cleaned up.
|
||||
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
|
||||
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
|
||||
[ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && \
|
||||
rm -f "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
|
||||
```
|
||||
|
||||
### Per-turn protocol
|
||||
|
|
@ -504,20 +518,37 @@ Each turn targets exactly one under-illuminated dimension, and runs two gates
|
|||
before it spends anything:
|
||||
|
||||
```bash
|
||||
# 0. Resolve the plugin root ONCE. ${CLAUDE_PLUGIN_ROOT} is substituted in this
|
||||
# command's text but is EMPTY in the Bash tool's process env, and a bare
|
||||
# `node ${CLAUDE_PLUGIN_ROOT}/lib/…` then runs `node /lib/…`, which exits 1 —
|
||||
# indistinguishable from a gate that said no.
|
||||
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
|
||||
case "$VOYAGE_ROOT" in
|
||||
/*) ;;
|
||||
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage 2>/dev/null | head -1)" ;;
|
||||
esac
|
||||
if [ ! -f "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" ]; then
|
||||
echo "[voyage] gates could not run — plugin root unresolved (exit 2). NOT a denial:"
|
||||
echo " stop the loop and report to the operator. Never proceed ungated."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 1. Budget gate — per turn, per dimension. Exit 0 = granted, exit 1 = denied.
|
||||
# JSON on stdout: {ok, used, budget, reason?}
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/research-loop-cap.mjs \
|
||||
node "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" \
|
||||
--run-id {run_id} --dimension {dimension} --effort {phase_signal_result.effort}
|
||||
|
||||
# 2. Privacy gate — EVERY outbound query, before it leaves the machine.
|
||||
# Exit 0 = send as-is; exit 1 = rewrite the query and re-gate. Never bypass.
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/query-privacy-gate.mjs "{query text}"
|
||||
node "$VOYAGE_ROOT/lib/validators/query-privacy-gate.mjs" "{query text}"
|
||||
```
|
||||
|
||||
A denied budget gate is an exit condition, not a retry. A failed privacy gate
|
||||
is a rewrite: the hard-block tier (secret-shaped strings) is never
|
||||
operator-overridable, so a query that trips it must be reformulated, not
|
||||
forced through.
|
||||
**Exit 2 is not exit 1.** A denied budget gate is an exit condition, not a
|
||||
retry. A failed privacy gate is a rewrite: the hard-block tier (secret-shaped
|
||||
strings) is never operator-overridable, so a query that trips it must be
|
||||
reformulated, not forced through. A gate that *could not run* is neither — no
|
||||
rewrite can clear it, so treat it as a hard stop and say so, rather than
|
||||
rewriting a query that was never the problem.
|
||||
|
||||
**Empty turns.** A turn that returns no findings, or findings without
|
||||
citations, is marked `empty`. An empty turn is counted in `empty_turns` and
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue