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.
- package/README.md +22 -5
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/installation.md +80 -5
- package/docs/managed-deployment.md +1 -1
- package/docs/policies.md +20 -5
- package/docs/policy-posture.md +171 -0
- package/docs/testing.md +4 -1
- package/package.json +16 -9
- package/src/lib/call-claims.ts +71 -0
- package/src/lib/config-writer.d.mts +13 -0
- package/src/lib/config-writer.mjs +89 -0
- package/src/lib/custom-tools.ts +69 -8
- package/src/lib/guard-command.ts +97 -0
- package/src/lib/guard-dispatcher.ts +56 -0
- package/src/lib/host-worktree.ts +109 -0
- package/src/lib/mcp-inventory.ts +86 -0
- package/src/lib/permission-seat.ts +119 -0
- package/src/lib/plugin-instance.ts +124 -0
- package/src/lib/policy-posture.ts +227 -0
- package/src/lib/project-memory.ts +16 -0
- package/src/lib/read-only-tools.ts +61 -0
- package/src/lib/state.ts +167 -36
- package/src/lib/v2-plugin.ts +208 -22
- package/src/lib/v2-todo.ts +137 -7
- package/src/lib/version.ts +22 -0
- package/src/lib/worktree.ts +61 -18
- package/src/policies/mcp.ts +47 -2
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +221 -6
- package/src/workflow-guard.ts +17 -5
- package/test/test-e2e.mts +91 -6
- package/test/test.mts +861 -4
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
455
|
-
|
|
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:
|
|
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
|
-
|
|
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 () =>
|
|
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`.
|
package/src/lib/v2-todo.ts
CHANGED
|
@@ -2,12 +2,27 @@
|
|
|
2
2
|
* V2 todo reconstruction shared by the plugin entrypoint and its V1 SDK
|
|
3
3
|
* adapter.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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.
|
package/src/lib/version.ts
CHANGED
|
@@ -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
|
+
}
|