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
|
@@ -150,6 +150,21 @@ function databaseIsActive(path: string, directory: string): boolean {
|
|
|
150
150
|
return active;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
function reapStaleActiveMarkers(directory: string): void {
|
|
154
|
+
const databaseNames = new Set(readdirSync(directory).filter((entry) => entry.endsWith(".sqlite")));
|
|
155
|
+
for (const name of readdirSync(directory)) {
|
|
156
|
+
const markerSeparator = name.lastIndexOf(".active.");
|
|
157
|
+
if (markerSeparator < 0) continue;
|
|
158
|
+
const databaseName = name.slice(0, markerSeparator);
|
|
159
|
+
if (!databaseNames.has(databaseName)) continue;
|
|
160
|
+
const pid = Number(name.slice(markerSeparator + ".active.".length).split(".", 1)[0]);
|
|
161
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
162
|
+
try { process.kill(pid, 0); } catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(join(directory, name), { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
153
168
|
export function maintainProjectMemoryStorage(store: ProjectMemoryStore, directory = getProjectMemoryDir(), options: MaintenanceOptions = {}): void {
|
|
154
169
|
const now = options.now ?? Date.now();
|
|
155
170
|
const maintenanceMarker = `${store.path}.maintenance`;
|
|
@@ -173,6 +188,7 @@ export function maintainProjectMemoryStorage(store: ProjectMemoryStore, director
|
|
|
173
188
|
store.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
174
189
|
store.db.exec("PRAGMA optimize");
|
|
175
190
|
store.db.exec("VACUUM");
|
|
191
|
+
reapStaleActiveMarkers(directory);
|
|
176
192
|
try { utimesSync(store.path, now / 1000, now / 1000); } catch {}
|
|
177
193
|
writeFileSync(maintenanceMarker, "", { mode: 0o600 });
|
|
178
194
|
try { utimesSync(maintenanceMarker, now / 1000, now / 1000); } catch {}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool pruning for read-only subagent roles.
|
|
3
|
+
*
|
|
4
|
+
* `execute.before` already denies a mutation from a read-only role, but only
|
|
5
|
+
* after the model has planned the call and the host has admitted it. The V2
|
|
6
|
+
* `session.hook("context")` sees the per-request tool snapshot, so the
|
|
7
|
+
* stronger invariant is to remove the mutating tools before the model can
|
|
8
|
+
* choose them.
|
|
9
|
+
*
|
|
10
|
+
* This is a narrowing only. The `execute.before` seat stays in place as the
|
|
11
|
+
* backstop: tools supplied by an ACP agent or a nested Code Mode call may not
|
|
12
|
+
* appear in the host snapshot, and a pruned list is not an authorization
|
|
13
|
+
* boundary on its own.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tools removed from a read-only role's request.
|
|
18
|
+
*
|
|
19
|
+
* - `write`/`edit`/`patch`/`apply_patch` author file contents.
|
|
20
|
+
* - `shell`/`bash` author bytes through redirects and can mutate git state.
|
|
21
|
+
* - `execute` is Code Mode, which runs nested tool calls. Nested calls are not
|
|
22
|
+
* documented to fire the guard's hooks, so a read-only role must not reach
|
|
23
|
+
* them.
|
|
24
|
+
* - `todowrite` replaces the whole task list; read-only roles do not plan work.
|
|
25
|
+
* - `subagent`/`task` can delegate a mutation to a child that runs with the
|
|
26
|
+
* child's own role.
|
|
27
|
+
* - `guard_worktree_create`/`guard_worktree_cleanup` mutate repository state
|
|
28
|
+
* and are already denied by the dispatcher for this role.
|
|
29
|
+
*/
|
|
30
|
+
export const READ_ONLY_PRUNED_TOOLS: readonly string[] = [
|
|
31
|
+
"write",
|
|
32
|
+
"edit",
|
|
33
|
+
"patch",
|
|
34
|
+
"apply_patch",
|
|
35
|
+
"shell",
|
|
36
|
+
"bash",
|
|
37
|
+
"execute",
|
|
38
|
+
"todowrite",
|
|
39
|
+
"subagent",
|
|
40
|
+
"task",
|
|
41
|
+
"guard_worktree_create",
|
|
42
|
+
"guard_worktree_cleanup",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Delete the mutating tools from a per-request tool snapshot.
|
|
47
|
+
*
|
|
48
|
+
* Returns the ids actually removed, so the caller can journal the narrowing.
|
|
49
|
+
* The snapshot is host-owned and mutable for the duration of the request;
|
|
50
|
+
* deleting a key affects only the outgoing call.
|
|
51
|
+
*/
|
|
52
|
+
export function pruneToolsForReadOnlyRole(tools: Record<string, unknown> | undefined): string[] {
|
|
53
|
+
if (!tools || typeof tools !== "object") return [];
|
|
54
|
+
const removed: string[] = [];
|
|
55
|
+
for (const id of READ_ONLY_PRUNED_TOOLS) {
|
|
56
|
+
if (!(id in tools)) continue;
|
|
57
|
+
delete tools[id];
|
|
58
|
+
removed.push(id);
|
|
59
|
+
}
|
|
60
|
+
return removed;
|
|
61
|
+
}
|
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(
|