diff --git a/CONTEXT.md b/CONTEXT.md index 91c7e006..a188bfb3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -275,6 +275,10 @@ _Avoid_: task-executor (task collides with the harness's own task tools, and **s The per-slice record a **Slice executor** writes at each completed stage, and the anchor an interrupted slice resumes from. It preserves the resume granularity that per-transition `subState` writes give today, now that one executor spawn spans several stages. The executor owns the record and reads it back to resume itself; the orchestrator never opens it, obtaining its contents only through a dedicated MCP tool when an envelope is missing or invalid — the same structured-recovery path as the **Worktree fallback**. It also carries the once-only **Model fallback** guard, which is why that guard survives a handoff without living in the orchestrator's checkpoint. _Avoid_: slice state, slice checkpoint (the run-state checkpoint is the orchestrator's; this record is the executor's) +**Read guard**: +The plugin-level `PreToolUse` **Hook** that mechanically denies the orchestrator a read of a slice-internal artifact — the **Slice progress record** and the slice report — and returns a reason naming the correct behaviour instead. It tells the orchestrator from a subagent by the agent identity the hook event carries, is scoped to `Read` and `Bash`, and is a silent no-op with no run in progress. It is **defence in depth for the prose rule, never a replacement**: policy can disable plugin hooks, and `@`-referenced files reach the model without any tool call at all, so the boundary stated in the orchestrator's own instructions stays load-bearing. See ADR-0017. +_Avoid_: read block, permission rule (a permission rule was the rejected alternative — it would restrict the executor too and carries no corrective message) + **Failure class**: The closed enum a **Slice executor** returns naming *why* a slice failed, alongside the prose failure reason. It exists because the layer holding the evidence and the layer holding tracker authority are no longer the same one: the executor observes the failure and classifies it, and the orchestrator maps the class to a triage label and writes it. Classification follows the evidence; labelling policy stays with the single writer. _Avoid_: failure reason (that is the prose companion, not the enum), error code diff --git a/plugins/orchestrate/README.md b/plugins/orchestrate/README.md index 832a6c23..1c20ede4 100644 --- a/plugins/orchestrate/README.md +++ b/plugins/orchestrate/README.md @@ -144,6 +144,12 @@ A long backlog can exhaust the orchestrator session before every wave is done When several runs proceed concurrently in one repository, the watchdog binds to the correct run by **driver-session identity**: a companion `SessionStart` hook captures the session's `session_id` into `$ORCHESTRATE_SESSION_ID`, the orchestrator records it as `driverSessionId` in `run-state.json` (refreshed on resume), and the watchdog matches the event's `session_id` against each in-progress run — writing the flag only under the matching run's directory. If it cannot disambiguate, or the identity is unavailable, the watchdog safely writes nothing: the run stays correct and merely loses automatic handoff, remaining manually resumable with `/orchestrate`. +### Read guard + +The slice-executor delegation layer saves the orchestrator's context by keeping slice-internal artifacts — each slice's progress record and its report — out of the orchestrator's window entirely: it learns a slice's outcome from the executor's result envelope and passes those paths forward without opening them. The bundled `read-guard` hook (a `PreToolUse` hook scoped to `Read` and `Bash`) enforces that boundary mechanically, denying such a read and returning a reason that names what to do instead — use the envelope's own fields, and recover the record through the `recover_slice_progress` tool if the envelope is missing or invalid. It tells the orchestrator from a subagent by the agent identity the hook event carries, so an executor reading its own record is untouched, and with no run in progress it is a silent no-op for every path. + +It is **defence in depth, not a dependency.** The same boundary is stated as a rule in the orchestrator's own instructions and stays load-bearing: enterprise policy (`allowManagedHooksOnly`) or a user setting (`disableAllHooks`, which is all-or-nothing and would also give up the context watchdog) can switch plugin hooks off, and even when enabled the hook cannot see a file referenced with `@` in a prompt, which Claude Code inserts without any tool call. + ## Installation ```bash @@ -421,7 +427,8 @@ plugins/orchestrate/ ├── README.md # This file ├── hooks/ │ └── hooks.json # context-watchdog (PostToolUse) + -│ # session-start (SessionStart) hooks +│ # session-start (SessionStart) + +│ # read-guard (PreToolUse) hooks ├── skills/ │ ├── orchestrate/ │ │ ├── SKILL.md # The orchestrator judgment spine @@ -440,7 +447,7 @@ plugins/orchestrate/ ├── src/ # Tool implementations ├── test/ # Unit suite └── dist/ # Bundled server + context-watchdog + - # session-start hooks + # session-start + read-guard hooks ``` ## Contributing to orchestrate-mcp @@ -450,7 +457,7 @@ The `orchestrate-mcp/` directory contains a TypeScript MCP server whose compiled ```bash cd plugins/orchestrate/orchestrate-mcp npm ci # if node_modules is stale -npm run build # regenerates dist/index.js, dist/context-watchdog.js, dist/session-start.js +npm run build # regenerates dist/index.js, dist/context-watchdog.js, dist/session-start.js, dist/read-guard.js git add dist/ ``` diff --git a/plugins/orchestrate/hooks/hooks.json b/plugins/orchestrate/hooks/hooks.json index e4e0e2ec..d7b3729c 100644 --- a/plugins/orchestrate/hooks/hooks.json +++ b/plugins/orchestrate/hooks/hooks.json @@ -12,6 +12,18 @@ ] } ], + "PreToolUse": [ + { + "matcher": "Read|Bash", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/orchestrate-mcp/dist/read-guard.js\"", + "timeout": 5 + } + ] + } + ], "PostToolUse": [ { "matcher": ".*", diff --git a/plugins/orchestrate/orchestrate-mcp/dist/read-guard.js b/plugins/orchestrate/orchestrate-mcp/dist/read-guard.js new file mode 100755 index 00000000..ae154bce --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/dist/read-guard.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// src/hooks/read-guard.ts +var path = __toESM(require("path")); +var GUARDED_BASENAME = /^slice-\d+-(progress\.json|report\.md)$/; +var READ_VERBS = /* @__PURE__ */ new Set([ + "cat", + "head", + "tail", + "less", + "more", + "bat", + "sed", + "awk", + "grep", + "rg", + "jq", + "od", + "xxd", + "strings", + "nl", + "wc", + "source", + "." +]); +var READ_GUARD_DENY_REASON = "orchestrate: the orchestrator does not open slice-internal artifacts. Use the slice-executor envelope's own fields for the slice's outcome, and pass reportPath forward without opening it. If the envelope is missing or invalid, recover the progress record's contents through the recover_slice_progress MCP tool, which derives the path from (runId, issue) and returns validated structured data."; +function denyPayload(reason) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + } + }; +} +function unquote(token) { + const match = token.match(/^(["'])(.*)\1$/); + return match ? match[2] : token; +} +function isGuardedPath(candidate, cwd, runDir) { + if (candidate === "") return false; + const resolved = path.resolve(cwd, candidate); + return path.dirname(resolved) === runDir && GUARDED_BASENAME.test(path.basename(resolved)); +} +function bashReadsGuardedPath(command, cwd, runDir) { + for (const segment of command.split(/&&|\|\||;|\||\n/)) { + const tokens = segment.trim().split(/\s+/).filter((t) => t !== ""); + if (tokens.length === 0) continue; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + let candidate; + if (token === "<") candidate = tokens[i + 1]; + else if (token.startsWith("<") && !token.startsWith("<<")) { + candidate = token.slice(1); + } + if (candidate !== void 0 && isGuardedPath(unquote(candidate), cwd, runDir)) { + return true; + } + } + let start = 0; + while (start < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start])) { + start++; + } + if (start >= tokens.length) continue; + if (!READ_VERBS.has(path.basename(unquote(tokens[start])))) continue; + for (const token of tokens.slice(start + 1)) { + if (isGuardedPath(unquote(token), cwd, runDir)) return true; + } + } + return false; +} +function decideReadGuard(input) { + const none = { decision: "none" }; + try { + if (typeof input.agentId === "string" && input.agentId.length > 0) { + return none; + } + const runId = input.activeRunId; + if (typeof runId !== "string" || runId.length === 0) return none; + if (typeof input.cwd !== "string" || input.cwd.length === 0) return none; + const toolInput = input.toolInput; + if (typeof toolInput !== "object" || toolInput === null) return none; + const cwd = input.cwd; + const runDir = path.resolve(cwd, ".orchestrate", "runs", runId); + if (input.toolName === "Read") { + const filePath = toolInput.file_path; + if (typeof filePath === "string" && isGuardedPath(filePath, cwd, runDir)) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + if (input.toolName === "Bash") { + const command = toolInput.command; + if (typeof command === "string" && bashReadsGuardedPath(command, cwd, runDir)) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + return none; + } catch { + return none; + } +} + +// src/hooks/run-discovery.ts +var path2 = __toESM(require("path")); +var fs = __toESM(require("fs")); +function scanInProgressRuns(cwd) { + const runsDir = path2.join(cwd, ".orchestrate", "runs"); + let entries; + try { + entries = fs.readdirSync(runsDir, { withFileTypes: true }); + } catch { + return []; + } + const runs = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const statePath = path2.join(runsDir, entry.name, "run-state.json"); + let runState; + try { + runState = JSON.parse(fs.readFileSync(statePath, "utf8")); + } catch { + continue; + } + if (typeof runState !== "object" || runState === null || runState.status !== "in-progress") { + continue; + } + const rawId = runState.driverSessionId; + runs.push({ + runId: entry.name, + driverSessionId: typeof rawId === "string" ? rawId : null + }); + } + return runs; +} +function findActiveRunForSession(cwd, sessionId) { + const runs = scanInProgressRuns(cwd); + if (runs.length === 0) return null; + if (typeof sessionId === "string" && sessionId.length > 0) { + const matches = runs.filter((r) => r.driverSessionId === sessionId); + if (matches.length === 1) return matches[0].runId; + } + if (runs.length === 1) return runs[0].runId; + return null; +} + +// src/hooks/read-guard-cli.ts +function readStdin() { + return new Promise((resolve2) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve2(data)); + process.stdin.on("error", () => resolve2(data)); + }); +} +async function main() { + let raw = ""; + try { + raw = await readStdin(); + } catch { + process.exit(0); + } + try { + const event = raw ? JSON.parse(raw) : {}; + const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + const sessionId = typeof event.session_id === "string" && event.session_id.length > 0 ? event.session_id : void 0; + const runId = findActiveRunForSession(cwd, sessionId); + if (runId === null) { + process.exit(0); + } + const decision = decideReadGuard({ + toolName: typeof event.tool_name === "string" ? event.tool_name : void 0, + toolInput: typeof event.tool_input === "object" && event.tool_input !== null ? event.tool_input : void 0, + // Present ONLY inside a subagent call, which is what makes it — and not + // `agent_type`, which a `--agent` session also carries — the main-thread + // discriminator. + agentId: typeof event.agent_id === "string" ? event.agent_id : void 0, + cwd, + activeRunId: runId + }); + if (decision.decision === "deny") { + process.stdout.write(JSON.stringify(denyPayload(decision.reason))); + } + } catch { + } + process.exit(0); +} +void main(); diff --git a/plugins/orchestrate/orchestrate-mcp/package.json b/plugins/orchestrate/orchestrate-mcp/package.json index 229b44be..4e1c112e 100644 --- a/plugins/orchestrate/orchestrate-mcp/package.json +++ b/plugins/orchestrate/orchestrate-mcp/package.json @@ -4,7 +4,7 @@ "description": "MCP server providing worktree lifecycle and orchestration tools for parallel Claude Code agents.", "main": "dist/index.js", "scripts": { - "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js && esbuild src/hooks/context-watchdog-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/context-watchdog.js && esbuild src/hooks/session-start-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/session-start.js", + "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js && esbuild src/hooks/context-watchdog-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/context-watchdog.js && esbuild src/hooks/session-start-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/session-start.js && esbuild src/hooks/read-guard-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/read-guard.js", "typecheck": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard-cli.ts b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard-cli.ts new file mode 100644 index 00000000..e888d552 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard-cli.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import { decideReadGuard, denyPayload } from "./read-guard.js"; +import { findActiveRunForSession } from "./run-discovery.js"; + +// Entry point for the `read-guard` PreToolUse hook. Claude Code pipes the hook +// event JSON on stdin; this script asks `read-guard.ts` whether the call is the +// orchestrator opening a slice-internal artifact, and on a deny writes the +// `hookSpecificOutput` payload to stdout. Every decision — deny and no-decision +// alike — exits 0: JSON output is only processed on exit 0, and silence plus +// exit 0 is the documented "no decision, normal permission flow applies" path. +// A blocking hook that crashed would be worse than one that missed, so the +// whole body is wrapped in a swallowing try/catch. +// +// All judgement lives in the pure module. This file does two things the module +// cannot: it reads the event, and it resolves the active run from the +// filesystem — the same split `context-watchdog-cli.ts` uses, where +// `findActiveRunForSession` is called here rather than inside the hook module. +// +// The run resolution is also the guard's OFF SWITCH: with no in-progress run, +// or with concurrent runs this session cannot be disambiguated against, +// discovery returns null and every path is allowed. That short-circuit is +// duplicated inside the module (which no-ops on an absent `activeRunId`), so +// the behaviour is unit-testable rather than reachable only through a process. + +/** Reads all of stdin as a string. Resolves with whatever arrived on error. */ +function readStdin(): Promise { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", () => resolve(data)); + }); +} + +async function main(): Promise { + let raw = ""; + try { + raw = await readStdin(); + } catch { + process.exit(0); + } + + try { + const event = raw ? (JSON.parse(raw) as Record) : {}; + const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + // An EMPTY `session_id` is treated as absent, the same guard + // `findActiveRunForSession` applies — an empty string is not an identity. + const sessionId = + typeof event.session_id === "string" && event.session_id.length > 0 + ? event.session_id + : undefined; + + const runId = findActiveRunForSession(cwd, sessionId); + if (runId === null) { + process.exit(0); + } + + const decision = decideReadGuard({ + toolName: typeof event.tool_name === "string" ? event.tool_name : undefined, + toolInput: + typeof event.tool_input === "object" && event.tool_input !== null + ? (event.tool_input as Record) + : undefined, + // Present ONLY inside a subagent call, which is what makes it — and not + // `agent_type`, which a `--agent` session also carries — the main-thread + // discriminator. + agentId: typeof event.agent_id === "string" ? event.agent_id : undefined, + cwd, + activeRunId: runId, + }); + + if (decision.decision === "deny") { + process.stdout.write(JSON.stringify(denyPayload(decision.reason))); + } + } catch { + // Any failure is swallowed — the guard must not disrupt the session. + } + process.exit(0); +} + +void main(); diff --git a/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard.ts b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard.ts new file mode 100644 index 00000000..49572343 --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard.ts @@ -0,0 +1,321 @@ +import * as path from "path"; + +// ─── Read guard — the orchestrator's slice-artifact read boundary ───────────── +// +// ADR-0017 gives the slice-executor delegation layer a read boundary: the +// orchestrator learns a slice's outcome from the executor's result envelope, +// never by opening the slice's own artifacts. That boundary buys the whole +// context saving the delegation layer exists for, and until now it was prose +// only — and prose guards erode late in a long run, which is exactly when the +// saving matters most. +// +// This module is the mechanical half. It decides, for one `PreToolUse` hook +// event, whether a tool call is the orchestrator reaching for a slice-internal +// artifact. It is DEFENCE IN DEPTH, not a dependency: the prose rule in +// `references/run-state.md` stays load-bearing, for the reasons in "Honest +// limitations" below. A later refactor must not delete it on the grounds that +// this hook covers it. +// +// PURE by construction — no filesystem access, no `process.cwd()`. The active +// run is discovered in the CLI entry point (`read-guard-cli.ts`) via +// `run-discovery.ts` and passed in, exactly as `context-watchdog-cli.ts` calls +// `findActiveRunForSession` rather than the watchdog module doing it. `cwd` is +// likewise an ARGUMENT: a bare `path.resolve` would silently fall back to the +// hook PROCESS's working directory and stop matching every relative path a +// Bash command carries. +// +// ─── What it guards, and what it deliberately does not ─────────────────────── +// +// Deny requires ALL of: +// +// 1. No `agent_id` on the event — see "identity" below. +// 2. An active run id — with none, every path is allowed (AC4). +// 3. The resolved path sits DIRECTLY IN `/.orchestrate/runs//` +// and its basename matches {@link GUARDED_BASENAME}. +// +// Clause 3 is narrower than the run directory ON PURPOSE. The same directory +// holds `run-state.json` — the orchestrator's OWN checkpoint, which it reads +// and writes at every slice — plus `context-flag.json`, `spawn-log.jsonl` and +// three rendered `.html` artifacts (`run-dir.ts`). A run-directory-wide rule +// would break every run, silently, and only in the field. Containment is by +// `path.dirname` equality rather than a prefix test, because `runs/` is a +// string prefix of `runs/-suffix`; that also neutralises `..` traversal. +// +// The basename pattern is structural rather than literal because the issue +// number is part of the contract: the record is deliberately per-slice, not +// per-run (`run-dir.ts`), so a parallel wave's siblings never clobber each +// other, and `run-dir.ts` declares the `slice--progress.json` filename +// a PUBLIC CONTRACT rather than an implementation detail. +// +// ─── Identity: `agent_id`, not `agent_type` ────────────────────────────────── +// +// The vendor hook reference (`docs/claude-code/hooks/claude-hook-reference-doc.md`, +// the "when running with --agent or inside a subagent" input table) documents +// `agent_id` as "present only when the hook fires inside a subagent call. Use +// this to distinguish subagent hook calls from main-thread calls." `agent_type` +// is ALSO present when the session itself runs with `--agent`, so it can be +// non-empty on a main thread and is the wrong discriminator. +// +// This is an ALLOW-BY-PRESENCE test, and the asymmetry is worth naming: if the +// platform ever stopped emitting `agent_id`, this guard would over-block every +// subagent rather than under-block the orchestrator — the UNSAFE direction +// relative to "never blocks a call it did not intend to block". It cannot be +// fixed from inside the hook; the citation above is here so the dependency is +// visible, and the retained prose rule is the designed fallback either way. +// An empty string is treated as absent, the same guard `context-watchdog-cli.ts` +// applies to `session_id`. +// +// ─── The deny contract ─────────────────────────────────────────────────────── +// +// {@link denyPayload} returns the exact wire object. Per the vendor mirror, +// `PreToolUse` returns its decision inside `hookSpecificOutput`; the top-level +// `decision`/`reason` fields are DEPRECATED for this event. Only a `deny` +// decision's reason is shown to Claude ("for allow and ask, shown to the user +// but not Claude; for deny, shown to Claude"), which is why the reason — not +// just the refusal — carries the correction. The alternative blocking +// mechanism, exit 2 with stderr, also blocks, but presents as an ERROR rather +// than a policy decision and breaks this package's uniform exit-0 discipline: +// do not "simplify" toward it. Silence plus exit 0 is the documented no-op. +// +// ─── Honest limitations ────────────────────────────────────────────────────── +// +// Six, all real, none closable here: +// +// - The Bash arm is a HEURISTIC over a command string, not a sandbox. It +// covers the enumerated read verbs and redirection sources. It does not +// cover a base64-encoded path, a path held in a shell variable, a +// `find -exec`, a heredoc body, or an alias. It closes the routes a model +// reaches for by default; it does not make evasion impossible. +// - The subcommand split is TEXTUAL and quote-blind, which cuts both ways. +// A separator inside a quoted argument splits the command anyway, so +// `grep -E "PASS|FAIL" ` becomes `grep -E "PASS` plus +// `FAIL" `, whose first token is not a read verb, and the +// read is ALLOWED — verified, and not a contrived shape. Deliberately not +// fixed: a quote-aware splitter buys a little coverage and risks false +// denies, and under-blocking is this module's declared safe direction. +// - The same blindness runs the OTHER way, and this list would be dishonest +// without it. A quoted argument that happens to contain a separator +// followed by ` ` — a `gh pr create --body` +// narrating `"did X; cat …/slice-7-report.md and moved on"` — is denied +// even though nothing is read. It is the only known over-block, it takes a +// command that both names an artifact and describes reading it, and the +// deny reason at least explains itself; rephrasing clears it. +// - The `Read` arm compares the LEXICALLY resolved path, never the link +// target: a symlink outside the run directory pointing at a guarded +// artifact is allowed. Resolving links would mean `fs` in a module that is +// pure by construction (see above), and would still not close the Bash arm. +// - `@`-references bypass it entirely. The mirror is explicit: "PreToolUse +// runs only when Claude calls a tool. Files you reference with @ in your +// prompt are added without any tool call ... so no PreToolUse hook fires +// for them, INCLUDING hooks matching Read." The documented closure — a +// `Read` deny rule — is precisely the alternative ADR-0017 rejected, since +// permission rules apply to the whole session and would restrict the +// executor too. This hole is larger than any Bash-heuristic gap. +// - The identity inversion described above. +// - Run discovery fails OPEN. `findActiveRunForSession` returns null both +// when no run is in progress AND when two or more runs are in progress and +// the session cannot be disambiguated — so the guard can no-op MID-RUN +// under concurrent runs, not only when idle. It also returns null when the +// session's `cwd` is a subdirectory that has no `.orchestrate/runs/` (#374, +// not fixed here). Every one of those is an ALLOW, which is the safe +// direction: the guard is weaker there, never wrong there. + +/** The two slice-internal artifacts, matched structurally on any issue number. */ +const GUARDED_BASENAME = /^slice-\d+-(progress\.json|report\.md)$/; + +/** + * Shell commands that READ a file's contents. Enumerated rather than inferred: + * the alternative — denying whenever a guarded path merely APPEARS in a command + * string — would refuse `echo`, a commit message, or a `gh issue comment` that + * names the artifact without opening it, which is a false deny in the middle of + * a live run. `source` and `.` are here because they read a file to execute it. + */ +const READ_VERBS = new Set([ + "cat", + "head", + "tail", + "less", + "more", + "bat", + "sed", + "awk", + "grep", + "rg", + "jq", + "od", + "xxd", + "strings", + "nl", + "wc", + "source", + ".", +]); + +/** The normalised hook event this module decides on. */ +export interface ReadGuardInput { + /** The event's `tool_name`. */ + toolName?: string; + /** The event's `tool_input` — `file_path` for `Read`, `command` for `Bash`. */ + toolInput?: Record; + /** The event's `agent_id`. Absent or empty means the main thread. */ + agentId?: string; + /** The event's `cwd`. Relative paths are resolved against it. */ + cwd?: string; + /** The run this session drives, resolved by the CLI. Absent means no run. */ + activeRunId?: string; +} + +/** Deny with a reason shown to Claude, or take no decision at all. */ +export type ReadGuardDecision = + | { decision: "none" } + | { decision: "deny"; reason: string }; + +/** The `PreToolUse` deny object, exactly as the platform expects it. */ +export interface PreToolUseDenyPayload { + hookSpecificOutput: { + hookEventName: "PreToolUse"; + permissionDecision: "deny"; + permissionDecisionReason: string; + }; +} + +/** + * The reason a denied read carries back to the model. Pinned as an exported + * constant so the hook and its tests cannot drift apart, and written as an + * INSTRUCTION rather than a refusal: it is the only thing the model is told + * about what to do instead, so it names both routes — the envelope's own + * fields for the outcome, and the structured-recovery tool for the record. + */ +export const READ_GUARD_DENY_REASON = + "orchestrate: the orchestrator does not open slice-internal artifacts. Use " + + "the slice-executor envelope's own fields for the slice's outcome, and pass " + + "reportPath forward without opening it. If the envelope is missing or " + + "invalid, recover the progress record's contents through the " + + "recover_slice_progress MCP tool, which derives the path from (runId, issue) " + + "and returns validated structured data."; + +/** Wraps a reason in the `PreToolUse` deny contract. */ +export function denyPayload(reason: string): PreToolUseDenyPayload { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }; +} + +/** Strip one layer of surrounding single or double quotes from a shell token. */ +function unquote(token: string): string { + const match = token.match(/^(["'])(.*)\1$/); + return match ? match[2] : token; +} + +/** Does this candidate resolve to a guarded artifact of the active run? */ +function isGuardedPath(candidate: string, cwd: string, runDir: string): boolean { + if (candidate === "") return false; + const resolved = path.resolve(cwd, candidate); + return ( + path.dirname(resolved) === runDir && + GUARDED_BASENAME.test(path.basename(resolved)) + ); +} + +/** + * Does this command string read a guarded artifact? + * + * The command is split into subcommands on `&&`, `||`, `;`, `|` and newlines, + * mirroring how the platform's own `if`-field matching checks each subcommand + * rather than only the first. Within a subcommand, leading `VAR=value` + * assignments are stripped — again matching the platform — and then two arms + * fire: an enumerated read verb applied to a guarded path, or a redirection + * whose source is one. `<<` is excluded so a heredoc marker is not read as a + * redirection source. + */ +function bashReadsGuardedPath( + command: string, + cwd: string, + runDir: string +): boolean { + for (const segment of command.split(/&&|\|\||;|\||\n/)) { + const tokens = segment.trim().split(/\s+/).filter((t) => t !== ""); + if (tokens.length === 0) continue; + + // Redirection is not a verb: `< file` has no command in front of it. + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + let candidate: string | undefined; + if (token === "<") candidate = tokens[i + 1]; + else if (token.startsWith("<") && !token.startsWith("<<")) { + candidate = token.slice(1); + } + if (candidate !== undefined && isGuardedPath(unquote(candidate), cwd, runDir)) { + return true; + } + } + + let start = 0; + while (start < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start])) { + start++; + } + if (start >= tokens.length) continue; + // A verb may be invoked by path (`/bin/cat`); `.` basenames to itself, + // while `./script.sh` basenames to `script.sh` and is correctly not a verb. + if (!READ_VERBS.has(path.basename(unquote(tokens[start])))) continue; + for (const token of tokens.slice(start + 1)) { + if (isGuardedPath(unquote(token), cwd, runDir)) return true; + } + } + return false; +} + +/** + * Decide whether one `PreToolUse` event is the orchestrator opening a + * slice-internal artifact. Returns `{ decision: "none" }` for everything else + * — including every tool other than `Read` and `Bash`, which `hooks.json`'s + * `Read|Bash` matcher already keeps away from this handler, and which is also + * what leaves the `recover_slice_progress` MCP tool untouched. + * + * Never throws. A malformed or unexpected event takes no decision. + */ +export function decideReadGuard(input: ReadGuardInput): ReadGuardDecision { + const none: ReadGuardDecision = { decision: "none" }; + try { + // A subagent — the executor and its workers — reads its own artifacts. + if (typeof input.agentId === "string" && input.agentId.length > 0) { + return none; + } + const runId = input.activeRunId; + if (typeof runId !== "string" || runId.length === 0) return none; + if (typeof input.cwd !== "string" || input.cwd.length === 0) return none; + const toolInput = input.toolInput; + if (typeof toolInput !== "object" || toolInput === null) return none; + + const cwd = input.cwd; + const runDir = path.resolve(cwd, ".orchestrate", "runs", runId); + + if (input.toolName === "Read") { + const filePath = toolInput.file_path; + if (typeof filePath === "string" && isGuardedPath(filePath, cwd, runDir)) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + + if (input.toolName === "Bash") { + const command = toolInput.command; + if ( + typeof command === "string" && + bashReadsGuardedPath(command, cwd, runDir) + ) { + return { decision: "deny", reason: READ_GUARD_DENY_REASON }; + } + return none; + } + + return none; + } catch { + // A guard that throws is worse than a guard that misses. + return none; + } +} diff --git a/plugins/orchestrate/orchestrate-mcp/test/hook-matcher-consistency.test.ts b/plugins/orchestrate/orchestrate-mcp/test/hook-matcher-consistency.test.ts new file mode 100644 index 00000000..e69dcd7d --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/test/hook-matcher-consistency.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +// ─── Hook-matcher consistency ───────────────────────────────────────────────── +// +// A hook matcher that names an agent type which no shipped definition provides +// reads as configured and behaves as if it were not: Claude Code simply never +// matches it, silently, forever. That is the same failure class the parity +// suite guards for `skills:` — a misspelling that looks like configuration. +// +// The invariant this file encodes: +// +// For every hook event whose matcher filters AGENT TYPE, every agent name in +// that matcher must correspond to a shipped definition under +// `plugins/orchestrate/agents/`, compared against the PLUGIN-SCOPED +// identifier `orchestrate:` — never the bare frontmatter +// name. +// +// Two facts pin the shape, both from the vendor hook reference mirror +// (`docs/claude-code/hooks/claude-hook-reference-doc.md`): +// +// - Only `SubagentStart` and `SubagentStop` match on agent type. Every other +// matcher-bearing event — `PreToolUse` included, which is what the read +// guard adds — matches on TOOL NAME. So this invariant is currently +// VACUOUSLY GREEN over the shipped file, and would stay decorative if it +// were only ever run against that file. +// - For a subagent shipped by a plugin, the agent identity is the +// plugin-scoped identifier (`my-plugin:reviewer`), not the bare frontmatter +// name. +// +// The vacuity is designed away the same way `agent-variant-parity.test.ts` +// handles its own ("a comparison over sections found in both is vacuously +// green when a section is missing from both"): the checker is a pure function +// exercised against RED fixtures first, and only then against the real file. +// The bare-name fixture is the load-bearing one — without it this suite does +// not encode the plugin-scoping half of the invariant at all. +// +// The checker lives here rather than in `src/hooks/read-guard.ts`: it has +// nothing to do with the read decision and would ship as dead bytes inside +// `dist/read-guard.js`. + +/** Hook events whose `matcher` filters agent type rather than tool name. */ +const AGENT_TYPE_MATCHER_EVENTS = new Set(["SubagentStart", "SubagentStop"]); + +/** The plugin name every shipped subagent identity is scoped by. */ +const PLUGIN_NAME = "orchestrate"; + +// test/ → orchestrate-mcp/ → orchestrate/ +const pluginDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ".." +); +const agentsDir = path.join(pluginDir, "agents"); +const hooksJsonPath = path.join(pluginDir, "hooks", "hooks.json"); + +/** + * Read one top-level scalar frontmatter field. Returns undefined when the key + * is absent, so a missing field is reported as such rather than as a mismatch. + */ +function frontmatterField( + frontmatter: string, + key: string +): string | undefined { + for (const line of frontmatter.split("\n")) { + const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$/); + if (match && match[1] === key) return match[2].trim(); + } + return undefined; +} + +/** + * Discover every shipped subagent's identity by READING the directory — never + * a hardcoded list, which would go stale exactly when a definition is added or + * renamed, the moment this check matters most. The identity is the definition's + * `name:` frontmatter field, not its filename. + */ +function shippedAgentNames(): string[] { + const names: string[] = []; + for (const file of fs.readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) { + const raw = fs.readFileSync(path.join(agentsDir, file), "utf8"); + const lines = raw.split("\n"); + if (lines[0] !== "---") continue; + const closing = lines.indexOf("---", 1); + if (closing === -1) continue; + const name = frontmatterField(lines.slice(1, closing).join("\n"), "name"); + if (name !== undefined && name !== "") names.push(name); + } + return names.sort(); +} + +/** + * A matcher entry is a PLAIN NAME when, after its optional `^`/`$` anchors are + * stripped, nothing regex-significant remains. Anything else — `.*`, a + * character class, an alternation group — is a deliberate pattern rather than + * a name, and is SKIPPED rather than reported: a checker that failed on `.*` + * would produce a false failure on the two matchers this plugin already ships. + */ +function plainNameOf(entry: string): string | null { + const stripped = entry.trim().replace(/^\^/, "").replace(/\$$/, "").trim(); + if (stripped === "") return null; + return /^[A-Za-z0-9_:-]+$/.test(stripped) ? stripped : null; +} + +/** + * Every agent name a matcher names. Alternation accepts BOTH `|` and `,` — + * splitting on only one separator would silently skip half the entries of a + * comma-separated matcher, which is a false GREEN, the direction that matters. + */ +function matcherNames(matcher: string): string[] { + return matcher + .split(/[|,]/) + .map(plainNameOf) + .filter((entry): entry is string => entry !== null); +} + +/** + * Returns one violation string per agent name that a matcher names and no + * shipped definition provides. An empty array means the file is consistent. + */ +export function validateAgentMatchers( + hooksJson: unknown, + shippedScopedNames: Set +): string[] { + const violations: string[] = []; + if (typeof hooksJson !== "object" || hooksJson === null) return violations; + const hooks = (hooksJson as Record).hooks; + if (typeof hooks !== "object" || hooks === null) return violations; + + for (const [event, blocks] of Object.entries( + hooks as Record + )) { + if (!AGENT_TYPE_MATCHER_EVENTS.has(event)) continue; + if (!Array.isArray(blocks)) continue; + for (const block of blocks) { + const matcher = + typeof block === "object" && block !== null + ? (block as Record).matcher + : undefined; + if (typeof matcher !== "string") continue; + for (const name of matcherNames(matcher)) { + if (!shippedScopedNames.has(name)) { + violations.push( + `${event} matcher names "${name}", which no shipped agent definition provides` + ); + } + } + } + } + return violations; +} + +const shippedNames = shippedAgentNames(); +// The identity a hook event carries for a PLUGIN subagent is the scoped form, +// never the bare frontmatter name. Building the set bare — the obvious reading +// — produces a checker that passes every fixture except the one that matters. +const shippedScopedNames = new Set( + shippedNames.map((name) => `${PLUGIN_NAME}:${name}`) +); + +/** A `hooks.json` shape carrying one agent-type matcher. */ +function hooksJsonWithSubagentMatcher(matcher: string): unknown { + return { + hooks: { + SubagentStart: [ + { + matcher, + hooks: [{ type: "command", command: "node noop.js", timeout: 5 }], + }, + ], + }, + }; +} + +describe("shippedAgentNames", () => { + it("discovers the plugin's agent definitions from the directory", () => { + // Guards the checker's own input: an empty set would make every fixture + // below "violate", and the real file trivially consistent-looking only + // because nothing was compared. + expect(shippedNames.length).toBeGreaterThan(0); + expect(shippedNames).toContain("reviewer-deep"); + expect(shippedNames).toContain("slice-executor-standard"); + }); +}); + +describe("validateAgentMatchers", () => { + it("reports a matcher naming an agent with no shipped definition", () => { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(`${PLUGIN_NAME}:no-such-agent`), + shippedScopedNames + ) + ).toHaveLength(1); + }); + + it("reports a BARE agent name even when a definition carries that frontmatter name", () => { + // The load-bearing case. `reviewer-deep` IS a shipped definition's `name:`, + // but the identity a hook event carries for a plugin subagent is + // `orchestrate:reviewer-deep`. A matcher spelled bare therefore matches + // nothing at runtime while looking entirely correct in review. + expect(shippedNames).toContain("reviewer-deep"); + const violations = validateAgentMatchers( + hooksJsonWithSubagentMatcher("reviewer-deep"), + shippedScopedNames + ); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain("reviewer-deep"); + }); + + it("accepts a correctly scoped name, anchored or bare of anchors", () => { + for (const matcher of [ + `${PLUGIN_NAME}:reviewer-deep`, + `^${PLUGIN_NAME}:reviewer-deep$`, + ]) { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(matcher), + shippedScopedNames + ), + `"${matcher}" is a valid identity` + ).toEqual([]); + } + }); + + it("splits alternation on both `|` and `,`", () => { + // Splitting on only one separator would skip the other's entries entirely + // — a false green, the direction that matters. + for (const matcher of [ + `${PLUGIN_NAME}:reviewer-deep|nope-a`, + `${PLUGIN_NAME}:reviewer-deep,nope-b`, + ]) { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(matcher), + shippedScopedNames + ), + `"${matcher}" hides one bad entry` + ).toHaveLength(1); + } + }); + + it("skips a non-trivial regex rather than reporting it as a bad name", () => { + for (const matcher of [".*", `^${PLUGIN_NAME}:(reviewer|implementer)-.*$`]) { + expect( + validateAgentMatchers( + hooksJsonWithSubagentMatcher(matcher), + shippedScopedNames + ), + `"${matcher}" is a pattern, not a name` + ).toEqual([]); + } + }); + + it("ignores events whose matcher filters tool name, not agent type", () => { + // `PreToolUse` matches on tool name, so `Read|Bash` is not a claim about + // any agent and must never be reported. + expect( + validateAgentMatchers( + { hooks: { PreToolUse: [{ matcher: "Read|Bash", hooks: [] }] } }, + shippedScopedNames + ) + ).toEqual([]); + }); + + it("never throws on a malformed or empty configuration", () => { + for (const value of [null, undefined, {}, { hooks: null }, 42, "x"]) { + expect(() => validateAgentMatchers(value, shippedScopedNames)).not.toThrow(); + expect(validateAgentMatchers(value, shippedScopedNames)).toEqual([]); + } + }); +}); + +describe("the shipped hooks.json", () => { + const shipped = JSON.parse(fs.readFileSync(hooksJsonPath, "utf8")); + + it("names no agent type without a shipped definition", () => { + expect(validateAgentMatchers(shipped, shippedScopedNames)).toEqual([]); + }); + + it("registers the read guard as a PreToolUse hook", () => { + const blocks = shipped.hooks?.PreToolUse; + expect(Array.isArray(blocks)).toBe(true); + const entries = blocks.flatMap((b: { hooks?: unknown[] }) => b.hooks ?? []); + expect( + entries.some((e: { command?: string }) => + (e.command ?? "").includes("read-guard.js") + ) + ).toBe(true); + }); + + it("scopes the read guard to Read and Bash, not to every tool", () => { + // AC6 at the wiring level. `.*` would put the handler in front of every + // call — including the `recover_slice_progress` MCP tool the deny reason + // points at as the structured alternative. + const block = shipped.hooks.PreToolUse.find( + (b: { hooks?: { command?: string }[] }) => + (b.hooks ?? []).some((e) => (e.command ?? "").includes("read-guard.js")) + ); + expect(block.matcher).toBe("Read|Bash"); + }); + + it("does not run the read guard asynchronously", () => { + // An async PreToolUse hook cannot block. Copying the watchdog's + // `"async": true` would silently defeat the deny while every unit test + // above stayed green — the defect no module test can see. + const entries = shipped.hooks.PreToolUse.flatMap( + (b: { hooks?: { command?: string; async?: boolean }[] }) => b.hooks ?? [] + ).filter((e: { command?: string }) => + (e.command ?? "").includes("read-guard.js") + ); + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + expect(entry.async).toBeUndefined(); + } + }); +}); diff --git a/plugins/orchestrate/orchestrate-mcp/test/read-guard.test.ts b/plugins/orchestrate/orchestrate-mcp/test/read-guard.test.ts new file mode 100644 index 00000000..086f17fb --- /dev/null +++ b/plugins/orchestrate/orchestrate-mcp/test/read-guard.test.ts @@ -0,0 +1,574 @@ +import { describe, it, expect } from "vitest"; +import { + decideReadGuard, + denyPayload, + READ_GUARD_DENY_REASON, + type ReadGuardInput, +} from "../src/hooks/read-guard.js"; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── +// +// The guard's whole job is a decision about a hook event, so the event's field +// names are the contract under test. They are spelled in exactly ONE place — +// the builders below — so a platform rename is one edit rather than a sweep, +// and so a wrong guess (`path` instead of `file_path` for `Read`) fails loudly +// here rather than producing a module that parses cleanly and denies nothing. +// +// `file_path` for `Read` and `command` for `Bash` are the vendor-documented +// `tool_input` shapes; see the module's own doc comment for the citation. + +const CWD = "/repo"; +const RUN_ID = "prd352-20260803-015333"; +const RUN_DIR = `${CWD}/.orchestrate/runs/${RUN_ID}`; + +/** The `tool_input` a `Read` call carries. */ +function readInput(filePath: string): Record { + return { file_path: filePath, offset: 0, limit: 200 }; +} + +/** The `tool_input` a `Bash` call carries. */ +function bashInput(command: string): Record { + return { command, description: "a command", timeout: 120000 }; +} + +/** + * A hook-event-derived guard input with the main-thread defaults: an active + * run, the repo root as `cwd`, and NO `agent_id` (which is what "main thread" + * means — the field is emitted only inside a subagent call). + */ +function hookEvent(overrides: Partial = {}): ReadGuardInput { + return { + toolName: "Read", + toolInput: readInput(`${RUN_DIR}/slice-361-report.md`), + cwd: CWD, + activeRunId: RUN_ID, + ...overrides, + }; +} + +describe("READ_GUARD_DENY_REASON", () => { + it("names the envelope as the primary route to the slice's outcome", () => { + // Half of AC2: the deny reason is the ONLY instruction the model receives + // about what to do instead, so it must name the correct behaviour and not + // merely refuse. + expect(READ_GUARD_DENY_REASON).toContain("envelope"); + expect(READ_GUARD_DENY_REASON).toContain("reportPath"); + }); + + it("names the structured-recovery tool as the fallback route", () => { + expect(READ_GUARD_DENY_REASON).toContain("recover_slice_progress"); + }); +}); + +describe("denyPayload", () => { + it("emits the PreToolUse hookSpecificOutput contract, not the deprecated top-level fields", () => { + // Pins the WIRE contract. A module that returned the deprecated top-level + // `decision`/`reason` would satisfy every decision test above while + // producing a hook the platform ignores. + const payload = denyPayload("because"); + expect(payload).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "because", + }, + }); + expect(payload).not.toHaveProperty("decision"); + expect(payload).not.toHaveProperty("reason"); + }); +}); + +describe("decideReadGuard — identity", () => { + it("denies a main-thread Read of a slice report", () => { + const result = decideReadGuard(hookEvent()); + expect(result.decision).toBe("deny"); + // Both clauses of AC2, asserted separately: the decision AND the reason. + expect(result).toEqual({ + decision: "deny", + reason: expect.stringContaining("envelope"), + }); + expect( + result.decision === "deny" ? result.reason : "" + ).toContain("recover_slice_progress"); + }); + + it("denies a main-thread Read of a slice progress record", () => { + const result = decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/slice-361-progress.json`) }) + ); + expect(result.decision).toBe("deny"); + }); + + it("allows the same read from a subagent, identified by agent_id", () => { + // AC3. `agent_id` is emitted only inside a subagent call, which is what + // makes it — and not `agent_type` — the discriminator. + expect(decideReadGuard(hookEvent({ agentId: "sub-abc123" }))).toEqual({ + decision: "none", + }); + }); + + it("treats an empty agent_id as absent, so it still denies", () => { + expect(decideReadGuard(hookEvent({ agentId: "" })).decision).toBe("deny"); + }); +}); + +describe("decideReadGuard — no run in progress", () => { + it("is a silent no-op for a guarded path when no run is active", () => { + // AC4. Also covers the run-discovery paths that yield no runId at all: + // a cwd outside the run's repo, and two concurrent runs that cannot be + // disambiguated. + expect(decideReadGuard(hookEvent({ activeRunId: undefined }))).toEqual({ + decision: "none", + }); + }); + + it("is a silent no-op when the active run id is empty", () => { + expect(decideReadGuard(hookEvent({ activeRunId: "" }))).toEqual({ + decision: "none", + }); + }); + + it("is a silent no-op for a guarded Bash read when no run is active", () => { + expect( + decideReadGuard( + hookEvent({ + activeRunId: undefined, + toolName: "Bash", + toolInput: bashInput(`cat ${RUN_DIR}/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("is a silent no-op when cwd is unavailable", () => { + expect(decideReadGuard(hookEvent({ cwd: undefined }))).toEqual({ + decision: "none", + }); + }); +}); + +describe("decideReadGuard — path scoping", () => { + it("allows run-state.json, the orchestrator's own checkpoint", () => { + // The single most dangerous over-block available: the orchestrator reads + // and writes this file at every slice, so a run-directory-wide rule would + // break every run — silently, and only in the field. + expect( + decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/run-state.json`) }) + ) + ).toEqual({ decision: "none" }); + }); + + it.each(["dashboard.html", "graph.html", "report.html"])( + "allows the rendered %s artifact in the run directory", + (name) => { + expect( + decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/${name}`) }) + ) + ).toEqual({ decision: "none" }); + } + ); + + it("allows the run directory's other bookkeeping files", () => { + for (const name of ["context-flag.json", "spawn-log.jsonl"]) { + expect( + decideReadGuard( + hookEvent({ toolInput: readInput(`${RUN_DIR}/${name}`) }) + ), + `${name} must not be guarded` + ).toEqual({ decision: "none" }); + } + }); + + it("allows a guarded basename that lives outside the active run directory", () => { + // The rule is scoped to the run directory, not to the filename: an + // executor's own worktree copy, or another run's directory, is not this + // guard's business. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${CWD}/notes/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("allows a guarded basename in a DIFFERENT run's directory", () => { + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput( + `${CWD}/.orchestrate/runs/backlog-20260101-000000/slice-361-report.md` + ), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("is not fooled by a run directory whose name merely PREFIXES the active one", () => { + // `runs/` is a prefix of `runs/-suffix`, so a startsWith-based + // containment check would deny a path this run does not own. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${RUN_DIR}-suffix/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("denies any issue number, not just the one this slice was written for", () => { + for (const issue of ["1", "361", "99999"]) { + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${RUN_DIR}/slice-${issue}-progress.json`), + }) + ).decision, + `slice-${issue}-progress.json must be guarded` + ).toBe("deny"); + } + }); + + it("resolves a relative path against the supplied cwd", () => { + // The cwd is passed IN. A bare path.resolve would fall back to the hook + // PROCESS's cwd and silently stop matching every relative path. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput( + `.orchestrate/runs/${RUN_ID}/slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("resolves traversal segments before deciding", () => { + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput( + `${RUN_DIR}/nested/../slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("allows a guarded basename nested BELOW the run directory", () => { + // Both artifacts sit directly in the run directory; a nested lookalike is + // not one of them. + expect( + decideReadGuard( + hookEvent({ + toolInput: readInput(`${RUN_DIR}/nested/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); +}); + +describe("decideReadGuard — tool scoping", () => { + it("takes no decision on the structured-recovery MCP tool", () => { + // AC9. The recovery tool's input carries a `repoPath`, so a module that + // keyed off "any path-shaped string in tool_input" would deny the very + // escape hatch the deny reason points at. + expect( + decideReadGuard( + hookEvent({ + toolName: "mcp__plugin_orchestrate_orchestrate__recover_slice_progress", + toolInput: { repoPath: CWD, runId: RUN_ID, issue: 361 }, + }) + ) + ).toEqual({ decision: "none" }); + }); + + it.each(["Edit", "Write", "Grep", "Glob", "Agent", "TodoWrite"])( + "takes no decision on an unrelated tool (%s)", + (toolName) => { + // AC6 at the module level; `hooks.json`'s matcher is the other half and + // is asserted in test/hook-matcher-consistency.test.ts. + expect( + decideReadGuard(hookEvent({ toolName, toolInput: { path: RUN_DIR } })) + ).toEqual({ decision: "none" }); + } + ); + + it("takes no decision when the tool name is absent", () => { + expect(decideReadGuard(hookEvent({ toolName: undefined }))).toEqual({ + decision: "none", + }); + }); +}); + +describe("decideReadGuard — malformed input", () => { + it("never throws on a missing or wrongly-typed tool_input", () => { + // AC7. A hook that throws is a hook that breaks the session. + const inputs: ReadGuardInput[] = [ + hookEvent({ toolInput: undefined }), + hookEvent({ toolInput: {} }), + hookEvent({ toolInput: { file_path: 42 as unknown as string } }), + hookEvent({ toolInput: { file_path: "" } }), + hookEvent({ toolName: "Bash", toolInput: {} }), + hookEvent({ toolName: "Bash", toolInput: { command: null } }), + {}, + ]; + for (const input of inputs) { + expect(() => decideReadGuard(input)).not.toThrow(); + expect(decideReadGuard(input)).toEqual({ decision: "none" }); + } + }); +}); + +// ─── The Bash arm ───────────────────────────────────────────────────────────── +// +// A stated heuristic, not a sandbox. The deny-tests below enumerate the read +// verbs the module claims to cover; the allow-tests are equally load-bearing, +// because a false deny in the middle of a live run is the AC7 failure that +// actually costs something. + +const READ_VERBS = [ + "cat", + "head", + "tail", + "less", + "more", + "bat", + "sed", + "awk", + "grep", + "rg", + "jq", + "od", + "xxd", + "strings", + "nl", + "wc", +]; + +describe("decideReadGuard — Bash reads", () => { + it.each(READ_VERBS)("denies `%s` applied to a guarded path", (verb) => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`${verb} ${RUN_DIR}/slice-361-report.md`), + }) + ).decision, + `\`${verb}\` on a guarded path must be denied` + ).toBe("deny"); + }); + + it("denies a quoted guarded path", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`cat "${RUN_DIR}/slice-361-report.md"`), + }) + ).decision + ).toBe("deny"); + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`cat '${RUN_DIR}/slice-361-progress.json'`), + }) + ).decision + ).toBe("deny"); + }); + + it("denies a verb invoked by absolute path", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`/bin/cat ${RUN_DIR}/slice-361-report.md`), + }) + ).decision + ).toBe("deny"); + }); + + it("denies past leading environment assignments", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `LC_ALL=C cat ${RUN_DIR}/slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("denies a guarded read in a LATER subcommand of a chain", () => { + for (const chain of ["&&", "||", ";", "|"]) { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `npm test ${chain} cat ${RUN_DIR}/slice-361-report.md` + ), + }) + ).decision, + `a guarded read after \`${chain}\` must be denied` + ).toBe("deny"); + } + }); + + it("denies a redirection whose source is a guarded path", () => { + for (const command of [ + `jq . < ${RUN_DIR}/slice-361-progress.json`, + `while read -r l; do echo "$l"; done <${RUN_DIR}/slice-361-report.md`, + ]) { + expect( + decideReadGuard( + hookEvent({ toolName: "Bash", toolInput: bashInput(command) }) + ).decision, + `redirection in \`${command}\` must be denied` + ).toBe("deny"); + } + }); + + it("denies sourcing a guarded path", () => { + for (const verb of ["source", "."]) { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`${verb} ${RUN_DIR}/slice-361-report.md`), + }) + ).decision, + `\`${verb}\` on a guarded path must be denied` + ).toBe("deny"); + } + }); + + it("denies a relative guarded path resolved against cwd", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `cat .orchestrate/runs/${RUN_ID}/slice-361-report.md` + ), + }) + ).decision + ).toBe("deny"); + }); +}); + +describe("decideReadGuard — Bash commands that must NOT be denied", () => { + it("allows a command that merely MENTIONS a guarded path", () => { + // The AC7 clause most likely to be missed, and the one that breaks a live + // run: the orchestrator names these files constantly — in echoes, in + // commit messages, in issue comments — without ever reading them. + for (const command of [ + `echo "wrote ${RUN_DIR}/slice-361-report.md"`, + `git commit -m "slice 361: see slice-361-report.md"`, + `gh issue comment 361 --body "progress: ${RUN_DIR}/slice-361-progress.json"`, + `ls -l ${RUN_DIR}/slice-361-report.md`, + `rm -f ${RUN_DIR}/slice-361-progress.json`, + ]) { + expect( + decideReadGuard( + hookEvent({ toolName: "Bash", toolInput: bashInput(command) }) + ), + `\`${command}\` reads nothing and must be allowed` + ).toEqual({ decision: "none" }); + } + }); + + it("allows a read verb applied to an UNGUARDED path", () => { + for (const command of [ + `cat ${RUN_DIR}/run-state.json`, + `cat ${CWD}/package.json`, + `jq .status ${RUN_DIR}/run-state.json`, + ]) { + expect( + decideReadGuard( + hookEvent({ toolName: "Bash", toolInput: bashInput(command) }) + ), + `\`${command}\` must be allowed` + ).toEqual({ decision: "none" }); + } + }); + + it("does not read `./script.sh` as the `.` source builtin", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput(`./script.sh ${RUN_DIR}/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); + + it("does not treat a heredoc marker as a redirection source", () => { + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `cat < { + // Both directions of the SAME limitation, pinned so a later "improvement" + // to the splitter has to face them deliberately. Documented in the + // module's "Honest limitations"; neither is closable without a + // quote-aware parser, which buys little and risks more false denies. + // If a splitter change turns this test RED, that is not automatically a + // regression: re-read the limitation first. The over-block half going + // green-to-red means the false deny was FIXED, and this assertion should + // be deleted rather than the fix reverted to keep it passing. + // + // Under-block: the `|` in the regex splits the command, so the guarded + // path lands in a segment whose first token is not a read verb. + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `grep -E "PASS|FAIL" ${RUN_DIR}/slice-361-report.md` + ), + }) + ) + ).toEqual({ decision: "none" }); + + // Over-block: the only known false deny. A quoted argument NARRATING a + // read — a separator, then a read verb, then a guarded path — reads as a + // subcommand to a textual splitter. + expect( + decideReadGuard( + hookEvent({ + toolName: "Bash", + toolInput: bashInput( + `gh pr create --body 'did X; cat ${RUN_DIR}/slice-361-report.md and moved on'` + ), + }) + ).decision + ).toBe("deny"); + }); + + it("allows a Bash command from a subagent regardless of what it reads", () => { + expect( + decideReadGuard( + hookEvent({ + agentId: "sub-abc123", + toolName: "Bash", + toolInput: bashInput(`cat ${RUN_DIR}/slice-361-report.md`), + }) + ) + ).toEqual({ decision: "none" }); + }); +}); diff --git a/plugins/orchestrate/skills/orchestrate/references/run-state.md b/plugins/orchestrate/skills/orchestrate/references/run-state.md index 6ab82cb7..5789c68a 100644 --- a/plugins/orchestrate/skills/orchestrate/references/run-state.md +++ b/plugins/orchestrate/skills/orchestrate/references/run-state.md @@ -247,7 +247,30 @@ the run's own directory — and returns validated, structured data. It reports a a **malformed** one (`PROGRESS_INVALID`: bad JSON, a schema mismatch, or a record naming a different run or slice), and never throws. This is the same structured-recovery posture as `recover_changed_files`, where the worktree is -ground truth recovered through a tool rather than by reading prose. +ground truth recovered through a tool rather than by reading prose. The slice +**report** the executor writes beside this record falls under the same boundary: +the orchestrator passes its path forward and never opens it. + +**A hook enforces this too — as defence in depth, not as a replacement.** The +plugin ships a `PreToolUse` read guard that denies the orchestrator a `Read` (or +an obvious shell read) of `slice--progress.json` and +`slice--report.md`, and returns a reason naming this behaviour instead. +**The rule above stays load-bearing regardless**, for four reasons, and a later +refactor must not delete it on the grounds that the hook covers it: + +- An enterprise administrator can set `allowManagedHooksOnly`, which blocks + user, project, and plugin hooks alike — only plugins force-enabled in managed + settings are exempt. +- Any user can set `disableAllHooks: true`. There is **no way to disable one + hook while keeping the others**, so opting out of this guard also gives up the + `context-watchdog` hook, and with it automatic context handoff. +- The plugin itself can simply be disabled. +- Even fully enabled, the hook **cannot see every read**. A file referenced with + `@` in a prompt is inserted while the prompt is built, with no tool call, so no + `PreToolUse` hook fires for it — including hooks matching `Read`. The + documented closure is a permission deny rule, which ADR-0017 rejected: it + would apply to the whole session and restrict the executor too, and it carries + no corrective message back to the model. ## Resume