voyage/docs/spike-pretooluse-subagent-reach.md
Kjell Tore Guttormsen 22cb7df403 fix(cap-hook): shrink the inherited deny window and print the way out of it
DEFAULT_TTL_MS was 6h, measured from marker.startedAt rather than last
activity, and `claude --resume` keeps the same session_id - so a run that
died holding its marker handed the resumed session the remainder of that
window, denying every WebSearch/WebFetch/Task including work unrelated to
research. The header's design goal ("An unrelated session must never be
denied") held across sessions and read as broader than it was: by this
scope key a resume IS the same session.

Three changes, none of which pretends to close it:

- The tombstone boundary (32e20fc) already removed the common case. A run
  that crashed MID-loop leaves no denial record, so the resume is allowed;
  only a crash AFTER the cap denied a turn opens a window at all. Pinned by
  a test with a part-spent ledger and no tombstone.
- TTL 6h -> 2h. A 24-turn loop at a couple of minutes per turn is under an
  hour, so nothing needed six, and debris no longer owns the rest of the
  working day.
- Every denial now prints the marker path with "if this loop is not
  running, delete it", plus the auto-reset horizon. The window existed
  before with no stated remedy, which is what made it expensive.

A liveness check would close it properly. This hook has nothing
trustworthy to check liveness against - the marker's writer is a shell
snippet whose $$ is a subshell, not the session - so the limit is written
into the header as a limit instead of being papered over.

