opencode-workflow-guard 1.15.5 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, unlinkSync } from "node:fs";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
- import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
4
+ import { audit, getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
5
  import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
6
6
  import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
7
7
  import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
@@ -56,6 +56,23 @@ const workspaceMutationCounts = new Map<string, number>();
56
56
  export const sessionMutationTimestamps = new Map<string, number>();
57
57
  export const sessionMutationCounts = new Map<string, number>();
58
58
  export const sessionWorkspaces = new Map<string, string>();
59
+ // Per-session, per-task mutation count recorded when an active task entered
60
+ // its current active window. Completion is judged per task: one mutation
61
+ // cannot vouch for several completions.
62
+ export const sessionTodoTaskStarts = new Map<string, Map<string, number>>();
63
+
64
+ export function todoTaskStarts(sessionID: string): Map<string, number> {
65
+ let starts = sessionTodoTaskStarts.get(sessionID);
66
+ if (!starts) {
67
+ starts = new Map();
68
+ sessionTodoTaskStarts.set(sessionID, starts);
69
+ }
70
+ return starts;
71
+ }
72
+
73
+ export function clearTodoSessionState(sessionID: string): void {
74
+ sessionTodoTaskStarts.delete(sessionID);
75
+ }
59
76
  // Sessions whose workspace was registered from their OWN context (tool-call
60
77
  // arguments or a host-provided per-call worktree). A session registered only
61
78
  // from the plugin-instance default is deliberately left unmarked: concurrent
@@ -89,33 +106,155 @@ export function getSessionBoundWorkspace(sessionID?: string): string | undefined
89
106
  }
90
107
 
