feat(orchestrate): enforce the read boundary with a PreToolUse guard - #377
Merged
rodrigorjsf merged 1 commit intoAug 3, 2026
Merged
rodrigorjsf merged 1 commit into
rodrigorjsf merged 1 commit into
Conversation
rodrigorjsf
merged commit Aug 3, 2026
17dd985
into
orchestrate/umbrella-prd352-20260803-015333
1 check passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice #361 of PRD #352 — orchestrate run
prd352-20260803-015333.The orchestrator's rule against opening a slice's report or progress record was prose only, and prose guards erode late in a long run — exactly when the context saving matters most. This adds a plugin-level
PreToolUsehook that denies the read and hands the model a reason naming what to do instead.One acceptance criterion was refuted, and the honest reading is narrower
AC8 asked for a test over "a hook matcher that names an agent type". Only
SubagentStartandSubagentStopmatch on agent type (claude-hook-reference-doc.md:229); this slice adds aPreToolUse, which matches on tool name. Inventing aSubagentStartmatcher just to have something to test would have been an unrequested hook.What shipped instead is a general invariant over
hooks.json, tested in three layers. The load-bearing one is the bare-name trap: a matcher namingreviewer-deepmust be a violation even though a definition carries that frontmatter name, because the shipped identifier isorchestrate:reviewer-deep. Without that layer the test is decorative — a matcher on the bare name silently never fires, and a guard that silently never fires is worse than no guard, because it is trusted.Design decisions that keep the guard from being silently inert, or silently harmful
matcher: "Read|Bash", never".*". This is what makes AC9 true by tool-name disjointness:recover_slice_progressnever reaches the handler, so structured recovery is unaffected by construction rather than by a special case.asynckey. The watchdog'sPostToolUseblock setsasync: truebecause it only observes. An asyncPreToolUsecannot block — copying that key would leave AC2 unmet with the whole suite green. Asserted against the realhooks.json, not just commented.runs/<id>/would catchrun-state.json, the orchestrator's own checkpoint, breaking every run silently and only in the field. Deny requiresdirnameequality against the resolved run directory and aslice-<n>-(progress.json|report.md)basename.dirnamerather than a prefix test, becauseruns/<id>is a string prefix ofruns/<id>-suffix— and it neutralises..traversal for free.agent_id, notagent_type.agent_typeis also present when the session runs with--agent, so it can be non-empty on a main thread.agent_idis documented as present only inside a subagent call (:644vs:645).Honest limitations, stated in the module rather than implied away
PreToolUsedoes not fire for files referenced with@in a prompt — their contents are inserted while building the prompt, with no tool call, including for hooks matchingRead(:1400). The documented closure is aReaddeny rule, which is precisely the alternativeADR-0017:113-115rejected: permission rules apply session-wide and would restrict the executor too. That hole is larger than any Bash-heuristic gap and is unclosable in this design — which is a fourth reason, beyond organizational policy, that the prose rule stays load-bearing.Review confirmed three further holes the first draft's comment did not admit to, and pinned two with tests: the subcommand splitter is quote-blind, so
grep -E "A|B" <path>splits on the pattern's pipe and is allowed, while a--bodycontaining; cat <path>is denied though nothing is read. The splitter was deliberately left alone — a quote-aware parser buys little coverage and adds false-deny risk, and AC7 makes over-blocking the expensive direction.Verification
Red-green in three stages, not one: a permissive stub turned all 29 deny cases red; a deliberately over-broad implementation turned all 27 allow cases red together, including
run-state.jsonand mere-mention; only the narrow rules close at 1 failure. A one-sided red proof would have accepted a guard that blocks everything.Both implementer and reviewer independently piped real hook events into the committed
dist/read-guard.js— every unit test exercises the pure module, but AC2 grades the hook. Reviewer also rebuilt the bundle and diffed it against the committed one: byte-identical.Orchestrator pre-merge gate: build passed; tests 807/808 with the single known
RE-ENTRANCYbaseline failure;read-guard.test.ts61 green,hook-matcher-consistency.test.ts12 green;dist/adds onlyread-guard.js.Known, deferred
plugins/orchestrate/README.mdis 470 lines against a 400-line cap in.claude/rules/readme-files.md. It was already 463 at HEAD — a pre-existing violation this slice worsens by 7 lines. Fixing it means deleting content other slices added; carried to #363, which owns the README.Closes #361