Review finding d913d1b655012fe206ea925b9fc77b401566a39e (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 23:06:47 +02:00

160 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Spike: does a plugin `PreToolUse` hook reach sub-agent tool calls?
**Date:** 2026-08-09
**Claude Code version:** 2.1.226
**Plugin:** voyage 5.9.1 (installed from `ktg-plugin-marketplace`)
**Gates:** Step 10 of `plan.md` (`2026-06-30-trekresearch-storm-upgrade`)
## Question
Step 10 wants to enforce the conversation-turn cap in a `PreToolUse` hook. That
is only viable if a **plugin** `PreToolUse` hook fires on tool calls made
*inside a sub-agent*. If it does not, the cap must be enforced somewhere else
and Step 10 becomes a documented downgrade instead.
The question is genuinely open, not answerable from docs alone: the current
[hooks reference](https://code.claude.com/docs/en/hooks) states that sub-agent
tool calls fire the same hooks and carry `agent_id` / `agent_type`, while
[issue #34692](https://github.com/anthropics/claude-code/issues/34692) reported
the exact opposite behaviour. The answer is therefore version-dependent and had
to be measured on the version actually in use.
## Method
A one-shot probe hook was registered for the `WebSearch` matcher, and a
**headless child session** was launched to exercise it. The child was used
because hooks are resolved when a session starts — a matcher added mid-session
cannot be observed by the session that added it.
Two deviations from the step as originally written, both forced and both
verified not to affect the result:
1. **The matcher was injected into the installed plugin's `hooks.json`, not the
repository's.** The plan assumed the repo working tree *is* the active plugin
root. It is not: `~/.claude/plugins/cache/ktg-plugin-marketplace/voyage/5.9.1/`
is a plain directory holding its own copy, and that copy is what loads.
Editing `hooks/hooks.json` in the repo would have measured nothing. The cache
file was backed up, modified, and restored — verified byte-identical to the
repo file afterwards.
2. **The probe script lives under the session scratchpad, not `${TMPDIR}`.** A
pathguard hook refuses writes to `${TMPDIR}`. The load-bearing property was
only that the script sit **outside `hooks/scripts/`**, which
`tests/lib/doc-consistency.test.mjs:75-84` counts via `readdirSync`; the
scratchpad satisfies that just as well. The directory still holds 7 scripts.
### Probe hook
Logged every invocation as one JSON line (`tool_name`, `agent_id`,
`agent_type`, plus the untouched stdin) and always exited `0`, so it could not
alter the child's behaviour.
### Commands
```bash
# 1. inject the temporary matcher into the INSTALLED plugin
node -e '...push {matcher:"WebSearch", ...voyage-spike-hook.mjs} into hooks.PreToolUse...'
# 2. exercise it from a fresh child session
claude -p "Spawn exactly one sub-agent via the Agent tool (subagent_type: general-purpose).
Instruct that sub-agent to perform exactly ONE WebSearch for the query
'claude code hooks reference' and report back the first result title.
You MUST NOT call WebSearch yourself in the main context - only the
sub-agent may call it. When the sub-agent returns, reply with the word DONE." \
--allowedTools "Agent,Task,WebSearch" \
--max-turns 15
# 3. restore
cp "${TMPDIR}voyage-hooks-backup.json" <cache>/hooks/hooks.json
```
The child returned `DONE`.
> An earlier attempt additionally passed `--permission-mode bypassPermissions`
> and was refused by the auto-mode classifier. The flag was dropped;
> `--allowedTools` alone was sufficient.
## Raw observation
The log contains **exactly one** record — so the main context did not call
`WebSearch` itself, and the single entry is unambiguously the sub-agent's call:
```json
{"at":"2026-08-09T12:07:47.796Z","tool_name":"WebSearch",
"agent_id":"aa6d19525a4680fe0","agent_type":"general-purpose",
"raw_stdin":"{\"session_id\":\"b126fd6a-...\",\"cwd\":\"/Users/ktg/repos/ktg-plugin-marketplace/voyage\",
\"permission_mode\":\"auto\",\"agent_id\":\"aa6d19525a4680fe0\",\"agent_type\":\"general-purpose\",
\"effort\":{\"level\":\"xhigh\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"WebSearch\",
\"tool_input\":{\"query\":\"claude code hooks reference\"},\"tool_use_id\":\"toolu_01Ka4...\"}"}
```
Both `agent_id` and `agent_type` are populated, matching the documented
common-input fields for sub-agent-originated tool events. A main-context call
would have carried neither.
## Consequence for Step 10
A plugin `PreToolUse` hook **does** observe sub-agent tool calls on CC 2.1.226,
and can attribute them via `agent_id` / `agent_type`. Step 10 may therefore take
the enforcement branch rather than the documented-downgrade branch.
Two limits worth carrying forward, neither of which changes the verdict:
- This measures `WebSearch` on one CC version. The behaviour regressed once
before (#34692), so the hook must fail **open**, never assume it is the only
gate, and the cap must remain correct if the hook silently stops firing.
- The probe only establishes *reach*. Whether a **blocking** (exit 2) decision
from inside a sub-agent propagates usefully was not measured — the probe
always exited 0 by design.
RESULT: FIRES
## Enforcement outcome
Step 10 took the **enforcement branch**: `hooks/scripts/pre-agent-cap.mjs`
(PreToolUse, matcher `WebSearch|WebFetch|Task`), pinned by
`tests/hooks/agent-cap.test.mjs`.
What it does: counts turns spent by a run — read-only, from the append-only
ledger that `lib/util/research-loop-cap.mjs` writes — and exits 2 once
`turns_used >= max_conv_turns × maxDimensions`. It never appends to the
ledger; a cap that recorded its own enforcement would count itself.
Scope key, the part that makes a globally-wired `PreToolUse` hook safe:
`session_id` **+** a marker file only the Phase 5 loop writes, at
`${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json`:
```json
{ "runId": "<run id>", "startedAt": "<ISO-8601>" }
```
No marker for the calling session ⇒ out of scope ⇒ allow, unconditionally.
An unrelated session is never denied because some other run spent its budget.
Fail-open and fail-closed are split deliberately:
| Condition | Outcome | Why |
|---|---|---|
| No marker / no `session_id` / unparsable stdin | allow | Not evidence of a loop turn |
| Marker older than TTL (default 2h, `VOYAGE_CAP_SCOPE_TTL_MS`) | allow + auto-reset | A crashed run must not deny tool calls forever, and `--resume` keeps the same `session_id` |
| `VOYAGE_DISABLE_CAP_HOOK=1` | allow | Kill switch |
| `VOYAGE_STORM_ENABLED``1` | allow | Default-off: no loop runs, nothing to enforce |
| In scope, `CLAUDE_PLUGIN_DATA` absent | **deny** | A budget control that cannot count must not grant — same stance as `research-loop-cap.mjs` |
| In scope, budget spent | **deny (exit 2)** | The bound |
Both limits recorded above still hold and are not closed by this step. Reach
was measured on one CC version for one tool, and blocking-propagation from
inside a sub-agent was never measured — so this hook is **defence in depth**,
and `research-loop-cap.mjs` must remain correct on its own if the hook
silently stops firing.
**Follow-up, closed (S79).** Step 10 shipped the hook correct but **latent**
nothing wrote the marker, and it enforces exactly when a marker exists.
`commands/trekresearch.md` Phase 5 now writes it (`### Loop scope marker`,
keyed by `CLAUDE_CODE_SESSION_ID`, carrying the same `run_id` the ledger is
counted under) and removes it on all three exits. Cleanup covers exits only; a
crashed session is covered by the TTL above. Verified end-to-end: the snippet
as shipped arms the hook, the hook denies at `8/8` turns, and the removal
snippet returns it to allow so Phase 6 can still spawn agents. Pinned by
`tests/lib/doc-consistency.test.mjs` (STORM marker), which derives the
directory name from `SCOPE_DIRNAME` in the hook, so renaming either side
fails.