91
108
  /**
92
- * Register a session's workspace from a tool call's own context. Candidates in
93
- * priority order: tool-argument paths (workdir/filePath — the call's own
94
- * target), then a host-provided per-call worktree/directory. A session that is
95
- * already registered is never re-anchored from the plugin-instance fallback:
96
- * under concurrent loops that fallback can belong to a different session's
97
- * workspace, which is exactly how workspaces cross-contaminate. Only a
98
- * session with no registration at all is bootstrapped from the fallback, and
99
- * such a bootstrap stays unbound (never used to bind review evidence).
109
+ * The nearest bound workspace up the parent chain, for a session with no
110
+ * direct binding of its own.
111
+ *
112
+ * A subagent's policy root already inherits its parent's workspace, so its
113
+ * registration must use the same answer. Without this, a child of a bound
114
+ * session looked unbound to the registration path and its first foreign read
115
+ * rebound it into another repository: the parent stayed confined and the child
116
+ * escaped.
117
+ *
118
+ * Walks at most `MAX_PARENT_DEPTH` links and stops on a cycle.
119
+ *
120
+ * Cost: one `session.get` per parent link, issued only when the session has no
121
+ * direct binding. A directly bound session returns from the in-memory map with
122
+ * no RPC, so the common case pays nothing. The walk is depth-capped and
123
+ * cycle-checked, so a malformed parent graph cannot spin.
124
+ */
125
+ const MAX_PARENT_DEPTH = 16;
126
+
127
+ /**
128
+ * Walk up the parent chain from `sessionID` and return the nearest directly
129
+ * bound ancestor's workspace.
130
+ *
131
+ * Shared by `resolveInheritedWorkspace` (full walk, used by workspace
132
+ * registration so a subagent cannot rebind out of its parent's boundary) and
133
+ * `resolveSessionBoundWorkspace` (one level, used by review-verdict binding).
134
+ * The depth parameter is the only difference; keeping one implementation makes
135
+ * the one-lookup-per-link cost visible in a single place.
136
+ */
137
+ async function resolveAncestorBoundWorkspace(sessionID: string, maxDepth: number): Promise<string | undefined> {
138
+ if (maxDepth <= 0) return undefined;
139
+ const client = getSdkClient();
140
+ const session = client?.session;
141
+ const get = session?.get;
142
+ if (typeof get !== "function") return undefined;
143
+ const seen = new Set<string>([sessionID]);
144
+ let current = sessionID;
145
+ for (let depth = 0; depth < maxDepth; depth++) {
146
+ let parentID: unknown;
147
+ try {
148
+ const result = await get.call(session, { path: { id: current } });
149
+ parentID = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ if (typeof parentID !== "string" || !parentID || seen.has(parentID)) return undefined;
154
+ const inherited = getSessionBoundWorkspace(parentID);
155
+ if (inherited) return inherited;
156
+ seen.add(parentID);
157
+ current = parentID;
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ export async function resolveInheritedWorkspace(sessionID?: string): Promise<string | undefined> {
163
+ if (!sessionID) return undefined;
164
+ const direct = getSessionBoundWorkspace(sessionID);
165
+ if (direct) return direct;
166
+ return resolveAncestorBoundWorkspace(sessionID, MAX_PARENT_DEPTH);
167
+ }
168
+
169
+ /**
170
+ * Register a session's workspace from a tool call's own context.
171
+ *
172
+ * Two distinct operations, deliberately split:
173
+ *
174
+ * - **First bind.** A session with no bound workspace adopts the git root of
175
+ * its own call arguments (`workdir`/`filePath`), else the host-provided
176
+ * per-call worktree, else a path-derived bootstrap from the plugin-instance
177
+ * fallback. A bootstrap from the fallback stays unbound: concurrent loops
178
+ * share this process, so a value derived from the last-active plugin
179
+ * instance must never bind review evidence.
180
+ * - **Widen.** Once bound, only an explicit host-declared per-call worktree
181
+ * may move the boundary. Worktree isolation and relay flows set it, so it is
182
+ * a boundary statement from the host. A bare path candidate outside the
183
+ * boundary is a read or write against foreign state, which the boundary
184
+ * policy refuses; adopting it would let one allowed external read move the
185
+ * whole write boundary.
186
+ *
187
+ * "Bound" includes an inherited binding: a subagent with no direct binding of
188
+ * its own already resolves to its nearest bound ancestor's workspace for
189
+ * policy decisions, so a path-derived candidate must not bind it elsewhere.
190
+ *
191
+ * Every widen and every refused widen is journaled, because the boundary is
192
+ * what unattended operation rests on.
100
193
  */
101
- export function registerSessionWorkspaceFromToolCall(
194
+ export async function registerSessionWorkspaceFromToolCall(
102
195
  sessionID: string | undefined,
103
- call: { workdir?: string; filePath?: string; hostWorktree?: string; toolWorktree: string },
104
- ): void {
196
+ call: { workdir?: string; filePath?: string; hostWorktree?: string; toolWorktree: string; mutating?: boolean },
197
+ ): Promise<void> {
105
198
  if (!sessionID) return;
106
- const rawCandidate = call.workdir ?? call.filePath ?? call.hostWorktree;
107
- if (rawCandidate) {
108
- const candidatePath = isAbsolute(rawCandidate) ? rawCandidate : resolve(call.toolWorktree, rawCandidate);
109
- const gitRoot = findGitRoot(candidatePath);
110
- if (gitRoot) {
111
- sessionWorkspaces.set(sessionID, gitRoot);
112
- sessionWorkspaceBound.add(sessionID);
113
- }
199
+ const bound = await resolveInheritedWorkspace(sessionID);
200
+
201
+ // A host-declared per-call worktree is an explicit boundary statement.
202
+ const hostRoot = call.hostWorktree ? findGitRoot(call.hostWorktree) : undefined;
203
+ if (hostRoot && hostRoot !== bound) {
204
+ setSessionWorkspace(sessionID, hostRoot);
205
+ auditWorkspaceRebind(sessionID, bound, hostRoot, "host-worktree");
206
+ return;
207
+ }
208
+
209
+ const rawCandidate = call.workdir ?? call.filePath;
210
+ if (!rawCandidate) {
211
+ if (sessionWorkspaces.has(sessionID)) return;
212
+ const gitRoot = findGitRoot(call.toolWorktree);
213
+ sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
114
214
  return;
115
215
  }
116
- if (sessionWorkspaces.has(sessionID)) return;
117
- const gitRoot = findGitRoot(call.toolWorktree);
118
- sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
216
+
217
+ const candidatePath = isAbsolute(rawCandidate) ? rawCandidate : resolve(call.toolWorktree, rawCandidate);
218
+ const gitRoot = findGitRoot(candidatePath);
219
+ if (!gitRoot) return;
220
+ if (gitRoot === bound) return;
221
+
222
+ if (!bound) {
223
+ sessionWorkspaces.set(sessionID, gitRoot);
224
+ sessionWorkspaceBound.add(sessionID);
225
+ return;
226
+ }
227
+ auditWorkspaceRebindRefused(sessionID, bound, gitRoot, call.mutating ? "mutation" : "read");
228
+ }
229
+
230
+ /** Durable record of a granted boundary widening. */
231
+ function auditWorkspaceRebind(sessionID: string, from: string | undefined, to: string, source: string): void {
232
+ audit({
233
+ ts: new Date().toISOString(),
234
+ sessionID,
235
+ tool: "workspace.binding",
236
+ decision: "allow",
237
+ phase: "event",
238
+ reason: "workspace_rebind_host_worktree",
239
+ input: { from, to, source },
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Durable record of a refused widening. The refusal itself is the boundary
245
+ * policy's decision; this entry exists so an operator can tell whether a
246
+ * foreign path arrived through a read or a mutation.
247
+ */
248
+ function auditWorkspaceRebindRefused(sessionID: string, from: string, to: string, source: "read" | "mutation"): void {
249
+ audit({
250
+ ts: new Date().toISOString(),
251
+ sessionID,
252
+ tool: "workspace.binding",
253
+ decision: "block",
254
+ phase: "event",
255
+ reason: "workspace_rebind_refused",
256
+ input: { from, to, source },
257
+ });
119
258
  }
120
259
 
121
260
  /**
@@ -129,19 +268,10 @@ export async function resolveSessionBoundWorkspace(sessionID?: string): Promise<
129
268
  if (!sessionID) return undefined;
130
269
  const direct = getSessionBoundWorkspace(sessionID);
131
270
  if (direct) return direct;
132
- try {
133
- const client = getSdkClient();
134
- const session = client?.session;
135
- const get = session?.get;
136
- if (typeof get === "function") {
137
- const result = await get.call(session, { path: { id: sessionID } });
138
- const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
139
- if (typeof parent === "string" && parent && sessionWorkspaceBound.has(parent)) {
140
- return sessionWorkspaces.get(parent);
141
- }
142
- }
143
- } catch {}
144
- return undefined;
271
+ // One level only: review-verdict binding asks for the immediate parent, and
272
+ // walking further would let a grandparent's workspace answer for a child
273
+ // that was deliberately left unbound.
274
+ return resolveAncestorBoundWorkspace(sessionID, 1);
145
275
  }
146
276
 
147
277
  export let lastVerify: VerifyResult | undefined;
@@ -242,6 +372,7 @@ export function resetVerifyState(): void {
242
372
  workspaceMutationTimestamps.clear();
243
373
  workspaceMutationCounts.clear();
244
374
  sessionVerifyResults.clear();
375
+ sessionTodoTaskStarts.clear();
245
376
  }
246
377
 
247
378
  export function recordReviewResult(
@@ -6,9 +6,11 @@
6
6
  * - `ctx.tool.transform` for custom tools and tool-description enrichment
7
7
  * - `ctx.tool.hook("execute.before"/"execute.after")` for deterministic enforcement
8
8
  * - `ctx.session.hook("context"/"compaction"/"prompt")` for system guidance,
9
- * compaction context, and recovery checkpoints
10
- * - `ctx.permission.hook("evaluate")` for permission journaling
9
+ * read-only tool pruning, compaction context, and recovery checkpoints
10
+ * - `ctx.command.transform` for the server-side `/guard-status` command
11
+ * - `ctx.permission.hook("evaluate")` for the pre-execution deny seat
11
12
  * - `ctx.shell.hook("create.before")` for env scrubbing
13
+ * - `ctx.mcp.list()` plus `mcp.tools.changed` for the guarded-server inventory
12
14
  * - `ctx.event.subscribe()` for tool outcomes, session lifecycle, and claims-vs-evidence
13
15
  *
14
16
  * The V1 SDK client surface (`TodoSdkClient`) is satisfied by an adapter so
@@ -20,7 +22,7 @@ import { Message } from "@opencode/ai";
20
22
  import { z } from "zod";
21
23
  import { join, resolve } from "node:path";
22
24
  import type { TodoSdkClient } from "./types.ts";
23
- import { scanSessionTodos, toolPartName } from "./v2-todo.ts";
25
+ import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos, toolPartName } from "./v2-todo.ts";
24
26
  import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
25
27
  import {
26
28
  setWorkspaceRoot,
@@ -31,6 +33,7 @@ import {
31
33
  getSessionMutationCount,
32
34
  sessionVerifyResults,
33
35
  sessionReviews,
36
+ clearTodoSessionState,
34
37
  isRecoveryCheckpointsEnabled,
35
38
  isLearningEnabled,
36
39
  isProjectMemoryEnabled,
@@ -65,6 +68,13 @@ import { loadedPluginVersion } from "./version.ts";
65
68
  import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
66
69
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
67
70
  import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
71
+ import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
72
+ import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
73
+ import { evaluatePermissionSeat } from "./permission-seat.ts";
74
+ import { pruneToolsForReadOnlyRole } from "./read-only-tools.ts";
75
+ import { registerGuardStatusCommand } from "./guard-command.ts";
76
+ import { createHostWorktreePort } from "./host-worktree.ts";
77
+ import { listHostMcpServerNames, recordHostMcpServers } from "./mcp-inventory.ts";
68
78
 
69
79
  type V2Context = Plugin.Context;
70
80
 
@@ -88,7 +98,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
88
98
  },
89
99
  },
90
100
  session: {
91
- todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id) }),
101
+ todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id, recordedTodoCapability()) }),
92
102
  get: async ({ path }) => {
93
103
  const session = await ctx.session.get({ sessionID: path.id });
94
104
  return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
@@ -110,7 +120,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
110
120
  // ── Builtin tool description enrichment ───────────────────────────────────────
111
121
 
112
122
  const TODOWRITE_SUFFIX =
113
- "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
123
+ "\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
114
124
 
115
125
  const EDIT_SUFFIX =
116
126
  "\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
@@ -122,12 +132,14 @@ const SUBAGENT_SUFFIX =
122
132
  // host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
123
133
  // candidates are harmless no-ops.
124
134
  //
125
- // V2.0.10 builtin surface (docs + binary): read, glob, grep, edit, write,
126
- // patch, shell, webfetch, websearch, question, skill, subagent, execute.
127
- // - There is no builtin `todowrite` (the V1 name). Todowrite tool parts seen
128
- // in V2 sessions come from ACP agents that supply their own tools; those
129
- // are not in the server registry, so this enrichment cannot reach them.
130
- // The candidate is kept for hosts that do register a todowrite tool.
135
+ // Version boundary for the builtin surface:
136
+ // - V2.0.x (docs + binary, up to and including 2.0.15): read, glob, grep,
137
+ // edit, write, patch, shell, webfetch, websearch, question, skill, subagent,
138
+ // execute. There is no builtin `todowrite`; todowrite parts in V2 sessions
139
+ // come from ACP agents that supply their own tools, which are not in the
140
+ // server registry, so this enrichment cannot reach them.
141
+ // - V2.1.x adds a builtin `todowrite` behind the `experimental.todo` config
142
+ // key, so the candidate below reaches a real registry entry there.
131
143
  // - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
132
144
  // candidates for the same reason.
133
145
  const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
@@ -141,7 +153,10 @@ const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker:
141
153
  export const WorkflowGuardV2 = async (ctx: V2Context) => {
142
154
  const hostRoot = ctx.location?.directory || process.cwd();
143
155
  const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
144
- const projectDirectory = (ctx.location as { project?: { directory?: string; canonical?: string } })?.project?.directory;
156
+ // `location.project` is required by the host's `Location.Info` schema
157
+ // (`@opencode/schema/dist/location.d.ts`), so the optional read is only a
158
+ // runtime guard for harnesses and stubs that construct a partial context.
159
+ const projectDirectory = ctx.location.project?.directory;
145
160
  let effectiveRoot = hostIsFilesystemRoot && projectDirectory && resolve(projectDirectory) !== resolve(projectDirectory, "..")
146
161
  ? projectDirectory
147
162
  : hostRoot;
@@ -154,6 +169,20 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
154
169
  }
155
170
 
156
171
  const client = createV2SdkClient(ctx);
172
+ // Load-once guard: OpenCode 2 merges plugin arrays, so the same package
173
+ // named in global and project config loads this module twice. A duplicate
174
+ // stays inert (no hooks, no tools, no second workspace latch) and leaves a
175
+ // warn-level record; see lib/plugin-instance.ts.
176
+ const instance = registerPluginInstance({ workspaceRoot: effectiveRoot });
177
+ if (instance.duplicate) {
178
+ try {
179
+ console.warn(
180
+ `[workflow-guard] warn: duplicate plugin load for ${instance.workspaceRoot} (module ${instance.modulePath}); ` +
181
+ "this instance stays inert. Configure the guard in one location only.",
182
+ );
183
+ } catch {}
184
+ return () => releasePluginInstance(instance.key);
185
+ }
157
186
  setWorkspaceRoot(effectiveRoot);
158
187
  setSdkClient(client);
159
188
  reloadProjectConfig(effectiveRoot);
@@ -185,7 +214,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
185
214
  const toolOutcomes = new ToolOutcomeTracker();
186
215
 
187
216
  // ── Custom tools + builtin description enrichment ──
188
- const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client });
217
+ const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client, hostWorktree: createHostWorktreePort(ctx), todoCapability: () => recordedTodoCapability(), hostVersion: () => ctx.app?.version });
189
218
  await ctx.tool.transform((editor) => {
190
219
  for (const [name, definition] of Object.entries(customTools)) {
191
220
  editor.add({
@@ -222,8 +251,32 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
222
251
  }
223
252
  });
224
253
 
254
+ // ── /guard-status command ──
255
+ // Reachable from every V2 client without the optional TUI companion. The
256
+ // report is produced by the same guard_status implementation the tool uses,
257
+ // then delivered as a synthetic message so it costs no model turn.
258
+ await registerGuardStatusCommand(ctx, {
259
+ runStatus: async (sessionID) => {
260
+ const boundWorktree = getSessionBoundWorkspace(sessionID);
261
+ const result = await (customTools.guard_status as unknown as {
262
+ execute: (input: unknown, context: unknown) => Promise<unknown>;
263
+ }).execute({}, {
264
+ sessionID,
265
+ ...(boundWorktree ? { worktree: boundWorktree } : {}),
266
+ directory: boundWorktree ?? effectiveRoot,
267
+ });
268
+ return typeof result === "string" ? result : JSON.stringify(result);
269
+ },
270
+ effectiveRoot,
271
+ client,
272
+ });
273
+
225
274
  // ── Deterministic enforcement ──
226
275
  await ctx.tool.hook("execute.before", (event) => {
276
+ // One policy decision per tool call even if a second instance with a
277
+ // different module path (global cache copy plus a source checkout)
278
+ // registered too. The owner evaluates; the other defers.
279
+ if (!claimToolCall(instance.key, event.sessionID, event.id, event.tool)) return;
227
280
  const toolWorktree = getSessionBoundWorkspace(event.sessionID) || effectiveRoot;
228
281
  return runWithRuntimeState(toolWorktree, client, async () => {
229
282
  const args = event.input;
@@ -234,6 +287,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
234
287
  agent: event.agent,
235
288
  });
236
289
  if (reason !== undefined) {
290
+ releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
237
291
  await showBlockToast(reason);
238
292
  const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
239
293
  const circuitBreakerSuffix = failureCount >= 2
@@ -245,7 +299,12 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
245
299
  const record = asRecord(args);
246
300
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
247
301
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
248
- registerSessionWorkspaceFromToolCall(event.sessionID, { workdir, filePath, toolWorktree });
302
+ await registerSessionWorkspaceFromToolCall(event.sessionID, {
303
+ workdir,
304
+ filePath,
305
+ toolWorktree,
306
+ mutating: EDIT_TOOL_NAMES.has(event.tool) || SHELL_TOOL_NAMES.has(event.tool),
307
+ });
249
308
  if (event.tool === "read") {
250
309
  const target = editTargets(args, toolWorktree)[0];
251
310
  if (target) {
@@ -286,6 +345,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
286
345
  });
287
346
 
288
347
  await ctx.tool.hook("execute.after", async (event) => {
348
+ releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
289
349
  const startedAt = toolLifecycle.finish(event.sessionID, event.id);
290
350
  const outcome = toolOutcomes.recordFallbackCompleted(event.sessionID, event.id, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
291
351
  if (outcome) {
@@ -339,6 +399,25 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
339
399
  // ── Session request hooks ──
340
400
  await ctx.session.hook("context", (event) => {
341
401
  try {
402
+ // A read-only role must not be able to *choose* a mutating tool. The
403
+ // execute.before seat still denies it, but pruning the per-request
404
+ // tool snapshot removes the call before the model plans it. Pruning
405
+ // is a narrowing only: tools supplied outside the host snapshot
406
+ // (ACP agents, nested Code Mode calls) still meet the backstop.
407
+ if (event.agent && isReadOnlyRole(event.agent)) {
408
+ const removed = pruneToolsForReadOnlyRole(event.tools as Record<string, unknown> | undefined);
409
+ if (removed.length > 0) {
410
+ audit({
411
+ ts: new Date().toISOString(),
412
+ sessionID: event.sessionID,
413
+ tool: "session.context",
414
+ decision: "block",
415
+ phase: "decision",
416
+ reason: "read_only_role_tools_pruned",
417
+ input: { agent: event.agent, removed },
418
+ });
419
+ }
420
+ }
342
421
  const existing = Array.isArray(event.system)
343
422
  ? event.system.map((part) => (typeof part === "string" ? part : (part as { text?: string }).text ?? "")).join("\n")
344
423
  : "";
@@ -388,7 +467,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
388
467
  priorityContextBlocks.push(
389
468
  `## Active Tasks${attribution}\n` +
390
469
  lines.join("\n") +
391
- "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
470
+ "\nComplete tasks efficiently and address remaining ones. Mark a task completed only after its work is actually done: the guard blocks a completion with no recorded file mutation in that task's window. Append `[read-only]` to a genuinely read-only task (review, investigation), and use `cancelled` for work that was dropped.",
392
471
  );
393
472
  }
394
473
 
@@ -451,22 +530,57 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
451
530
  } catch {}
452
531
  });
453
532
 
454
- // ── Permission journaling ──
455
- await ctx.permission.hook("evaluate", (event) => {
533
+ // ── Permission seat: deny before the tool starts ──
534
+ // `evaluate` sees normalized `action`/`resources` and runs before the tool,
535
+ // so it is the earliest hard stop for shell commands, file mutations, and
536
+ // guarded MCP calls. It cannot see raw tool input, so the raw-argument work
537
+ // (todo lifecycle, stale-write, post-edit snapshots) stays in
538
+ // `execute.before`. See lib/permission-seat.ts for the split and the limits.
539
+ //
540
+ // Flow limit, documented by the host: an explicit configured `deny` is
541
+ // resolved before this hook runs, so the hook never sees it. A guard audit
542
+ // therefore cannot explain a configured deny; only host config can. The
543
+ // hook runs for allow/ask, and a denial here is always a tightening.
544
+ await ctx.permission.hook("evaluate", async (event) => {
545
+ const configuredEffect = event.effect;
456
546
  try {
547
+ const seat = await evaluatePermissionSeat(
548
+ {
549
+ sessionID: event.sessionID,
550
+ agent: event.agent,
551
+ action: event.action,
552
+ resources: event.resources,
553
+ effect: configuredEffect,
554
+ },
555
+ { workspaceRoot: effectiveRoot, client },
556
+ );
557
+ if (seat.decision) {
558
+ event.effect = "deny";
559
+ event.message = seat.decision.message;
560
+ }
457
561
  audit({
458
562
  ts: new Date().toISOString(),
459
563
  sessionID: event.sessionID,
460
564
  tool: "permission.ask",
461
- decision: event.effect === "deny" ? "block" : "allow",
565
+ decision: seat.effect === "deny" ? "block" : "allow",
566
+ phase: "decision",
567
+ reason: seat.decision?.code,
568
+ policyDecision: seat.decision,
462
569
  input: {
463
570
  action: event.action,
464
571
  resources: event.resources,
465
572
  agent: event.agent,
466
- effect: event.effect,
573
+ configuredEffect,
574
+ mappedTool: seat.mappedTool,
467
575
  },
468
576
  });
469
- } catch {}
577
+ } catch (error) {
578
+ // Fail closed: a seat that cannot evaluate must not become a pass.
579
+ try {
580
+ event.effect = "deny";
581
+ event.message = `[workflow-guard permission.evaluate] Blocked: the guard could not evaluate this permission request (${(error as Error)?.message ?? "unknown error"}).`;
582
+ } catch {}
583
+ }
470
584
  });
471
585
 
472
586
  // ── Shell env scrubbing ──
@@ -488,6 +602,47 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
488
602
  } catch {}
489
603
  });
490
604
 
605
+ // ── MCP inventory ──
606
+ // Seed the guarded token set from host-declared servers, then refresh it
607
+ // whenever the host reports that a server's tools changed. Widen-only:
608
+ // removal adds nothing, so a renamed or dropped server can never unguard a
609
+ // tool that still names a live system. The static defaults in
610
+ // policies/mcp.ts stay the floor.
611
+ const seedMcpInventory = async () => {
612
+ try {
613
+ const added = recordHostMcpServers(await listHostMcpServerNames(ctx));
614
+ if (added.length > 0) {
615
+ audit({
616
+ ts: new Date().toISOString(),
617
+ tool: "mcp.inventory",
618
+ decision: "allow",
619
+ phase: "event",
620
+ reason: "host_mcp_servers_widened",
621
+ input: { added },
622
+ });
623
+ }
624
+ } catch {}
625
+ };
626
+ await seedMcpInventory();
627
+
628
+ // ── Todo capability probe ──
629
+ // Ask the host's registry whether `todowrite` exists, instead of inferring
630
+ // it from history. On 2.0.x the answer is no, so the mutation gate fails
631
+ // open; on 2.1.x with `experimental.todo: true` the answer is yes, so a
632
+ // session that never planned is gated. Probe once: the registry is fixed
633
+ // for the life of the process.
634
+ try {
635
+ const capability = await detectTodoCapability(ctx);
636
+ recordTodoCapability(capability);
637
+ audit({
638
+ ts: new Date().toISOString(),
639
+ tool: "todo.capability",
640
+ decision: "allow",
641
+ phase: "event",
642
+ reason: `todo_capability_${capability}`,
643
+ });
644
+ } catch {}
645
+
491
646
  // ── Event stream ──
492
647
  const controller = new AbortController();
493
648
  void (async () => {
@@ -500,6 +655,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
500
655
  client,
501
656
  toolLifecycle,
502
657
  toolOutcomes,
658
+ refreshMcpInventory: seedMcpInventory,
503
659
  });
504
660
  } catch {}
505
661
  }
@@ -508,7 +664,10 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
508
664
  }
509
665
  })();
510
666
 
511
- return () => controller.abort();
667
+ return () => {
668
+ controller.abort();
669
+ releasePluginInstance(instance.key);
670
+ };
512
671
  };
513
672
 
514
673
  // ── Event handling ────────────────────────────────────────────────────────────
@@ -518,6 +677,8 @@ interface V2EventState {
518
677
  client: unknown;
519
678
  toolLifecycle: ToolInvocationLifecycle;
520
679
  toolOutcomes: ToolOutcomeTracker;
680
+ /** Re-read the host MCP roster after `mcp.tools.changed`. */
681
+ refreshMcpInventory?: () => Promise<void>;
521
682
  }
522
683
 
523
684
  async function handleV2Event(
@@ -526,6 +687,14 @@ async function handleV2Event(
526
687
  ): Promise<void> {
527
688
  const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
528
689
 
690
+ if (event.type === "mcp.tools.changed") {
691
+ // The host's server roster or one server's tool list changed. Re-read it
692
+ // so a newly connected live-system server is guarded before its tools
693
+ // are callable. Widen-only: this can add tokens, never remove them.
694
+ await state.refreshMcpInventory?.();
695
+ return;
696
+ }
697
+
529
698
  if (event.type === "session.message.content.updated") {
530
699
  const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
531
700
  const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
@@ -625,6 +794,7 @@ async function handleV2Event(
625
794
  clearReadFingerprints(sessionID);
626
795
  toolLifecycle.clearSession(sessionID);
627
796
  toolOutcomes.clearSession(sessionID);
797
+ clearTodoSessionState(sessionID);
628
798
  toolLifecycle.takeRecoveryRun(sessionID);
629
799
  await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
630
800
  }
@@ -646,8 +816,24 @@ async function handleV2Event(
646
816
  });
647
817
  return;
648
818
  }
819
+ // `command.executed` exists in the V2 event stream (`data: { name, sessionID,
820
+ // arguments, messageID }`) and carries what the V1 audit wanted: the slash
821
+ // command name, its arguments, and the session. It is journaled here rather
822
+ // than dropped, so a command that mutates workspace state stays attributable.
823
+ if (event.type === "command.executed") {
824
+ const data = event.data as { sessionID?: unknown; name?: unknown; arguments?: unknown } | undefined;
825
+ audit({
826
+ ts: new Date().toISOString(),
827
+ sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
828
+ tool: "command.executed",
829
+ decision: "allow",
830
+ phase: "event",
831
+ reason: typeof data?.name === "string" ? data.name : undefined,
832
+ input: summarizeInput({ name: data?.name, arguments: data?.arguments }),
833
+ });
834
+ return;
835
+ }
649
836
  // Note on V1 events without a V2 counterpart:
650
- // - `command.executed`: no V2 event stream equivalent (audit-only in V1; dropped).
651
837
  // - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
652
838
  // - `session.created`: exists in the V2 union but needed no V1 behavior beyond
653
839
  // the audit above; session state resets happen on `session.deleted`.