opencode-workflow-guard 1.15.3 → 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.
@@ -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`.
@@ -2,12 +2,27 @@
2
2
  * V2 todo reconstruction shared by the plugin entrypoint and its V1 SDK
3
3
  * adapter.
4
4
  *
5
- * OpenCode V2 exposes no todo endpoint, and its builtin tool registry has no
6
- * `todowrite` (the V1 name). Todo state therefore only exists when an ACP agent
7
- * supplies its own `todowrite` tool. This module reads that state from session
8
- * message history and distinguishes "definitively empty" from "cannot
9
- * determine", which the policy layer relies on to decide whether to enforce the
10
- * active-todo gate.
5
+ * Version boundary, because the two generations need different treatment:
6
+ *
7
+ * - **OpenCode 2.0.x** exposes no todo endpoint and its builtin tool registry
8
+ * has no `todowrite` (the V1 name). Todo state only exists when an ACP agent
9
+ * supplies its own `todowrite` tool, so the list is reconstructed from
10
+ * session message history.
11
+ * - **OpenCode 2.1.x** reintroduces a builtin `todowrite` behind the
12
+ * `experimental.todo` config key. When that tool is registered, a session
13
+ * with no todo part has never planned, which must gate mutations rather than
14
+ * fail open.
15
+ *
16
+ * Capability is therefore probed, not inferred from history: `detectTodoCapability`
17
+ * asks the host's tool registry (`ctx.tool.list()`) whether `todowrite` is
18
+ * registered. History tells you whether a session planned; the registry tells
19
+ * you whether planning was possible. Only the second question decides whether
20
+ * an empty list means "not yet planned" or "cannot plan".
21
+ *
22
+ * This module distinguishes "definitively empty" from "cannot determine", which
23
+ * the policy layer relies on to decide whether to enforce the active-todo gate.
24
+ * Todos are never evidence: a `todowrite` call replaces the whole list, so a
25
+ * model can mark everything completed without doing the work.
11
26
  */
12
27
 
13
28
  import type { TodoItem } from "./types.ts";
@@ -19,6 +34,50 @@ export interface TodoScanContext {
19
34
  };
20
35
  }
21
36
 
37
+ /** Minimal tool-registry surface needed to probe for `todowrite`. */
38
+ export interface TodoCapabilityContext {
39
+ tool?: {
40
+ list?: () => Promise<unknown>;
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Whether this host can plan at all.
46
+ *
47
+ * - `available`: the registry reports `todowrite`, so an empty list means the
48
+ * session never planned and the gate must enforce.
49
+ * - `unavailable`: the registry answered and has no `todowrite`, so the gate
50
+ * must fail open or builtin-only sessions deadlock.
51
+ * - `unknown`: the registry could not be read. Treat as unavailable for
52
+ * enforcement, but say so out loud (the caller logs once per session).
53
+ */
54
+ export type TodoCapability = "available" | "unavailable" | "unknown";
55
+
56
+ /**
57
+ * Probe the host's tool registry for `todowrite`.
58
+ *
59
+ * Tool ids on the host are effective names, so a namespaced or prefixed
60
+ * registration (`todowrite`, `todo.write`) still counts. A registry that throws
61
+ * or is absent reports `unknown` rather than `unavailable`, because "we could
62
+ * not ask" must never be reported as "the host said no".
63
+ */
64
+ export async function detectTodoCapability(ctx: TodoCapabilityContext | undefined): Promise<TodoCapability> {
65
+ const list = ctx?.tool?.list;
66
+ if (typeof list !== "function") return "unknown";
67
+ try {
68
+ const entries = await list.call(ctx!.tool);
69
+ if (!Array.isArray(entries)) return "unknown";
70
+ return entries.some((entry) => {
71
+ const name = (entry as { name?: unknown; id?: unknown } | undefined)?.name ?? (entry as { id?: unknown } | undefined)?.id;
72
+ return typeof name === "string" && /^todowrite$/i.test(name);
73
+ })
74
+ ? "available"
75
+ : "unavailable";
76
+ } catch {
77
+ return "unknown";
78
+ }
79
+ }
80
+
22
81
  /**
23
82
  * V2 tool parts carry the tool name in `tool` (the V1 field was `name`); ACP
24
83
  * agents may still report either shape, so accept both.
@@ -29,6 +88,66 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
29
88
  return undefined;
30
89
  }
31
90
 
91
+ /**
92
+ * Proven capability per plugin instance, for reporting and for the fail-open
93
+ * warning. Held on `globalThis` via `Symbol.for` because OpenCode can load the
94
+ * same package from two module paths (a cache copy plus a source checkout), and
95
+ * the capability answer must agree between them.
96
+ */
97
+ const TODO_CAPABILITY = Symbol.for("opencode-workflow-guard.todo-capability.v1");
98
+ const capabilityRegistry = globalThis as unknown as Record<symbol, TodoCapability | undefined>;
99
+
100
+ /** Last probed capability. `unknown` before setup, which fails open. */
101
+ export function recordedTodoCapability(): TodoCapability {
102
+ return capabilityRegistry[TODO_CAPABILITY] ?? "unknown";
103
+ }
104
+
105
+ /**
106
+ * Record the probed capability. Returns the previous value so the caller can
107
+ * tell whether this is the first probe for the process.
108
+ */
109
+ export function recordTodoCapability(capability: TodoCapability): TodoCapability {
110
+ const previous = recordedTodoCapability();
111
+ capabilityRegistry[TODO_CAPABILITY] = capability;
112
+ return previous;
113
+ }
114
+
115
+ export function resetTodoCapabilityForTesting(): void {
116
+ delete capabilityRegistry[TODO_CAPABILITY];
117
+ }
118
+
119
+ /**
120
+ * Sessions already warned about the fail-open path. Bounded: the warning is
121
+ * once per session, and a process that outlives thousands of sessions must not
122
+ * leak a set for every one of them.
123
+ */
124
+ const WARNED_SESSIONS = Symbol.for("opencode-workflow-guard.todo-warned.v2");
125
+ const warnedRegistry = globalThis as unknown as Record<symbol, Set<string> | undefined>;
126
+ const warnedSessions: Set<string> =
127
+ warnedRegistry[WARNED_SESSIONS] ?? (warnedRegistry[WARNED_SESSIONS] = new Set());
128
+ const MAX_WARNED_SESSIONS = 512;
129
+
130
+ /**
131
+ * Report the active-todo gate failing open, once per session.
132
+ *
133
+ * Silent fail-open is how a rails policy becomes decorative: the gate stops
134
+ * enforcing and nothing says so. Returns true when this call was the first for
135
+ * the session, so the caller can journal it exactly once.
136
+ */
137
+ export function reportTodoGateFailOpen(sessionID: string | undefined, capability: TodoCapability): boolean {
138
+ const key = sessionID ?? "<no-session>";
139
+ if (warnedSessions.has(key)) return false;
140
+ if (warnedSessions.size >= MAX_WARNED_SESSIONS) warnedSessions.clear();
141
+ warnedSessions.add(key);
142
+ try {
143
+ console.warn(
144
+ `[workflow-guard] warn: active-todo gate is not enforcing for session ${key}: ` +
145
+ `todoCapability=${capability}. Mutations are allowed without an active todo.`,
146
+ );
147
+ } catch {}
148
+ return true;
149
+ }
150
+
32
151
  /**
33
152
  * Reconstruct one session's current todo list from its message history. The
34
153
  * newest applied `todowrite` tool call's input is the current state.
@@ -37,7 +156,13 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
37
156
  * - `TodoItem[]` when a `todowrite` part defines the list, including `[]` when
38
157
  * the newest applied call emptied it;
39
158
  * - `undefined` when todo state is unknown: the history cannot be read, or the
40
- * session contains no `todowrite` part at all (no todo capability).
159
+ * session contains no `todowrite` part and the capability is absent.
160
+ *
161
+ * `capability` decides what an empty history means. When the host reports
162
+ * `available`, a session with no `todowrite` part has never planned, so the
163
+ * result is `[]` and the mutation gate enforces. When it is `unavailable` or
164
+ * `unknown`, an empty history stays `undefined` so builtin-only sessions fail
165
+ * open instead of deadlocking on a tool they cannot call.
41
166
  *
42
167
  * Callers must treat `undefined` as "cannot determine" (fail open) and `[]` as
43
168
  * "definitively no active todos" (enforce). Collapsing the two is what
@@ -46,6 +171,7 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
46
171
  export async function scanSessionTodos(
47
172
  ctx: TodoScanContext,
48
173
  sessionID: string,
174
+ capability: TodoCapability = "unknown",
49
175
  ): Promise<TodoItem[] | undefined> {
50
176
  try {
51
177
  const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
@@ -66,6 +192,10 @@ export async function scanSessionTodos(
66
192
  if (Array.isArray(todos)) return todos as TodoItem[];
67
193
  }
68
194
  }
195
+ // No applied `todowrite` part. With a registered tool that means the
196
+ // session never planned, which must gate. Without one, the session
197
+ // cannot plan at all, so the gate has nothing to enforce.
198
+ if (capability === "available") return [];
69
199
  // A `todowrite` part exists but none is applied yet (e.g. still
70
200
  // streaming): the list is in flux, report it as empty rather than
71
201
  // unknown so the gate stays closed until the write settles.
@@ -19,3 +19,25 @@ export function loadedPluginVersion(moduleUrl: string = import.meta.url): string
19
19
  return undefined;
20
20
  }
21
21
  }
22
+
23
+ /**
24
+ * The OpenCode release this build is qualified against, read from the pinned
25
+ * `@opencode/plugin` dependency in the same manifest.
26
+ *
27
+ * Pinning the host API stops an unreviewed major from floating in, but a
28
+ * published guard can still be installed into a host that is newer or older
29
+ * than the one it was verified on. Reporting the qualified pair next to the
30
+ * loaded host version makes that mismatch visible before it becomes a silent
31
+ * no-op, which is the failure mode the migration guide names.
32
+ */
33
+ export function qualifiedHostVersion(moduleUrl: string = import.meta.url): string | undefined {
34
+ try {
35
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", moduleUrl)), "utf8")) as {
36
+ dependencies?: Record<string, unknown>;
37
+ };
38
+ const pinned = manifest.dependencies?.["@opencode/plugin"];
39
+ return typeof pinned === "string" && /^\d+\.\d+\.\d+/.test(pinned) ? pinned : undefined;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }