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
Review finding 4638fea9 (MAJOR emitted, catalogue tier BLOCKER). Live in every
session regardless of the STORM flag, so it is not deferrable to the measurement
decision.
Anchoring the rule to command position (S75-S78) was right in intent - the
unanchored word match blocked quoted grep patterns, heredoc data and commit
messages - but it ran against the whitespace-collapsed string. Measured against
normalizeCommand() output, five forms that the previous rule blocked were
allowed:
- newline separator: \s+ -> ' ' collapsed the newline BEFORE the pattern ran,
making the \n branch of the separator class dead code
- `&` background separator: absent from the class entirely
- `bash -c` / `sh -c`: the wrapped command sits inside quotes, never at
command position
- `xargs <cmd>`: no separator in front of the command at all
And it missed its own motivating case: `grep "a|b" f` stayed blocked, because
the `|` inside the quotes still read as a separator.
Fix: the rule now runs against a command-position view (`commandView: true`,
per-rule input selection) instead of the collapsed string. The view keeps
newlines, adds `&` to the separator class, and classifies each span:
- quoted spans -> data (one space), so a grep alternation, echoed prose and a
commit message pass
- EXCEPT the argument of a shell wrapper (`sh -c`, `bash -c`, with optional
sudo and absolute path) -> spliced back in at command position
- heredoc bodies -> data, keeping the operator line. Restoring the newline
separator without this would newly block every heredoc line starting with a
matched word - the exact friction anchoring existed to remove
- `xargs [flags]` -> separator inserted after the flags
Two defects found while verifying, both the same regression class and both
fixed here rather than left:
- `\name` runs name (the backslash only suppresses alias expansion). The old
unanchored rule blocked it; the anchored one allowed it.
- heredoc bodies, as above - a false positive this change would otherwise have
introduced.
Known limit, stated rather than implied: `xargs -I {} <cmd>` is not parsed, so
the inserted separator lands before the argument, not the command.
Other BLOCK rules are untouched and still run against the collapsed string.
Verified by a 37-case adversarial probe through the real hook (all five bypass
forms, both wrapper forms, backslash, heredoc, quoted alternation, ordinary
commands, and the unrelated rules): 37/37 as expected. 9 new tests.
Suite 952 (950/0/2, baseline 937 + 15 across both Track A fixes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zNqxP8qTWgJhn3wMYUFEh
S21 = devil's-advocate blind spot #2 (security/safety), operator scope
"security-core": fix genuine defects, honestly record the rest.
SSRF fix (CWE-918). validateOtlpEndpoint classified the host by literal
string match. Decimal/hex/octal/trailing-dot encodings are already caught
(the WHATWG URL parser canonicalizes them), but IPv4-mapped IPv6 literals
(::ffff:127.0.0.1, ::ffff:192.168.x, ::ffff:169.254.169.254) render as
::ffff:HHHH:HHHH and matched neither the loopback set, the RFC-1918 regex,
link-local, nor HARD_BLOCKED_HOSTS — they passed over https and would reach
loopback / private / cloud-metadata, defeating the comment's "PERMANENTLY
block metadata" promise. Added mappedV4() to decode the embedded IPv4
(dotted + hex-pair forms) and classify on it. TDD: 4 tests failing-first —
mapped loopback/RFC-1918/metadata rejected (metadata stays HARD_BLOCKED even
with VOYAGE_OTEL_ALLOW_PRIVATE=1), mapped public ::ffff:8.8.8.8 still valid
(no over-block). Threat model narrow (endpoint is operator-set env; export
opt-in; a brief cannot set env).
Survivor #18 honest-residual. T2-bakeoff §3 "Classifier interference: 0"
read as satisfied, but the auto/bypass-mode re-run that matters for headless
trekreview was never run (mode is operator-set, not settable in-session) —
footnoted, not gating. Per recommendation #9, moved to an OPEN RESIDUAL
(untested), guarded by a new doc-consistency pin.
Audit record. ## S21 resolution block in devils-advocate-results.md
dispositions all four sub-questions: SSRF (fixed), hooks-block (verified;
advisory-rail residuals: Write-only matcher, Bash-redirect, regex gaps — by
design), malicious-brief-headless (catastrophe-blocked, exfil NOT blocked —
inherent limit). S21b flagged (NOT done): operations.md:15 mis-describes
autonomy-gate.mjs state machine + --gates table.
Test count: real baseline 700 (698 pass / 2 skip), NOT 715 — the node:test
headline carried in S20 commit msgs was stale/miscounted (census figures
were correct). Now 705 (703 pass / 2 skip / 0 fail); census behavior
601->605, doc-pins 71->72, total 672->677.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
S2 of the 2.1.181 upgrade. Schemas verified verbatim against the official
slash-commands and hooks docs before editing (a first-pass camelCase
'disallowedTools' claim was caught and corrected to kebab-case against the doc).
- CC-14 (SHIP): migrate all 7 hooks in hooks/hooks.json to exec-form
{command:"node", args:["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/X.mjs"]}. Doc
recommends exec-form whenever a hook references a path placeholder; protects
consumers installing under a path with spaces. ${CLAUDE_PLUGIN_ROOT}
interpolates inside args (verified). hooks-json-stop-wired test made
form-agnostic (normalizes command+args to one invocation string).
- CC-11 (SHIP): add `disallowed-tools: Agent, TeamCreate` to trekexecute
frontmatter, enforcing its documented "No Agent tool, no TeamCreate" rule.
allowed-tools grants auto-approval but does NOT remove tools from the pool,
so the prior omission left Agent callable; disallowed-tools removes it.
trekexecute is the only command with a documented exclusion.
- CC-15 (DECIDE: keep universal): re-affirm F2 deferral. pre-bash/pre-write
executors stay universal -- session-agnostic safety (rm -rf /, ~/.ssh, .env)
that narrowing to execute-only would only weaken. Header comments corrected.
- CC-10 (DECIDE: design note, no code): no blanket Agent(model:opus) deny rule
-- would break balanced/economy profiles; any model-enforcement must be
profile-aware, deferred into W2. Folded into open question #3.
Matrix updated with S2 resolutions section. Tests 578 pass / 0 fail / 2 skip;
claude plugin validate passes (only pre-existing root-CLAUDE.md warning).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
Found by simulert v4.1 smoke — doc/code-drift in v4.1 ship:
docs/observability.md claims "Cloud metadata endpoints (169.254.169.254)
are permanently blocked" but the validator allowed them when
VOYAGE_OTEL_ALLOW_PRIVATE=1. Cloud metadata services expose IAM
credentials and instance secrets — operator-trust extended to
RFC-1918 home-lab access does NOT extend here, because the
blast-radius (cloud-account compromise) is qualitatively different.
New HARD_BLOCKED_HOSTS set checked BEFORE the link-local opt-in path:
- 169.254.169.254 (AWS / GCP / Azure metadata)
- 100.100.100.200 (AliCloud metadata)
- metadata.google.internal
- metadata.azure.com
New error code ENDPOINT_HARD_BLOCKED. Existing test for
ENDPOINT_LINK_LOCAL_REJECTED on 169.254.169.254 updated to assert
the new code; 3 new tests verify the hard-block holds even with
VOYAGE_OTEL_ALLOW_PRIVATE=1, plus AliCloud + GCP-hostname coverage.
Tests: 487 → 490 pass + 2 skipped.
Step 13 of v4.1 — adds Stop hook entry pointing to
hooks/scripts/otel-export.mjs (added in Step 12 / commit cced21b).
Mounts the orchestrator on Claude Code's Stop event so OTel/Prometheus
export runs at session-end when VOYAGE_EXPORT_MODE is set.
HIGH-risk-mitigering: tests/hooks/hooks-json-stop-wired.test.mjs
asserter at Stop-key finnes, refererer otel-export.mjs, bruker
\${CLAUDE_PLUGIN_ROOT}-substitusjon, og har type:command.
Tests: 464 → 468 (4 new). All green.
Pins existing BLOCK rules in the two pre-* executor hooks so a future
silent weakening of BLOCK_RULES surfaces as test failures instead of
slipping through code review.
50 new tests covering both hooks plus allow-list pins (lib/, tests/,
docs/, ls, git, npm) and fail-open on malformed input. Reuses
tests/helpers/hook-helper.mjs child-process spawner.
[skip-docs]
New hooks/scripts/post-compact-flush.mjs (PostCompact event, CC v2.1.105+):
auto-discovers <cwd>/.claude/projects/*/.session-state.local.json (most
recently modified), validates it via session-state-validator, emits
additionalContext via stdout so the post-compact assistant turn has
Handover 7 resume context loaded immediately.
Read-only — never writes. Always exits 0; never blocks compaction. Uses
only node:fs sync APIs available since Node 12 (no glob dependency).
Companion to the existing pre-compact-flush.mjs:
- PreCompact: refresh progress.json + .session-state.local.json
- PostCompact: re-inject .session-state.local.json into context
Wired in hooks/hooks.json under a new PostCompact matcher block.
Both files staged via /tmp/claude-* and copied into hooks/* via Bash to
respect the llm-security plugin path-guard (which blocks direct Write to
hooks/scripts/*.mjs and hooks*.json).
Test: tests/hooks/post-compact-flush.test.mjs (4 tests) covers no-state,
malformed-state, valid-state, and multi-project mtime selection.