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/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.
100
124
  */
101
- export function registerSessionWorkspaceFromToolCall(
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.
193
+ */
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));
214
+ return;
215
+ }
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);
114
225
  return;
115
226
  }
116
- if (sessionWorkspaces.has(sessionID)) return;
117
- const gitRoot = findGitRoot(call.toolWorktree);
118
- sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
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(
@@ -412,6 +543,51 @@ export function isDocumentationRequired(root: string): boolean {
412
543
  return cfg.requireDocumentation === true;
413
544
  }
414
545
 
546
+ /**
547
+ * Policy 25 (live-documentation research floor): enabled by default. The env
548
+ * override mirrors `isReviewRequired`; project config can opt out. The floor
549
+ * engages after `getLiveDocFailureThreshold` failed tool calls with no live
550
+ * lookup since the last failure.
551
+ */
552
+ export function isLiveDocsRequired(root: string): boolean {
553
+ const env = process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS?.toLowerCase();
554
+ if (env === "0" || env === "false" || env === "off") return false;
555
+ if (env === "1" || env === "true" || env === "on") return true;
556
+ const cfg = getProjectConfig(root);
557
+ return cfg.requireLiveDocs !== false;
558
+ }
559
+
560
+ export function getLiveDocFailureThreshold(root: string): number {
561
+ const env = process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
562
+ if (env !== undefined && env.trim() !== "") {
563
+ // Number() rejects trailing junk that parseInt would silently accept.
564
+ const parsed = Number(env.trim());
565
+ if (Number.isInteger(parsed) && parsed >= 1) return parsed;
566
+ }
567
+ const cfg = getProjectConfig(root);
568
+ if (typeof cfg.liveDocFailureThreshold === "number" && Number.isInteger(cfg.liveDocFailureThreshold) && cfg.liveDocFailureThreshold >= 1) {
569
+ return cfg.liveDocFailureThreshold;
570
+ }
571
+ return 3;
572
+ }
573
+
574
+ /**
575
+ * Extra tool-name fragments that count as a live-documentation lookup, so an
576
+ * operator whose docs server is not in the builtin token set can still satisfy
577
+ * the floor: `researchTools` in project config or
578
+ * `WORKFLOW_GUARD_RESEARCH_TOOLS` (comma-separated).
579
+ */
580
+ export function getResearchToolPatterns(root: string = getWorkspaceRoot()): string[] {
581
+ const env = process.env.WORKFLOW_GUARD_RESEARCH_TOOLS;
582
+ if (env !== undefined && env.trim() !== "") {
583
+ return env.split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
584
+ }
585
+ const configured = getProjectConfig(root).researchTools;
586
+ return Array.isArray(configured)
587
+ ? configured.filter((value): value is string => typeof value === "string").map((value) => value.trim().toLowerCase()).filter(Boolean)
588
+ : [];
589
+ }
590
+
415
591
  export function getOperationProfile(root: string): "interactive" | "autonomous" {
416
592
  return getProjectConfig(root).profile === "autonomous" ? "autonomous" : "interactive";
417
593
  }
package/src/lib/types.ts CHANGED
@@ -50,6 +50,21 @@ export interface ProjectConfig {
50
50
  */
51
51
  requireSubagentReview?: boolean;
52
52
  requireDocumentation?: boolean;
53
+ /**
54
+ * Policy 25 (live-documentation research floor). Enabled by default: after
55
+ * `liveDocFailureThreshold` failed tool calls with no live-documentation
56
+ * lookup since the last failure, mutating/shell tools are blocked until the
57
+ * session consults a current source. Set `false` in project config, or
58
+ * `WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0` in the environment, to disable.
59
+ */
60
+ requireLiveDocs?: boolean;
61
+ /** Failure count at which the research floor engages (default 3). */
62
+ liveDocFailureThreshold?: number;
63
+ /**
64
+ * Extra tool-name fragments counted as a live-documentation lookup, for an
65
+ * operator whose docs server is not in the builtin research token set.
66
+ */
67
+ researchTools?: string[];
53
68
  /**
54
69
  * Absolute paths of the LIVE control plane (runtime `~/.config/opencode`,
55
70
  * the guard's own install). When set, config-path protection classifies by