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/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.
|
|
100
124
|
*/
|
|
101
|
-
|
|
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
|
|
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));
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
@@ -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
|