opencode-workflow-guard 1.15.5 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/installation.md +79 -4
- 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/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 +838 -4
package/src/lib/state.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
-
import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
|
|
4
|
+
import { audit, getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
|
|
5
5
|
import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
6
6
|
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
7
7
|
import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
|
|
@@ -56,6 +56,23 @@ const workspaceMutationCounts = new Map<string, number>();
|
|
|
56
56
|
export const sessionMutationTimestamps = new Map<string, number>();
|
|
57
57
|
export const sessionMutationCounts = new Map<string, number>();
|
|
58
58
|
export const sessionWorkspaces = new Map<string, string>();
|
|
59
|
+
// Per-session, per-task mutation count recorded when an active task entered
|
|
60
|
+
// its current active window. Completion is judged per task: one mutation
|
|
61
|
+
// cannot vouch for several completions.
|
|
62
|
+
export const sessionTodoTaskStarts = new Map<string, Map<string, number>>();
|
|
63
|
+
|
|
64
|
+
export function todoTaskStarts(sessionID: string): Map<string, number> {
|
|
65
|
+
let starts = sessionTodoTaskStarts.get(sessionID);
|
|
66
|
+
if (!starts) {
|
|
67
|
+
starts = new Map();
|
|
68
|
+
sessionTodoTaskStarts.set(sessionID, starts);
|
|
69
|
+
}
|
|
70
|
+
return starts;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearTodoSessionState(sessionID: string): void {
|
|
74
|
+
sessionTodoTaskStarts.delete(sessionID);
|
|
75
|
+
}
|
|
59
76
|
// Sessions whose workspace was registered from their OWN context (tool-call
|
|
60
77
|
// arguments or a host-provided per-call worktree). A session registered only
|
|
61
78
|
// from the plugin-instance default is deliberately left unmarked: concurrent
|
|
@@ -89,33 +106,155 @@ export function getSessionBoundWorkspace(sessionID?: string): string | undefined
|
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
109
|
+
* The nearest bound workspace up the parent chain, for a session with no
|
|
110
|
+
* direct binding of its own.
|
|
111
|
+
*
|
|
112
|
+
* A subagent's policy root already inherits its parent's workspace, so its
|
|
113
|
+
* registration must use the same answer. Without this, a child of a bound
|
|
114
|
+
* session looked unbound to the registration path and its first foreign read
|
|
115
|
+
* rebound it into another repository: the parent stayed confined and the child
|
|
116
|
+
* escaped.
|
|
117
|
+
*
|
|
118
|
+
* Walks at most `MAX_PARENT_DEPTH` links and stops on a cycle.
|
|
119
|
+
*
|
|
120
|
+
* Cost: one `session.get` per parent link, issued only when the session has no
|
|
121
|
+
* direct binding. A directly bound session returns from the in-memory map with
|
|
122
|
+
* no RPC, so the common case pays nothing. The walk is depth-capped and
|
|
123
|
+
* cycle-checked, so a malformed parent graph cannot spin.
|
|
124
|
+
*/
|
|
125
|
+
const MAX_PARENT_DEPTH = 16;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Walk up the parent chain from `sessionID` and return the nearest directly
|
|
129
|
+
* bound ancestor's workspace.
|
|
130
|
+
*
|
|
131
|
+
* Shared by `resolveInheritedWorkspace` (full walk, used by workspace
|
|
132
|
+
* registration so a subagent cannot rebind out of its parent's boundary) and
|
|
133
|
+
* `resolveSessionBoundWorkspace` (one level, used by review-verdict binding).
|
|
134
|
+
* The depth parameter is the only difference; keeping one implementation makes
|
|
135
|
+
* the one-lookup-per-link cost visible in a single place.
|
|
136
|
+
*/
|
|
137
|
+
async function resolveAncestorBoundWorkspace(sessionID: string, maxDepth: number): Promise<string | undefined> {
|
|
138
|
+
if (maxDepth <= 0) return undefined;
|
|
139
|
+
const client = getSdkClient();
|
|
140
|
+
const session = client?.session;
|
|
141
|
+
const get = session?.get;
|
|
142
|
+
if (typeof get !== "function") return undefined;
|
|
143
|
+
const seen = new Set<string>([sessionID]);
|
|
144
|
+
let current = sessionID;
|
|
145
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
146
|
+
let parentID: unknown;
|
|
147
|
+
try {
|
|
148
|
+
const result = await get.call(session, { path: { id: current } });
|
|
149
|
+
parentID = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
if (typeof parentID !== "string" || !parentID || seen.has(parentID)) return undefined;
|
|
154
|
+
const inherited = getSessionBoundWorkspace(parentID);
|
|
155
|
+
if (inherited) return inherited;
|
|
156
|
+
seen.add(parentID);
|
|
157
|
+
current = parentID;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function resolveInheritedWorkspace(sessionID?: string): Promise<string | undefined> {
|
|
163
|
+
if (!sessionID) return undefined;
|
|
164
|
+
const direct = getSessionBoundWorkspace(sessionID);
|
|
165
|
+
if (direct) return direct;
|
|
166
|
+
return resolveAncestorBoundWorkspace(sessionID, MAX_PARENT_DEPTH);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Register a session's workspace from a tool call's own context.
|
|
171
|
+
*
|
|
172
|
+
* Two distinct operations, deliberately split:
|
|
173
|
+
*
|
|
174
|
+
* - **First bind.** A session with no bound workspace adopts the git root of
|
|
175
|
+
* its own call arguments (`workdir`/`filePath`), else the host-provided
|
|
176
|
+
* per-call worktree, else a path-derived bootstrap from the plugin-instance
|
|
177
|
+
* fallback. A bootstrap from the fallback stays unbound: concurrent loops
|
|
178
|
+
* share this process, so a value derived from the last-active plugin
|
|
179
|
+
* instance must never bind review evidence.
|
|
180
|
+
* - **Widen.** Once bound, only an explicit host-declared per-call worktree
|
|
181
|
+
* may move the boundary. Worktree isolation and relay flows set it, so it is
|
|
182
|
+
* a boundary statement from the host. A bare path candidate outside the
|
|
183
|
+
* boundary is a read or write against foreign state, which the boundary
|
|
184
|
+
* policy refuses; adopting it would let one allowed external read move the
|
|
185
|
+
* whole write boundary.
|
|
186
|
+
*
|
|
187
|
+
* "Bound" includes an inherited binding: a subagent with no direct binding of
|
|
188
|
+
* its own already resolves to its nearest bound ancestor's workspace for
|
|
189
|
+
* policy decisions, so a path-derived candidate must not bind it elsewhere.
|
|
190
|
+
*
|
|
191
|
+
* Every widen and every refused widen is journaled, because the boundary is
|
|
192
|
+
* what unattended operation rests on.
|
|
100
193
|
*/
|
|
101
|
-
export function registerSessionWorkspaceFromToolCall(
|
|
194
|
+
export async function registerSessionWorkspaceFromToolCall(
|
|
102
195
|
sessionID: string | undefined,
|
|
103
|
-
call: { workdir?: string; filePath?: string; hostWorktree?: string; toolWorktree: string },
|
|
104
|
-
): void {
|
|
196
|
+
call: { workdir?: string; filePath?: string; hostWorktree?: string; toolWorktree: string; mutating?: boolean },
|
|
197
|
+
): Promise<void> {
|
|
105
198
|
if (!sessionID) return;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
199
|
+
const bound = await resolveInheritedWorkspace(sessionID);
|
|
200
|
+
|
|
201
|
+
// A host-declared per-call worktree is an explicit boundary statement.
|
|
202
|
+
const hostRoot = call.hostWorktree ? findGitRoot(call.hostWorktree) : undefined;
|
|
203
|
+
if (hostRoot && hostRoot !== bound) {
|
|
204
|
+
setSessionWorkspace(sessionID, hostRoot);
|
|
205
|
+
auditWorkspaceRebind(sessionID, bound, hostRoot, "host-worktree");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const rawCandidate = call.workdir ?? call.filePath;
|
|
210
|
+
if (!rawCandidate) {
|
|
211
|
+
if (sessionWorkspaces.has(sessionID)) return;
|
|
212
|
+
const gitRoot = findGitRoot(call.toolWorktree);
|
|
213
|
+
sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
|
|
114
214
|
return;
|
|
115
215
|
}
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
216
|
+
|
|
217
|
+
const candidatePath = isAbsolute(rawCandidate) ? rawCandidate : resolve(call.toolWorktree, rawCandidate);
|
|
218
|
+
const gitRoot = findGitRoot(candidatePath);
|
|
219
|
+
if (!gitRoot) return;
|
|
220
|
+
if (gitRoot === bound) return;
|
|
221
|
+
|
|
222
|
+
if (!bound) {
|
|
223
|
+
sessionWorkspaces.set(sessionID, gitRoot);
|
|
224
|
+
sessionWorkspaceBound.add(sessionID);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
auditWorkspaceRebindRefused(sessionID, bound, gitRoot, call.mutating ? "mutation" : "read");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Durable record of a granted boundary widening. */
|
|
231
|
+
function auditWorkspaceRebind(sessionID: string, from: string | undefined, to: string, source: string): void {
|
|
232
|
+
audit({
|
|
233
|
+
ts: new Date().toISOString(),
|
|
234
|
+
sessionID,
|
|
235
|
+
tool: "workspace.binding",
|
|
236
|
+
decision: "allow",
|
|
237
|
+
phase: "event",
|
|
238
|
+
reason: "workspace_rebind_host_worktree",
|
|
239
|
+
input: { from, to, source },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Durable record of a refused widening. The refusal itself is the boundary
|
|
245
|
+
* policy's decision; this entry exists so an operator can tell whether a
|
|
246
|
+
* foreign path arrived through a read or a mutation.
|
|
247
|
+
*/
|
|
248
|
+
function auditWorkspaceRebindRefused(sessionID: string, from: string, to: string, source: "read" | "mutation"): void {
|
|
249
|
+
audit({
|
|
250
|
+
ts: new Date().toISOString(),
|
|
251
|
+
sessionID,
|
|
252
|
+
tool: "workspace.binding",
|
|
253
|
+
decision: "block",
|
|
254
|
+
phase: "event",
|
|
255
|
+
reason: "workspace_rebind_refused",
|
|
256
|
+
input: { from, to, source },
|
|
257
|
+
});
|
|
119
258
|
}
|
|
120
259
|
|
|
121
260
|
/**
|
|
@@ -129,19 +268,10 @@ export async function resolveSessionBoundWorkspace(sessionID?: string): Promise<
|
|
|
129
268
|
if (!sessionID) return undefined;
|
|
130
269
|
const direct = getSessionBoundWorkspace(sessionID);
|
|
131
270
|
if (direct) return direct;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (typeof get === "function") {
|
|
137
|
-
const result = await get.call(session, { path: { id: sessionID } });
|
|
138
|
-
const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
|
|
139
|
-
if (typeof parent === "string" && parent && sessionWorkspaceBound.has(parent)) {
|
|
140
|
-
return sessionWorkspaces.get(parent);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
} catch {}
|
|
144
|
-
return undefined;
|
|
271
|
+
// One level only: review-verdict binding asks for the immediate parent, and
|
|
272
|
+
// walking further would let a grandparent's workspace answer for a child
|
|
273
|
+
// that was deliberately left unbound.
|
|
274
|
+
return resolveAncestorBoundWorkspace(sessionID, 1);
|
|
145
275
|
}
|
|
146
276
|
|
|
147
277
|
export let lastVerify: VerifyResult | undefined;
|
|
@@ -242,6 +372,7 @@ export function resetVerifyState(): void {
|
|
|
242
372
|
workspaceMutationTimestamps.clear();
|
|
243
373
|
workspaceMutationCounts.clear();
|
|
244
374
|
sessionVerifyResults.clear();
|
|
375
|
+
sessionTodoTaskStarts.clear();
|
|
245
376
|
}
|
|
246
377
|
|
|
247
378
|
export function recordReviewResult(
|
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`.
|