opencode-workflow-guard 1.15.5 → 1.17.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 -21
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/accountability-evolution-plan.md +9 -9
- package/docs/installation.md +118 -23
- package/docs/managed-deployment.md +4 -4
- package/docs/policies.md +100 -64
- package/docs/policy-posture.md +171 -0
- package/docs/testing.md +6 -3
- package/docs/troubleshooting.md +20 -19
- 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 +70 -8
- package/src/lib/guard-command.ts +97 -0
- package/src/lib/guard-dispatcher.ts +70 -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/read-only-tools.ts +61 -0
- package/src/lib/state.ts +212 -36
- package/src/lib/types.ts +15 -0
- package/src/lib/v2-plugin.ts +262 -23
- 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/live-docs.ts +157 -0
- package/src/policies/mcp.ts +47 -2
- package/src/policies/stale-write.ts +10 -0
- package/src/policies/sweeping-claims.ts +71 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +221 -6
- package/src/workflow-guard.ts +97 -6
- package/test/test-e2e.mts +91 -6
- package/test/test.mts +1140 -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,10 +33,14 @@ import {
|
|
|
31
33
|
getSessionMutationCount,
|
|
32
34
|
sessionVerifyResults,
|
|
33
35
|
sessionReviews,
|
|
36
|
+
clearTodoSessionState,
|
|
34
37
|
isRecoveryCheckpointsEnabled,
|
|
35
38
|
isLearningEnabled,
|
|
36
39
|
isProjectMemoryEnabled,
|
|
37
40
|
getProjectConfig,
|
|
41
|
+
isLiveDocsRequired,
|
|
42
|
+
getLiveDocFailureThreshold,
|
|
43
|
+
getResearchToolPatterns,
|
|
38
44
|
} from "./state.ts";
|
|
39
45
|
import {
|
|
40
46
|
openProjectMemory,
|
|
@@ -48,12 +54,14 @@ import {
|
|
|
48
54
|
} from "./project-memory.ts";
|
|
49
55
|
import { secretIn } from "../policies/secrets.ts";
|
|
50
56
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
|
|
51
|
-
import { isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
|
|
57
|
+
import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
|
|
52
58
|
import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
|
|
53
59
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
|
|
54
60
|
import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
|
|
55
61
|
import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
|
|
56
62
|
import { checkCompletionClaims } from "../policies/completion.ts";
|
|
63
|
+
import { checkSweepingClaims } from "../policies/sweeping-claims.ts";
|
|
64
|
+
import { clearLiveDocsFloor, isResearchTool, liveDocsGatingReason, recordLiveDocsFailure, recordResearchLookup } from "../policies/live-docs.ts";
|
|
57
65
|
import { releaseFileClaims } from "../policies/file-claims.ts";
|
|
58
66
|
import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
59
67
|
import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "../policies/post-edit-validation.ts";
|
|
@@ -65,6 +73,13 @@ import { loadedPluginVersion } from "./version.ts";
|
|
|
65
73
|
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
|
|
66
74
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
|
|
67
75
|
import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
|
|
76
|
+
import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
|
|
77
|
+
import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
|
|
78
|
+
import { evaluatePermissionSeat } from "./permission-seat.ts";
|
|
79
|
+
import { pruneToolsForReadOnlyRole } from "./read-only-tools.ts";
|
|
80
|
+
import { registerGuardStatusCommand } from "./guard-command.ts";
|
|
81
|
+
import { createHostWorktreePort } from "./host-worktree.ts";
|
|
82
|
+
import { listHostMcpServerNames, recordHostMcpServers } from "./mcp-inventory.ts";
|
|
68
83
|
|
|
69
84
|
type V2Context = Plugin.Context;
|
|
70
85
|
|
|
@@ -88,7 +103,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
|
88
103
|
},
|
|
89
104
|
},
|
|
90
105
|
session: {
|
|
91
|
-
todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id) }),
|
|
106
|
+
todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id, recordedTodoCapability()) }),
|
|
92
107
|
get: async ({ path }) => {
|
|
93
108
|
const session = await ctx.session.get({ sessionID: path.id });
|
|
94
109
|
return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
|
|
@@ -110,7 +125,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
|
110
125
|
// ── Builtin tool description enrichment ───────────────────────────────────────
|
|
111
126
|
|
|
112
127
|
const TODOWRITE_SUFFIX =
|
|
113
|
-
"\n\nWorkflow Guard lifecycle:
|
|
128
|
+
"\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
129
|
|
|
115
130
|
const EDIT_SUFFIX =
|
|
116
131
|
"\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 +137,14 @@ const SUBAGENT_SUFFIX =
|
|
|
122
137
|
// host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
|
|
123
138
|
// candidates are harmless no-ops.
|
|
124
139
|
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
140
|
+
// Version boundary for the builtin surface:
|
|
141
|
+
// - V2.0.x (docs + binary, up to and including 2.0.15): read, glob, grep,
|
|
142
|
+
// edit, write, patch, shell, webfetch, websearch, question, skill, subagent,
|
|
143
|
+
// execute. There is no builtin `todowrite`; todowrite parts in V2 sessions
|
|
144
|
+
// come from ACP agents that supply their own tools, which are not in the
|
|
145
|
+
// server registry, so this enrichment cannot reach them.
|
|
146
|
+
// - V2.1.x adds a builtin `todowrite` behind the `experimental.todo` config
|
|
147
|
+
// key, so the candidate below reaches a real registry entry there.
|
|
131
148
|
// - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
|
|
132
149
|
// candidates for the same reason.
|
|
133
150
|
const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
|
|
@@ -141,7 +158,10 @@ const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker:
|
|
|
141
158
|
export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
142
159
|
const hostRoot = ctx.location?.directory || process.cwd();
|
|
143
160
|
const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
|
|
144
|
-
|
|
161
|
+
// `location.project` is required by the host's `Location.Info` schema
|
|
162
|
+
// (`@opencode/schema/dist/location.d.ts`), so the optional read is only a
|
|
163
|
+
// runtime guard for harnesses and stubs that construct a partial context.
|
|
164
|
+
const projectDirectory = ctx.location.project?.directory;
|
|
145
165
|
let effectiveRoot = hostIsFilesystemRoot && projectDirectory && resolve(projectDirectory) !== resolve(projectDirectory, "..")
|
|
146
166
|
? projectDirectory
|
|
147
167
|
: hostRoot;
|
|
@@ -154,6 +174,20 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
const client = createV2SdkClient(ctx);
|
|
177
|
+
// Load-once guard: OpenCode 2 merges plugin arrays, so the same package
|
|
178
|
+
// named in global and project config loads this module twice. A duplicate
|
|
179
|
+
// stays inert (no hooks, no tools, no second workspace latch) and leaves a
|
|
180
|
+
// warn-level record; see lib/plugin-instance.ts.
|
|
181
|
+
const instance = registerPluginInstance({ workspaceRoot: effectiveRoot });
|
|
182
|
+
if (instance.duplicate) {
|
|
183
|
+
try {
|
|
184
|
+
console.warn(
|
|
185
|
+
`[workflow-guard] warn: duplicate plugin load for ${instance.workspaceRoot} (module ${instance.modulePath}); ` +
|
|
186
|
+
"this instance stays inert. Configure the guard in one location only.",
|
|
187
|
+
);
|
|
188
|
+
} catch {}
|
|
189
|
+
return () => releasePluginInstance(instance.key);
|
|
190
|
+
}
|
|
157
191
|
setWorkspaceRoot(effectiveRoot);
|
|
158
192
|
setSdkClient(client);
|
|
159
193
|
reloadProjectConfig(effectiveRoot);
|
|
@@ -185,7 +219,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
185
219
|
const toolOutcomes = new ToolOutcomeTracker();
|
|
186
220
|
|
|
187
221
|
// ── Custom tools + builtin description enrichment ──
|
|
188
|
-
const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client });
|
|
222
|
+
const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client, hostWorktree: createHostWorktreePort(ctx), todoCapability: () => recordedTodoCapability(), hostVersion: () => ctx.app?.version });
|
|
189
223
|
await ctx.tool.transform((editor) => {
|
|
190
224
|
for (const [name, definition] of Object.entries(customTools)) {
|
|
191
225
|
editor.add({
|
|
@@ -222,8 +256,32 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
222
256
|
}
|
|
223
257
|
});
|
|
224
258
|
|
|
259
|
+
// ── /guard-status command ──
|
|
260
|
+
// Reachable from every V2 client without the optional TUI companion. The
|
|
261
|
+
// report is produced by the same guard_status implementation the tool uses,
|
|
262
|
+
// then delivered as a synthetic message so it costs no model turn.
|
|
263
|
+
await registerGuardStatusCommand(ctx, {
|
|
264
|
+
runStatus: async (sessionID) => {
|
|
265
|
+
const boundWorktree = getSessionBoundWorkspace(sessionID);
|
|
266
|
+
const result = await (customTools.guard_status as unknown as {
|
|
267
|
+
execute: (input: unknown, context: unknown) => Promise<unknown>;
|
|
268
|
+
}).execute({}, {
|
|
269
|
+
sessionID,
|
|
270
|
+
...(boundWorktree ? { worktree: boundWorktree } : {}),
|
|
271
|
+
directory: boundWorktree ?? effectiveRoot,
|
|
272
|
+
});
|
|
273
|
+
return typeof result === "string" ? result : JSON.stringify(result);
|
|
274
|
+
},
|
|
275
|
+
effectiveRoot,
|
|
276
|
+
client,
|
|
277
|
+
});
|
|
278
|
+
|
|
225
279
|
// ── Deterministic enforcement ──
|
|
226
280
|
await ctx.tool.hook("execute.before", (event) => {
|
|
281
|
+
// One policy decision per tool call even if a second instance with a
|
|
282
|
+
// different module path (global cache copy plus a source checkout)
|
|
283
|
+
// registered too. The owner evaluates; the other defers.
|
|
284
|
+
if (!claimToolCall(instance.key, event.sessionID, event.id, event.tool)) return;
|
|
227
285
|
const toolWorktree = getSessionBoundWorkspace(event.sessionID) || effectiveRoot;
|
|
228
286
|
return runWithRuntimeState(toolWorktree, client, async () => {
|
|
229
287
|
const args = event.input;
|
|
@@ -234,6 +292,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
234
292
|
agent: event.agent,
|
|
235
293
|
});
|
|
236
294
|
if (reason !== undefined) {
|
|
295
|
+
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
237
296
|
await showBlockToast(reason);
|
|
238
297
|
const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
|
|
239
298
|
const circuitBreakerSuffix = failureCount >= 2
|
|
@@ -241,11 +300,40 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
241
300
|
: "";
|
|
242
301
|
throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
|
|
243
302
|
}
|
|
303
|
+
// Policy 25: repeated failures with no live lookup since block the
|
|
304
|
+
// guess-and-retry surfaces until the session grounds itself in
|
|
305
|
+
// current documentation (V1 parity). Research tools are never gated.
|
|
306
|
+
if (event.sessionID && isMutatingSurfaceTool(event.tool)) {
|
|
307
|
+
const threshold = getLiveDocFailureThreshold(toolWorktree);
|
|
308
|
+
if (isLiveDocsRequired(toolWorktree)) {
|
|
309
|
+
const researchReason = liveDocsGatingReason(event.sessionID, event.tool, threshold, getResearchToolPatterns(toolWorktree));
|
|
310
|
+
if (researchReason !== undefined) {
|
|
311
|
+
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
312
|
+
await showBlockToast(researchReason);
|
|
313
|
+
console.log(`[workflow-guard] warn: ${researchReason}`);
|
|
314
|
+
audit({
|
|
315
|
+
ts: new Date().toISOString(),
|
|
316
|
+
sessionID: event.sessionID,
|
|
317
|
+
callID: event.id,
|
|
318
|
+
tool: event.tool,
|
|
319
|
+
decision: "block",
|
|
320
|
+
phase: "decision",
|
|
321
|
+
reason: "live_docs_research_floor",
|
|
322
|
+
});
|
|
323
|
+
throw new Error(`[workflow-guard] ${researchReason}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
244
327
|
toolLifecycle.start(event.sessionID, event.id);
|
|
245
328
|
const record = asRecord(args);
|
|
246
329
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
247
330
|
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
248
|
-
registerSessionWorkspaceFromToolCall(event.sessionID, {
|
|
331
|
+
await registerSessionWorkspaceFromToolCall(event.sessionID, {
|
|
332
|
+
workdir,
|
|
333
|
+
filePath,
|
|
334
|
+
toolWorktree,
|
|
335
|
+
mutating: EDIT_TOOL_NAMES.has(event.tool) || SHELL_TOOL_NAMES.has(event.tool),
|
|
336
|
+
});
|
|
249
337
|
if (event.tool === "read") {
|
|
250
338
|
const target = editTargets(args, toolWorktree)[0];
|
|
251
339
|
if (target) {
|
|
@@ -286,6 +374,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
286
374
|
});
|
|
287
375
|
|
|
288
376
|
await ctx.tool.hook("execute.after", async (event) => {
|
|
377
|
+
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
289
378
|
const startedAt = toolLifecycle.finish(event.sessionID, event.id);
|
|
290
379
|
const outcome = toolOutcomes.recordFallbackCompleted(event.sessionID, event.id, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
|
|
291
380
|
if (outcome) {
|
|
@@ -339,6 +428,25 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
339
428
|
// ── Session request hooks ──
|
|
340
429
|
await ctx.session.hook("context", (event) => {
|
|
341
430
|
try {
|
|
431
|
+
// A read-only role must not be able to *choose* a mutating tool. The
|
|
432
|
+
// execute.before seat still denies it, but pruning the per-request
|
|
433
|
+
// tool snapshot removes the call before the model plans it. Pruning
|
|
434
|
+
// is a narrowing only: tools supplied outside the host snapshot
|
|
435
|
+
// (ACP agents, nested Code Mode calls) still meet the backstop.
|
|
436
|
+
if (event.agent && isReadOnlyRole(event.agent)) {
|
|
437
|
+
const removed = pruneToolsForReadOnlyRole(event.tools as Record<string, unknown> | undefined);
|
|
438
|
+
if (removed.length > 0) {
|
|
439
|
+
audit({
|
|
440
|
+
ts: new Date().toISOString(),
|
|
441
|
+
sessionID: event.sessionID,
|
|
442
|
+
tool: "session.context",
|
|
443
|
+
decision: "block",
|
|
444
|
+
phase: "decision",
|
|
445
|
+
reason: "read_only_role_tools_pruned",
|
|
446
|
+
input: { agent: event.agent, removed },
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
342
450
|
const existing = Array.isArray(event.system)
|
|
343
451
|
? event.system.map((part) => (typeof part === "string" ? part : (part as { text?: string }).text ?? "")).join("\n")
|
|
344
452
|
: "";
|
|
@@ -388,7 +496,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
388
496
|
priorityContextBlocks.push(
|
|
389
497
|
`## Active Tasks${attribution}\n` +
|
|
390
498
|
lines.join("\n") +
|
|
391
|
-
"\nComplete tasks efficiently
|
|
499
|
+
"\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
500
|
);
|
|
393
501
|
}
|
|
394
502
|
|
|
@@ -451,22 +559,57 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
451
559
|
} catch {}
|
|
452
560
|
});
|
|
453
561
|
|
|
454
|
-
// ── Permission
|
|
455
|
-
|
|
562
|
+
// ── Permission seat: deny before the tool starts ──
|
|
563
|
+
// `evaluate` sees normalized `action`/`resources` and runs before the tool,
|
|
564
|
+
// so it is the earliest hard stop for shell commands, file mutations, and
|
|
565
|
+
// guarded MCP calls. It cannot see raw tool input, so the raw-argument work
|
|
566
|
+
// (todo lifecycle, stale-write, post-edit snapshots) stays in
|
|
567
|
+
// `execute.before`. See lib/permission-seat.ts for the split and the limits.
|
|
568
|
+
//
|
|
569
|
+
// Flow limit, documented by the host: an explicit configured `deny` is
|
|
570
|
+
// resolved before this hook runs, so the hook never sees it. A guard audit
|
|
571
|
+
// therefore cannot explain a configured deny; only host config can. The
|
|
572
|
+
// hook runs for allow/ask, and a denial here is always a tightening.
|
|
573
|
+
await ctx.permission.hook("evaluate", async (event) => {
|
|
574
|
+
const configuredEffect = event.effect;
|
|
456
575
|
try {
|
|
576
|
+
const seat = await evaluatePermissionSeat(
|
|
577
|
+
{
|
|
578
|
+
sessionID: event.sessionID,
|
|
579
|
+
agent: event.agent,
|
|
580
|
+
action: event.action,
|
|
581
|
+
resources: event.resources,
|
|
582
|
+
effect: configuredEffect,
|
|
583
|
+
},
|
|
584
|
+
{ workspaceRoot: effectiveRoot, client },
|
|
585
|
+
);
|
|
586
|
+
if (seat.decision) {
|
|
587
|
+
event.effect = "deny";
|
|
588
|
+
event.message = seat.decision.message;
|
|
589
|
+
}
|
|
457
590
|
audit({
|
|
458
591
|
ts: new Date().toISOString(),
|
|
459
592
|
sessionID: event.sessionID,
|
|
460
593
|
tool: "permission.ask",
|
|
461
|
-
decision:
|
|
594
|
+
decision: seat.effect === "deny" ? "block" : "allow",
|
|
595
|
+
phase: "decision",
|
|
596
|
+
reason: seat.decision?.code,
|
|
597
|
+
policyDecision: seat.decision,
|
|
462
598
|
input: {
|
|
463
599
|
action: event.action,
|
|
464
600
|
resources: event.resources,
|
|
465
601
|
agent: event.agent,
|
|
466
|
-
|
|
602
|
+
configuredEffect,
|
|
603
|
+
mappedTool: seat.mappedTool,
|
|
467
604
|
},
|
|
468
605
|
});
|
|
469
|
-
} catch {
|
|
606
|
+
} catch (error) {
|
|
607
|
+
// Fail closed: a seat that cannot evaluate must not become a pass.
|
|
608
|
+
try {
|
|
609
|
+
event.effect = "deny";
|
|
610
|
+
event.message = `[workflow-guard permission.evaluate] Blocked: the guard could not evaluate this permission request (${(error as Error)?.message ?? "unknown error"}).`;
|
|
611
|
+
} catch {}
|
|
612
|
+
}
|
|
470
613
|
});
|
|
471
614
|
|
|
472
615
|
// ── Shell env scrubbing ──
|
|
@@ -488,6 +631,47 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
488
631
|
} catch {}
|
|
489
632
|
});
|
|
490
633
|
|
|
634
|
+
// ── MCP inventory ──
|
|
635
|
+
// Seed the guarded token set from host-declared servers, then refresh it
|
|
636
|
+
// whenever the host reports that a server's tools changed. Widen-only:
|
|
637
|
+
// removal adds nothing, so a renamed or dropped server can never unguard a
|
|
638
|
+
// tool that still names a live system. The static defaults in
|
|
639
|
+
// policies/mcp.ts stay the floor.
|
|
640
|
+
const seedMcpInventory = async () => {
|
|
641
|
+
try {
|
|
642
|
+
const added = recordHostMcpServers(await listHostMcpServerNames(ctx));
|
|
643
|
+
if (added.length > 0) {
|
|
644
|
+
audit({
|
|
645
|
+
ts: new Date().toISOString(),
|
|
646
|
+
tool: "mcp.inventory",
|
|
647
|
+
decision: "allow",
|
|
648
|
+
phase: "event",
|
|
649
|
+
reason: "host_mcp_servers_widened",
|
|
650
|
+
input: { added },
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
} catch {}
|
|
654
|
+
};
|
|
655
|
+
await seedMcpInventory();
|
|
656
|
+
|
|
657
|
+
// ── Todo capability probe ──
|
|
658
|
+
// Ask the host's registry whether `todowrite` exists, instead of inferring
|
|
659
|
+
// it from history. On 2.0.x the answer is no, so the mutation gate fails
|
|
660
|
+
// open; on 2.1.x with `experimental.todo: true` the answer is yes, so a
|
|
661
|
+
// session that never planned is gated. Probe once: the registry is fixed
|
|
662
|
+
// for the life of the process.
|
|
663
|
+
try {
|
|
664
|
+
const capability = await detectTodoCapability(ctx);
|
|
665
|
+
recordTodoCapability(capability);
|
|
666
|
+
audit({
|
|
667
|
+
ts: new Date().toISOString(),
|
|
668
|
+
tool: "todo.capability",
|
|
669
|
+
decision: "allow",
|
|
670
|
+
phase: "event",
|
|
671
|
+
reason: `todo_capability_${capability}`,
|
|
672
|
+
});
|
|
673
|
+
} catch {}
|
|
674
|
+
|
|
491
675
|
// ── Event stream ──
|
|
492
676
|
const controller = new AbortController();
|
|
493
677
|
void (async () => {
|
|
@@ -500,6 +684,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
500
684
|
client,
|
|
501
685
|
toolLifecycle,
|
|
502
686
|
toolOutcomes,
|
|
687
|
+
refreshMcpInventory: seedMcpInventory,
|
|
503
688
|
});
|
|
504
689
|
} catch {}
|
|
505
690
|
}
|
|
@@ -508,7 +693,10 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
508
693
|
}
|
|
509
694
|
})();
|
|
510
695
|
|
|
511
|
-
return () =>
|
|
696
|
+
return () => {
|
|
697
|
+
controller.abort();
|
|
698
|
+
releasePluginInstance(instance.key);
|
|
699
|
+
};
|
|
512
700
|
};
|
|
513
701
|
|
|
514
702
|
// ── Event handling ────────────────────────────────────────────────────────────
|
|
@@ -518,6 +706,8 @@ interface V2EventState {
|
|
|
518
706
|
client: unknown;
|
|
519
707
|
toolLifecycle: ToolInvocationLifecycle;
|
|
520
708
|
toolOutcomes: ToolOutcomeTracker;
|
|
709
|
+
/** Re-read the host MCP roster after `mcp.tools.changed`. */
|
|
710
|
+
refreshMcpInventory?: () => Promise<void>;
|
|
521
711
|
}
|
|
522
712
|
|
|
523
713
|
async function handleV2Event(
|
|
@@ -526,6 +716,14 @@ async function handleV2Event(
|
|
|
526
716
|
): Promise<void> {
|
|
527
717
|
const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
|
|
528
718
|
|
|
719
|
+
if (event.type === "mcp.tools.changed") {
|
|
720
|
+
// The host's server roster or one server's tool list changed. Re-read it
|
|
721
|
+
// so a newly connected live-system server is guarded before its tools
|
|
722
|
+
// are callable. Widen-only: this can add tokens, never remove them.
|
|
723
|
+
await state.refreshMcpInventory?.();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
529
727
|
if (event.type === "session.message.content.updated") {
|
|
530
728
|
const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
|
|
531
729
|
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
@@ -552,6 +750,15 @@ async function handleV2Event(
|
|
|
552
750
|
};
|
|
553
751
|
const outcome = toolOutcomes.record(mapped);
|
|
554
752
|
if (outcome) {
|
|
753
|
+
// Policy 25 research floor (V1 parity): failures accrue debt;
|
|
754
|
+
// successful live-source lookups clear it. A failed research
|
|
755
|
+
// attempt never accrues debt, so a flaky lookup cannot deepen
|
|
756
|
+
// the block it was meant to escape.
|
|
757
|
+
if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
|
|
758
|
+
if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
|
|
759
|
+
} else if (outcome.status === "error") {
|
|
760
|
+
recordLiveDocsFailure(outcome.sessionID);
|
|
761
|
+
}
|
|
555
762
|
audit({
|
|
556
763
|
ts: new Date().toISOString(),
|
|
557
764
|
sessionID: outcome.sessionID,
|
|
@@ -596,6 +803,20 @@ async function handleV2Event(
|
|
|
596
803
|
input: { claim: check.claim, evidenceState: check.evidenceState },
|
|
597
804
|
});
|
|
598
805
|
}
|
|
806
|
+
// Policy 26: journal sweeping repository claims unsupported by read
|
|
807
|
+
// coverage. Observability only - never mutates the response text.
|
|
808
|
+
const sweeping = checkSweepingClaims(text, { sessionID });
|
|
809
|
+
if (sweeping.sweeping && sweeping.reason) {
|
|
810
|
+
console.log(`[workflow-guard] sweeping claim mismatch: ${sweeping.reason}`);
|
|
811
|
+
audit({
|
|
812
|
+
ts: new Date().toISOString(),
|
|
813
|
+
sessionID,
|
|
814
|
+
tool: "experimental.text.complete",
|
|
815
|
+
decision: "allow",
|
|
816
|
+
reason: `Sweeping claim '${sweeping.claim}' with ${sweeping.readFileCount} file(s) read`,
|
|
817
|
+
input: { claim: sweeping.claim, readFileCount: sweeping.readFileCount },
|
|
818
|
+
});
|
|
819
|
+
}
|
|
599
820
|
} catch {}
|
|
600
821
|
return;
|
|
601
822
|
}
|
|
@@ -624,7 +845,9 @@ async function handleV2Event(
|
|
|
624
845
|
releaseFileClaims(sessionID);
|
|
625
846
|
clearReadFingerprints(sessionID);
|
|
626
847
|
toolLifecycle.clearSession(sessionID);
|
|
848
|
+
clearLiveDocsFloor(sessionID);
|
|
627
849
|
toolOutcomes.clearSession(sessionID);
|
|
850
|
+
clearTodoSessionState(sessionID);
|
|
628
851
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
629
852
|
await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
|
|
630
853
|
}
|
|
@@ -646,8 +869,24 @@ async function handleV2Event(
|
|
|
646
869
|
});
|
|
647
870
|
return;
|
|
648
871
|
}
|
|
872
|
+
// `command.executed` exists in the V2 event stream (`data: { name, sessionID,
|
|
873
|
+
// arguments, messageID }`) and carries what the V1 audit wanted: the slash
|
|
874
|
+
// command name, its arguments, and the session. It is journaled here rather
|
|
875
|
+
// than dropped, so a command that mutates workspace state stays attributable.
|
|
876
|
+
if (event.type === "command.executed") {
|
|
877
|
+
const data = event.data as { sessionID?: unknown; name?: unknown; arguments?: unknown } | undefined;
|
|
878
|
+
audit({
|
|
879
|
+
ts: new Date().toISOString(),
|
|
880
|
+
sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
|
|
881
|
+
tool: "command.executed",
|
|
882
|
+
decision: "allow",
|
|
883
|
+
phase: "event",
|
|
884
|
+
reason: typeof data?.name === "string" ? data.name : undefined,
|
|
885
|
+
input: summarizeInput({ name: data?.name, arguments: data?.arguments }),
|
|
886
|
+
});
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
649
889
|
// Note on V1 events without a V2 counterpart:
|
|
650
|
-
// - `command.executed`: no V2 event stream equivalent (audit-only in V1; dropped).
|
|
651
890
|
// - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
|
|
652
891
|
// - `session.created`: exists in the V2 union but needed no V1 behavior beyond
|
|
653
892
|
// the audit above; session state resets happen on `session.deleted`.
|