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.
@@ -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
- * Register a session's workspace from a tool call's own context. Candidates in
93
- * priority order: tool-argument paths (workdir/filePath — the call's own
94
- * target), then a host-provided per-call worktree/directory. A session that is
95
- * already registered is never re-anchored from the plugin-instance fallback:
96
- * under concurrent loops that fallback can belong to a different session's
97
- * workspace, which is exactly how workspaces cross-contaminate. Only a
98
- * session with no registration at all is bootstrapped from the fallback, and
99
- * such a bootstrap stays unbound (never used to bind review evidence).
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 rawCandidate = call.workdir ?? call.filePath ?? call.hostWorktree;
107
- if (rawCandidate) {
108
- const candidatePath = isAbsolute(rawCandidate) ? rawCandidate : resolve(call.toolWorktree, rawCandidate);
109
- const gitRoot = findGitRoot(candidatePath);
110
- if (gitRoot) {
111
- sessionWorkspaces.set(sessionID, gitRoot);
112
- sessionWorkspaceBound.add(sessionID);
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
- if (sessionWorkspaces.has(sessionID)) return;
117
- const gitRoot = findGitRoot(call.toolWorktree);
118
- sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
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
- try {
133
- const client = getSdkClient();
134
- const session = client?.session;
135
- const get = session?.get;
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(