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/policies/todo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TodoItem } from "../lib/types.ts";
|
|
2
|
-
import { getSessionMutationCount, getSdkClient, getSubagentMutationBudget } from "../lib/state.ts";
|
|
2
|
+
import { getSessionMutationCount, getSdkClient, getSubagentMutationBudget, todoTaskStarts } from "../lib/state.ts";
|
|
3
3
|
|
|
4
4
|
export const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
5
5
|
export const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
|
|
@@ -94,6 +94,221 @@ export function hasActiveTodo(todos: TodoItem[]): boolean {
|
|
|
94
94
|
return todos.some((todo) => ACTIVE_TODO_STATUSES.has(String(todo.status ?? "")));
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Explicit read-only escape. A task whose text carries this marker completes
|
|
99
|
+
* without a mutation in its window. Every use is journaled, so an agent that
|
|
100
|
+
* marks real work read-only leaves a visible trail instead of passing silently.
|
|
101
|
+
*/
|
|
102
|
+
export const READ_ONLY_TODO_MARKER = /\[read[-_ ]?only\]/i;
|
|
103
|
+
|
|
104
|
+
export function isReadOnlyTodo(todo: TodoItem): boolean {
|
|
105
|
+
return READ_ONLY_TODO_MARKER.test(String(todo.content ?? ""));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Task identity: trimmed content with the read-only marker removed.
|
|
110
|
+
*
|
|
111
|
+
* The marker is an annotation about a task, not part of which task it is. An
|
|
112
|
+
* agent that planned a task and only then discovers it is read-only appends the
|
|
113
|
+
* marker; that must not read as deleting one task and inventing another, or the
|
|
114
|
+
* documented remedy would be blocked by the no-silent-deletion rule. It also
|
|
115
|
+
* keeps the task's window, so annotating instead of re-planning loses nothing.
|
|
116
|
+
*/
|
|
117
|
+
export function todoTaskKey(todo: TodoItem): string {
|
|
118
|
+
return String(todo.content ?? "")
|
|
119
|
+
.replace(READ_ONLY_TODO_MARKER, "")
|
|
120
|
+
.replace(/\s+/g, " ")
|
|
121
|
+
.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Raw task text, for messages the model has to match against its own list. */
|
|
125
|
+
function todoTaskLabel(todo: TodoItem): string {
|
|
126
|
+
return String(todo.content ?? "").trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Record when each active task entered its current active window, in the
|
|
131
|
+
* session's mutation count. Completion evidence is judged against this.
|
|
132
|
+
*
|
|
133
|
+
* Call this only for a `todowrite` the guard allowed: a blocked write never
|
|
134
|
+
* becomes the task list, so it must not move a task's start.
|
|
135
|
+
*/
|
|
136
|
+
export function recordTodoTaskStarts(
|
|
137
|
+
starts: Map<string, number>,
|
|
138
|
+
previousTodos: TodoItem[] | undefined,
|
|
139
|
+
nextTodos: TodoItem[],
|
|
140
|
+
mutationCount: number,
|
|
141
|
+
): void {
|
|
142
|
+
const previouslyActive = new Set<string>();
|
|
143
|
+
for (const todo of previousTodos ?? []) {
|
|
144
|
+
if (ACTIVE_TODO_STATUSES.has(String(todo.status ?? ""))) previouslyActive.add(todoTaskKey(todo));
|
|
145
|
+
}
|
|
146
|
+
const live = new Set<string>();
|
|
147
|
+
for (const todo of nextTodos) {
|
|
148
|
+
const key = todoTaskKey(todo);
|
|
149
|
+
if (!key) continue;
|
|
150
|
+
if (ACTIVE_TODO_STATUSES.has(String(todo.status ?? ""))) {
|
|
151
|
+
live.add(key);
|
|
152
|
+
// A task keeps its original window across rewrites and status
|
|
153
|
+
// changes within the active set. A task returning to active after
|
|
154
|
+
// finishing starts a fresh window.
|
|
155
|
+
if (!previouslyActive.has(key) || !starts.has(key)) starts.set(key, mutationCount);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Finished or dropped tasks close their window; a later task with the
|
|
159
|
+
// same text starts clean instead of inheriting stale evidence.
|
|
160
|
+
for (const key of [...starts.keys()]) {
|
|
161
|
+
if (!live.has(key)) starts.delete(key);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Completion evidence, enforced: a task may not move to `completed` unless the
|
|
167
|
+
* session mutated at least once inside that task's active window. One mutation
|
|
168
|
+
* cannot vouch for several completions, so N completions need N mutations
|
|
169
|
+
* counted from the earliest of them.
|
|
170
|
+
*
|
|
171
|
+
* Read-only tasks are the documented escape: they carry `[read-only]` in their
|
|
172
|
+
* text and complete freely. Todos are still not authority on finalization; the
|
|
173
|
+
* verification gate remains the authority there.
|
|
174
|
+
*
|
|
175
|
+
* Tasks are matched to their prior occurrence by count, not by last-write-wins,
|
|
176
|
+
* so duplicate content cannot hide a new completion behind an already-completed
|
|
177
|
+
* item of the same text.
|
|
178
|
+
*
|
|
179
|
+
* Returns the tasks that still lack evidence. An empty list means the write may
|
|
180
|
+
* proceed.
|
|
181
|
+
*/
|
|
182
|
+
export function unverifiedTodoCompletions(
|
|
183
|
+
previousTodos: TodoItem[] | undefined,
|
|
184
|
+
nextTodos: TodoItem[],
|
|
185
|
+
starts: Map<string, number>,
|
|
186
|
+
mutationCount: number,
|
|
187
|
+
): string[] {
|
|
188
|
+
// Each prior occurrence can absorb at most one new item of the same key. A
|
|
189
|
+
// prior `completed` occurrence absorbs a new `completed` item silently; any
|
|
190
|
+
// other prior occurrence leaves the new item as a fresh claim.
|
|
191
|
+
const absorbable = new Map<string, number>();
|
|
192
|
+
const absorbCompleted = new Map<string, number>();
|
|
193
|
+
for (const todo of previousTodos ?? []) {
|
|
194
|
+
const key = todoTaskKey(todo);
|
|
195
|
+
if (!key) continue;
|
|
196
|
+
absorbable.set(key, (absorbable.get(key) ?? 0) + 1);
|
|
197
|
+
if (String(todo.status ?? "") === "completed") {
|
|
198
|
+
absorbCompleted.set(key, (absorbCompleted.get(key) ?? 0) + 1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const claimed: Array<{ task: string; start: number }> = [];
|
|
203
|
+
for (const todo of nextTodos) {
|
|
204
|
+
const key = todoTaskKey(todo);
|
|
205
|
+
if (!key) continue;
|
|
206
|
+
if (String(todo.status ?? "") !== "completed") continue;
|
|
207
|
+
const completedBefore = absorbCompleted.get(key) ?? 0;
|
|
208
|
+
if (completedBefore > 0) {
|
|
209
|
+
// An unchanged completed item, not a new claim.
|
|
210
|
+
absorbCompleted.set(key, completedBefore - 1);
|
|
211
|
+
const remaining = absorbable.get(key) ?? 0;
|
|
212
|
+
if (remaining > 0) absorbable.set(key, remaining - 1);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const remaining = absorbable.get(key) ?? 0;
|
|
216
|
+
if (remaining > 0) absorbable.set(key, remaining - 1);
|
|
217
|
+
if (isReadOnlyTodo(todo)) continue;
|
|
218
|
+
claimed.push({ task: todoTaskLabel(todo), start: starts.get(key) ?? 0 });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (claimed.length === 0) return [];
|
|
222
|
+
// Each completion needs its own distinct mutation inside its own window.
|
|
223
|
+
// Checking only the earliest start would let mutations spent before a later
|
|
224
|
+
// task began vouch for it. Sorted by window start, a claim can only be
|
|
225
|
+
// satisfied if the window from that point still holds at least one mutation
|
|
226
|
+
// per remaining claim; the first failure marks an infeasible suffix, since
|
|
227
|
+
// every task after it has an equal or narrower window.
|
|
228
|
+
claimed.sort((a, b) => a.start - b.start);
|
|
229
|
+
for (let index = 0; index < claimed.length; index++) {
|
|
230
|
+
const remainingClaims = claimed.length - index;
|
|
231
|
+
if (mutationCount - claimed[index]!.start < remainingClaims) {
|
|
232
|
+
return claimed.slice(index).map((claim) => claim.task);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function readOnlyTodoCompletions(
|
|
239
|
+
previousTodos: TodoItem[] | undefined,
|
|
240
|
+
nextTodos: TodoItem[],
|
|
241
|
+
): string[] {
|
|
242
|
+
// Count prior occurrences by key and how many were already completed. An
|
|
243
|
+
// item carrying the marker is journaled when it is a new completion, which
|
|
244
|
+
// includes the common case of annotating an already-planned task.
|
|
245
|
+
const completedBefore = new Map<string, number>();
|
|
246
|
+
for (const todo of previousTodos ?? []) {
|
|
247
|
+
const key = todoTaskKey(todo);
|
|
248
|
+
if (!key) continue;
|
|
249
|
+
if (String(todo.status ?? "") === "completed") completedBefore.set(key, (completedBefore.get(key) ?? 0) + 1);
|
|
250
|
+
}
|
|
251
|
+
const readOnly: string[] = [];
|
|
252
|
+
for (const todo of nextTodos) {
|
|
253
|
+
if (String(todo.status ?? "") !== "completed" || !isReadOnlyTodo(todo)) continue;
|
|
254
|
+
const key = todoTaskKey(todo);
|
|
255
|
+
if (!key) continue;
|
|
256
|
+
const unchanged = completedBefore.get(key) ?? 0;
|
|
257
|
+
if (unchanged > 0) {
|
|
258
|
+
completedBefore.set(key, unchanged - 1);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
readOnly.push(todoTaskLabel(todo));
|
|
262
|
+
}
|
|
263
|
+
return readOnly;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function todoCompletionBlockReason(blocked: string[]): string {
|
|
267
|
+
const tasks = blocked.map((task) => `'${task}'`).join(", ");
|
|
268
|
+
return (
|
|
269
|
+
`Blocked todowrite: ${blocked.length === 1 ? "task" : "tasks"} ${tasks} marked completed without enough recorded work in the task window. ` +
|
|
270
|
+
"Complete the task's work first, then mark it completed. " +
|
|
271
|
+
"If the task was genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text and mark it completed. " +
|
|
272
|
+
"Use `cancelled` for work that was deliberately dropped."
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read-only view of a session's task evidence, for `guard_status`. Reports what
|
|
278
|
+
* the completion gate would decide now, so the requirement is visible before it
|
|
279
|
+
* blocks anything.
|
|
280
|
+
*/
|
|
281
|
+
export async function todoEvidenceReport(sessionID: string | undefined): Promise<{
|
|
282
|
+
active: number;
|
|
283
|
+
completed: number;
|
|
284
|
+
tasksWithoutRecordedWork: string[];
|
|
285
|
+
readOnlyTasks: string[];
|
|
286
|
+
}> {
|
|
287
|
+
if (!sessionID) return { active: 0, completed: 0, tasksWithoutRecordedWork: [], readOnlyTasks: [] };
|
|
288
|
+
const todos = await effectiveTodos(sessionID);
|
|
289
|
+
if (!todos) return { active: 0, completed: 0, tasksWithoutRecordedWork: [], readOnlyTasks: [] };
|
|
290
|
+
const starts = todoTaskStarts(sessionID);
|
|
291
|
+
const count = getSessionMutationCount(sessionID);
|
|
292
|
+
const active = todos.filter((todo) => ACTIVE_TODO_STATUSES.has(String(todo.status ?? "")));
|
|
293
|
+
const tasksWithoutRecordedWork: string[] = [];
|
|
294
|
+
const readOnlyTasks: string[] = [];
|
|
295
|
+
for (const todo of active) {
|
|
296
|
+
const label = todoTaskLabel(todo);
|
|
297
|
+
if (!label) continue;
|
|
298
|
+
if (isReadOnlyTodo(todo)) {
|
|
299
|
+
readOnlyTasks.push(label);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (count <= (starts.get(todoTaskKey(todo)) ?? 0)) tasksWithoutRecordedWork.push(label);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
active: active.length,
|
|
306
|
+
completed: todos.filter((todo) => String(todo.status ?? "") === "completed").length,
|
|
307
|
+
tasksWithoutRecordedWork,
|
|
308
|
+
readOnlyTasks,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
97
312
|
export function validateTodoLifecycle(
|
|
98
313
|
newTodos: TodoItem[],
|
|
99
314
|
existingTodos: TodoItem[] | undefined,
|
|
@@ -115,26 +330,26 @@ export function validateTodoLifecycle(
|
|
|
115
330
|
const isReviewObligation = (content: string) => reviewObligation.test(content) && !(remediationAction.test(content) && reviewReference.test(content) && remediationSubject.test(content));
|
|
116
331
|
const newContentCounts = new Map<string, number>();
|
|
117
332
|
for (const todo of newTodos) {
|
|
118
|
-
const content =
|
|
333
|
+
const content = todoTaskKey(todo);
|
|
119
334
|
newContentCounts.set(content, (newContentCounts.get(content) ?? 0) + 1);
|
|
120
335
|
}
|
|
121
336
|
const retainedReviewCounts = new Map<string, number>();
|
|
122
337
|
for (const todo of activeNew) {
|
|
123
|
-
const content =
|
|
338
|
+
const content = todoTaskKey(todo);
|
|
124
339
|
retainedReviewCounts.set(content, (retainedReviewCounts.get(content) ?? 0) + 1);
|
|
125
340
|
}
|
|
126
341
|
let retainedReviewObligations = 0;
|
|
127
342
|
for (const todo of activeExisting) {
|
|
128
|
-
const content =
|
|
343
|
+
const content = todoTaskKey(todo);
|
|
129
344
|
const remaining = retainedReviewCounts.get(content) ?? 0;
|
|
130
345
|
if (remaining > 0 && isReviewObligation(content)) {
|
|
131
346
|
retainedReviewCounts.set(content, remaining - 1);
|
|
132
347
|
retainedReviewObligations++;
|
|
133
348
|
}
|
|
134
349
|
}
|
|
135
|
-
let replacementReviewObligations = activeNew.filter((t) => isReviewObligation(
|
|
350
|
+
let replacementReviewObligations = activeNew.filter((t) => isReviewObligation(todoTaskKey(t))).length - retainedReviewObligations;
|
|
136
351
|
const missing = activeExisting.find((todo) => {
|
|
137
|
-
const content =
|
|
352
|
+
const content = todoTaskKey(todo);
|
|
138
353
|
const remaining = newContentCounts.get(content) ?? 0;
|
|
139
354
|
if (remaining === 0 && isReviewObligation(content) && replacementReviewObligations > 0) {
|
|
140
355
|
replacementReviewObligations--;
|
package/src/workflow-guard.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, re
|
|
|
22
22
|
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
23
23
|
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
24
24
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
25
|
-
import { guardToolCallImpl, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
|
|
25
|
+
import { guardToolCallImpl, isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
|
|
26
26
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
27
27
|
import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
|
|
28
28
|
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
resetVerifyState,
|
|
69
69
|
sessionMutationTimestamps,
|
|
70
70
|
sessionVerifyResults,
|
|
71
|
+
clearTodoSessionState,
|
|
71
72
|
getProjectConfig,
|
|
72
73
|
getLiveControlPlaneRoots,
|
|
73
74
|
getOperationProfile,
|
|
@@ -75,6 +76,9 @@ import {
|
|
|
75
76
|
isReviewRequired,
|
|
76
77
|
isSubagentReviewRequired,
|
|
77
78
|
isDocumentationRequired,
|
|
79
|
+
isLiveDocsRequired,
|
|
80
|
+
getLiveDocFailureThreshold,
|
|
81
|
+
getResearchToolPatterns,
|
|
78
82
|
getSubagentMutationBudget,
|
|
79
83
|
isLearningEnabled,
|
|
80
84
|
isProjectMemoryEnabled,
|
|
@@ -88,6 +92,7 @@ import {
|
|
|
88
92
|
getSessionWorkspace,
|
|
89
93
|
getSessionBoundWorkspace,
|
|
90
94
|
registerSessionWorkspaceFromToolCall,
|
|
95
|
+
resolveInheritedWorkspace,
|
|
91
96
|
resolveSessionBoundWorkspace,
|
|
92
97
|
resolveEffectiveWorkspace,
|
|
93
98
|
} from "./lib/state.ts";
|
|
@@ -112,6 +117,9 @@ export {
|
|
|
112
117
|
isReviewRequired,
|
|
113
118
|
isSubagentReviewRequired,
|
|
114
119
|
isDocumentationRequired,
|
|
120
|
+
isLiveDocsRequired,
|
|
121
|
+
getLiveDocFailureThreshold,
|
|
122
|
+
getResearchToolPatterns,
|
|
115
123
|
getSubagentMutationBudget,
|
|
116
124
|
isLearningEnabled,
|
|
117
125
|
getLearningInterventionBudget,
|
|
@@ -123,6 +131,7 @@ export {
|
|
|
123
131
|
getSessionWorkspace,
|
|
124
132
|
getSessionBoundWorkspace,
|
|
125
133
|
registerSessionWorkspaceFromToolCall,
|
|
134
|
+
resolveInheritedWorkspace,
|
|
126
135
|
resolveSessionBoundWorkspace,
|
|
127
136
|
resolveEffectiveWorkspace,
|
|
128
137
|
findGitRoot,
|
|
@@ -200,7 +209,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
|
|
|
200
209
|
: "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
|
|
201
210
|
}
|
|
202
211
|
|
|
203
|
-
export { loadedPluginVersion } from "./lib/version.ts";
|
|
212
|
+
export { loadedPluginVersion, qualifiedHostVersion } from "./lib/version.ts";
|
|
204
213
|
|
|
205
214
|
// ── Verification engine ──────────────────────────────────────────────────────
|
|
206
215
|
import {
|
|
@@ -327,6 +336,20 @@ import { checkCompletionClaims } from "./policies/completion.ts";
|
|
|
327
336
|
|
|
328
337
|
export { checkCompletionClaims };
|
|
329
338
|
|
|
339
|
+
import { checkSweepingClaims } from "./policies/sweeping-claims.ts";
|
|
340
|
+
|
|
341
|
+
export { checkSweepingClaims };
|
|
342
|
+
|
|
343
|
+
import {
|
|
344
|
+
clearLiveDocsFloor,
|
|
345
|
+
isResearchTool,
|
|
346
|
+
liveDocsGatingReason,
|
|
347
|
+
recordLiveDocsFailure,
|
|
348
|
+
recordResearchLookup,
|
|
349
|
+
} from "./policies/live-docs.ts";
|
|
350
|
+
|
|
351
|
+
export { isResearchTool, liveDocsGatingReason };
|
|
352
|
+
|
|
330
353
|
export { sendDesktopNotification } from "./policies/shell-safety.ts";
|
|
331
354
|
export { checkInteractiveTtyCommand, checkPackageHygiene, escapeAppleScriptString };
|
|
332
355
|
|
|
@@ -338,6 +361,14 @@ function logObservation(client: unknown, message: string): void {
|
|
|
338
361
|
} catch {}
|
|
339
362
|
}
|
|
340
363
|
|
|
364
|
+
function logWarn(client: unknown, message: string): void {
|
|
365
|
+
try {
|
|
366
|
+
void (client as TodoSdkClient | undefined)?.app?.log?.({
|
|
367
|
+
body: { service: "workflow-guard", level: "warn", message },
|
|
368
|
+
});
|
|
369
|
+
} catch {}
|
|
370
|
+
}
|
|
371
|
+
|
|
341
372
|
/**
|
|
342
373
|
* Public guard entry point. Audits every decision (block or allow) to a
|
|
343
374
|
* durable JSONL file before returning.
|
|
@@ -487,12 +518,43 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
487
518
|
: "";
|
|
488
519
|
throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
|
|
489
520
|
}
|
|
521
|
+
// Policy 25: after repeated failures with no live lookup since,
|
|
522
|
+
// block the guess-and-retry surfaces (mutations and shell) until
|
|
523
|
+
// the session grounds itself in current documentation. Research
|
|
524
|
+
// tools are never gated; an allowed research call records the
|
|
525
|
+
// lookup after it succeeds.
|
|
526
|
+
if (input.sessionID && isMutatingSurfaceTool(input.tool)) {
|
|
527
|
+
const threshold = getLiveDocFailureThreshold(toolWorktree);
|
|
528
|
+
if (isLiveDocsRequired(toolWorktree)) {
|
|
529
|
+
const researchReason = liveDocsGatingReason(input.sessionID, input.tool, threshold, getResearchToolPatterns(toolWorktree));
|
|
530
|
+
if (researchReason !== undefined) {
|
|
531
|
+
await emitBlockFeedback(researchReason);
|
|
532
|
+
logWarn(ctx.client, `[workflow-guard] ${researchReason}`);
|
|
533
|
+
audit({
|
|
534
|
+
ts: new Date().toISOString(),
|
|
535
|
+
sessionID: input.sessionID,
|
|
536
|
+
callID: input.callID,
|
|
537
|
+
tool: input.tool,
|
|
538
|
+
decision: "block",
|
|
539
|
+
phase: "decision",
|
|
540
|
+
reason: "live_docs_research_floor",
|
|
541
|
+
});
|
|
542
|
+
throw new Error(`[workflow-guard] ${researchReason}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
490
546
|
toolLifecycle.start(input.sessionID, input.callID);
|
|
491
547
|
if (input.sessionID) {
|
|
492
548
|
const record = asRecord(args);
|
|
493
549
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
494
550
|
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
495
|
-
registerSessionWorkspaceFromToolCall(input.sessionID, {
|
|
551
|
+
await registerSessionWorkspaceFromToolCall(input.sessionID, {
|
|
552
|
+
workdir,
|
|
553
|
+
filePath,
|
|
554
|
+
hostWorktree,
|
|
555
|
+
toolWorktree,
|
|
556
|
+
mutating: EDIT_TOOL_NAMES.has(input.tool) || SHELL_TOOL_NAMES.has(input.tool),
|
|
557
|
+
});
|
|
496
558
|
}
|
|
497
559
|
if (input.tool === "read") {
|
|
498
560
|
const target = editTargets(args, toolWorktree)[0];
|
|
@@ -613,7 +675,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
613
675
|
priorityContextBlocks.push(
|
|
614
676
|
`## Active Tasks${attribution}\n` +
|
|
615
677
|
lines.join("\n") +
|
|
616
|
-
"\nComplete tasks efficiently
|
|
678
|
+
"\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.",
|
|
617
679
|
);
|
|
618
680
|
}
|
|
619
681
|
|
|
@@ -718,6 +780,20 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
718
780
|
input: { claim: check.claim, evidenceState: check.evidenceState },
|
|
719
781
|
});
|
|
720
782
|
}
|
|
783
|
+
// Policy 26: journal sweeping repository claims unsupported by
|
|
784
|
+
// read coverage. Observability only - never mutates output.text.
|
|
785
|
+
const sweeping = checkSweepingClaims(text, { sessionID });
|
|
786
|
+
if (sweeping.sweeping && sweeping.reason) {
|
|
787
|
+
logObservation(ctx.client, `[workflow-guard] sweeping claim mismatch: ${sweeping.reason}`);
|
|
788
|
+
audit({
|
|
789
|
+
ts: new Date().toISOString(),
|
|
790
|
+
sessionID,
|
|
791
|
+
tool: "experimental.text.complete",
|
|
792
|
+
decision: "allow",
|
|
793
|
+
reason: `Sweeping claim '${sweeping.claim}' with ${sweeping.readFileCount} file(s) read`,
|
|
794
|
+
input: { claim: sweeping.claim, readFileCount: sweeping.readFileCount },
|
|
795
|
+
});
|
|
796
|
+
}
|
|
721
797
|
} catch {}
|
|
722
798
|
},
|
|
723
799
|
|
|
@@ -731,7 +807,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
731
807
|
if (description.includes("verification evidence")) return;
|
|
732
808
|
output.description =
|
|
733
809
|
description +
|
|
734
|
-
"\n\nWorkflow Guard lifecycle:
|
|
810
|
+
"\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.";
|
|
735
811
|
return;
|
|
736
812
|
}
|
|
737
813
|
if (EDIT_TOOL_NAMES.has(input.toolID)) {
|
|
@@ -784,6 +860,17 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
784
860
|
if (event?.type === "message.part.updated") {
|
|
785
861
|
const outcome = toolOutcomes.record((event.properties as { part?: ToolOutcomePart })?.part ?? {});
|
|
786
862
|
if (outcome) {
|
|
863
|
+
// Policy 25 research floor: a failed call accrues debt; a
|
|
864
|
+
// successful live-source lookup clears it. Unrelated
|
|
865
|
+
// successes intentionally do not clear it. A research
|
|
866
|
+
// attempt never accrues debt even when it fails: otherwise
|
|
867
|
+
// a flaky network would deepen the block the agent is
|
|
868
|
+
// already trying to escape, and the floor would deadlock.
|
|
869
|
+
if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
|
|
870
|
+
if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
|
|
871
|
+
} else if (outcome.status === "error") {
|
|
872
|
+
recordLiveDocsFailure(outcome.sessionID);
|
|
873
|
+
}
|
|
787
874
|
audit({
|
|
788
875
|
ts: new Date().toISOString(),
|
|
789
876
|
sessionID: outcome.sessionID,
|
|
@@ -810,7 +897,10 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
810
897
|
}
|
|
811
898
|
if (event?.type === "session.deleted") {
|
|
812
899
|
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
813
|
-
if (typeof sessionID === "string")
|
|
900
|
+
if (typeof sessionID === "string") {
|
|
901
|
+
toolOutcomes.clearSession(sessionID);
|
|
902
|
+
clearTodoSessionState(sessionID);
|
|
903
|
+
}
|
|
814
904
|
}
|
|
815
905
|
if (event?.type === "session.idle") {
|
|
816
906
|
const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
|
|
@@ -853,6 +943,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
853
943
|
clearReadFingerprints(sessionID);
|
|
854
944
|
toolLifecycle.clearSession(sessionID);
|
|
855
945
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
946
|
+
clearLiveDocsFloor(sessionID);
|
|
856
947
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
|
|
857
948
|
}
|
|
858
949
|
}
|
package/test/test-e2e.mts
CHANGED
|
@@ -11,8 +11,13 @@ const check = (name: string, cond: unknown): boolean => {
|
|
|
11
11
|
cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
|
|
12
12
|
return Boolean(cond);
|
|
13
13
|
};
|
|
14
|
+
// A live probe can only produce a guard verdict if the model actually ran.
|
|
15
|
+
// These are host/provider errors that mean it did not: the prompt never
|
|
16
|
+
// reached a model, so scoring the probe as a guard failure would be a false
|
|
17
|
+
// accusation. Match the host's literal error shapes, not the bare phrase, so
|
|
18
|
+
// model prose cannot mask a real guard regression.
|
|
14
19
|
const providerUnavailable = (output: string): boolean =>
|
|
15
|
-
/available credits|insufficient credits|rate limit|too many requests|capacity|overloaded
|
|
20
|
+
/available credits|insufficient credits|rate limit|too many requests|capacity|overloaded|Model unavailable:/i.test(output);
|
|
16
21
|
const checkLive = (name: string, cond: unknown, output: string): boolean => {
|
|
17
22
|
if (!cond && providerUnavailable(output)) {
|
|
18
23
|
unavailable++;
|
|
@@ -45,11 +50,11 @@ const packageEntry = join(testDir, "node_modules", "opencode-workflow-guard", "s
|
|
|
45
50
|
const installedPackageDir = join(testDir, "node_modules", "opencode-workflow-guard");
|
|
46
51
|
const installedPackageJson = JSON.parse(readFileSync(join(testDir, "node_modules", "opencode-workflow-guard", "package.json"), "utf8"));
|
|
47
52
|
check(
|
|
48
|
-
"npm package
|
|
53
|
+
"npm package pins every direct dependency to an exact version",
|
|
49
54
|
Object.keys(installedPackageJson.dependencies ?? {}).length > 0 &&
|
|
50
|
-
Object.values(installedPackageJson.dependencies).every((spec) => spec
|
|
55
|
+
Object.values(installedPackageJson.dependencies).every((spec) => /^\d+\.\d+\.\d+$/.test(String(spec))) &&
|
|
51
56
|
Object.keys(installedPackageJson.devDependencies ?? {}).length > 0 &&
|
|
52
|
-
Object.values(installedPackageJson.devDependencies).every((spec) => spec
|
|
57
|
+
Object.values(installedPackageJson.devDependencies).every((spec) => /^\d+\.\d+\.\d+$/.test(String(spec))),
|
|
53
58
|
);
|
|
54
59
|
check(
|
|
55
60
|
"npm tarball installs modular plugin entrypoint",
|
|
@@ -226,6 +231,7 @@ const runV2Probes = async (ctx: any) => {
|
|
|
226
231
|
\t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
|
|
227
232
|
\t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
|
|
228
233
|
\t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
|
|
234
|
+
\t\t"todo-completion": { tool: "todowrite", input: { todos: [{ content: "unstarted e2e work", status: "completed", priority: "medium" }] } },
|
|
229
235
|
\t};
|
|
230
236
|
\tconst whyResults: Record<string, any> = {};
|
|
231
237
|
\tfor (const [name, probe] of Object.entries(whyProbes)) {
|
|
@@ -253,6 +259,7 @@ const runV1Probes = async (ctx: any) => {
|
|
|
253
259
|
\t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
|
|
254
260
|
\t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
|
|
255
261
|
\t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
|
|
262
|
+
\t\t"todo-completion": { tool: "todowrite", input: { todos: [{ content: "unstarted e2e work", status: "completed", priority: "medium" }] } },
|
|
256
263
|
\t};
|
|
257
264
|
\tconst whyResults: Record<string, any> = {};
|
|
258
265
|
\tfor (const [name, probe] of Object.entries(whyProbes)) {
|
|
@@ -322,8 +329,11 @@ if (isOpenCodeV2) {
|
|
|
322
329
|
const servicePort = 49400 + (process.pid % 1000);
|
|
323
330
|
spawnSync("opencode", ["service", "set", "port", String(servicePort)], { cwd: testDir, encoding: "utf8", env: runtimeEnv, timeout: 30_000 });
|
|
324
331
|
}
|
|
332
|
+
// `run` takes its directory from the process cwd; 2.0.15 rejects `--dir` as an
|
|
333
|
+
// unknown flag, which made every live probe report a flag error instead of a
|
|
334
|
+
// guard verdict.
|
|
325
335
|
const runOpenCode = (args: string[], timeout: number) =>
|
|
326
|
-
spawnSync("opencode", ["run",
|
|
336
|
+
spawnSync("opencode", ["run", ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
|
|
327
337
|
const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
|
|
328
338
|
const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: runtimeEnv });
|
|
329
339
|
let stdout = "";
|
|
@@ -401,6 +411,9 @@ check("headless guard_why simulates secret read policy via read tool", whyProbe(
|
|
|
401
411
|
check("headless guard_why simulates secret read policy via shell", whyProbe("secrets-shell")?.policy === "secrets" && whyProbe("secrets-shell")?.code === "secret_read" && whyProbe("secrets-shell")?.status === "blocked");
|
|
402
412
|
check("headless guard_why simulates workspace boundary policy via write tool", whyProbe("boundary-write")?.policy === "boundary" && whyProbe("boundary-write")?.code === "workspace_escape" && whyProbe("boundary-write")?.status === "blocked");
|
|
403
413
|
check("headless guard_why simulates interactive tty shell-safety policy", whyProbe("shell-tty")?.policy === "shell-safety" && whyProbe("shell-tty")?.code === "interactive_tty" && whyProbe("shell-tty")?.status === "blocked");
|
|
414
|
+
// The completion gate is host-version independent: claiming work the session
|
|
415
|
+
// never did is blocked through the real dispatcher, on both adapter paths.
|
|
416
|
+
check("headless guard_why simulates the todo completion evidence gate", whyProbe("todo-completion")?.policy === "todo" && whyProbe("todo-completion")?.code === "todo_completion_unverified" && whyProbe("todo-completion")?.status === "blocked");
|
|
404
417
|
check("headless guard_status reports project config loaded from .opencode/workflow-guard.json", headlessAccountability?.status?.projectConfig?.protectedBranches?.[0] === "e2e-config-probe-branch" && headlessAccountability?.status?.projectConfig?.requireReview === false);
|
|
405
418
|
if (isOpenCodeV2) {
|
|
406
419
|
// The V2 adapter runs the shipped setup(ctx) for real; verify the
|
|
@@ -421,6 +434,78 @@ if (isOpenCodeV2) {
|
|
|
421
434
|
console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
|
|
422
435
|
}
|
|
423
436
|
|
|
437
|
+
// The todo capability probe is only meaningful against a host that can gate
|
|
438
|
+
// the tool. Probe the flag rather than the version: a host that does not know
|
|
439
|
+
// `experimental.todo` keeps the capability unavailable, which is the branch
|
|
440
|
+
// the main pass already covers.
|
|
441
|
+
const hostMinor = Number(opencodeSemver.split(".")[1] ?? 0);
|
|
442
|
+
if (isOpenCodeV2 && (opencodeMajor > 2 || (opencodeMajor === 2 && hostMinor >= 1))) {
|
|
443
|
+
const todoConfig = join(testDir, ".opencode", "opencode.json");
|
|
444
|
+
const previousConfig = readFileSync(todoConfig, "utf8");
|
|
445
|
+
const previousAccountability = readFileSync(accountabilityMarker, "utf8");
|
|
446
|
+
rmSync(initializedMarker, { force: true });
|
|
447
|
+
// A second pass needs its own XDG roots and service port. Reusing the first
|
|
448
|
+
// pass's state dirs reconnects to its already-running service, which serves
|
|
449
|
+
// the config it started with and never re-runs plugin setup.
|
|
450
|
+
const todoEnv: NodeJS.ProcessEnv = {
|
|
451
|
+
...runtimeEnv,
|
|
452
|
+
XDG_CONFIG_HOME: join(testDir, "runtime-config-todo"),
|
|
453
|
+
XDG_STATE_HOME: join(testDir, "runtime-state-todo"),
|
|
454
|
+
XDG_DATA_HOME: join(testDir, "runtime-data-todo"),
|
|
455
|
+
};
|
|
456
|
+
const todoPort = 49400 + ((process.pid + 500) % 1000);
|
|
457
|
+
spawnSync("opencode", ["service", "set", "port", String(todoPort)], { cwd: testDir, encoding: "utf8", env: todoEnv, timeout: 30_000 });
|
|
458
|
+
try {
|
|
459
|
+
const parsed = JSON.parse(previousConfig);
|
|
460
|
+
writeFileSync(todoConfig, `${JSON.stringify({ ...parsed, experimental: { ...(parsed.experimental ?? {}), todo: true } }, null, 2)}\n`);
|
|
461
|
+
const todoProbe = await new Promise<{ status: number | null }>((resolve) => {
|
|
462
|
+
const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: todoEnv });
|
|
463
|
+
let timer: NodeJS.Timeout | undefined;
|
|
464
|
+
const poll = setInterval(() => {
|
|
465
|
+
if (existsSync(initializedMarker)) {
|
|
466
|
+
clearInterval(poll);
|
|
467
|
+
if (timer) clearTimeout(timer);
|
|
468
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
469
|
+
resolve({ status: 0 });
|
|
470
|
+
}
|
|
471
|
+
}, 50);
|
|
472
|
+
timer = setTimeout(() => {
|
|
473
|
+
clearInterval(poll);
|
|
474
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
475
|
+
resolve({ status: null });
|
|
476
|
+
}, 30_000);
|
|
477
|
+
child.on("error", () => {
|
|
478
|
+
clearInterval(poll);
|
|
479
|
+
if (timer) clearTimeout(timer);
|
|
480
|
+
resolve({ status: null });
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
let flagged: any;
|
|
484
|
+
try {
|
|
485
|
+
flagged = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
|
|
486
|
+
} catch {}
|
|
487
|
+
// Flag on: the host registry exposes todowrite, so the guard reports the
|
|
488
|
+
// capability available and the gate stops failing open.
|
|
489
|
+
check(
|
|
490
|
+
"V2 todo capability reports available when experimental.todo is enabled",
|
|
491
|
+
todoProbe.status === 0 &&
|
|
492
|
+
(flagged?.tools ?? []).includes("todowrite") &&
|
|
493
|
+
flagged?.status?.todoCapability === "available",
|
|
494
|
+
);
|
|
495
|
+
// The main pass ran with the flag off, so the capability there must be
|
|
496
|
+
// unavailable. Together the two passes prove the probe reads the
|
|
497
|
+
// registry instead of inferring from history.
|
|
498
|
+
check(
|
|
499
|
+
"V2 todo capability reports unavailable when experimental.todo is disabled",
|
|
500
|
+
(headlessAccountability?.tools ?? []).includes("todowrite") === false &&
|
|
501
|
+
headlessAccountability?.status?.todoCapability === "unavailable",
|
|
502
|
+
);
|
|
503
|
+
} finally {
|
|
504
|
+
writeFileSync(todoConfig, previousConfig);
|
|
505
|
+
writeFileSync(accountabilityMarker, previousAccountability);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
424
509
|
if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
|
|
425
510
|
console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
|
|
426
511
|
if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
|
|
@@ -450,7 +535,7 @@ console.log(` Using live model: ${liveModel}`);
|
|
|
450
535
|
|
|
451
536
|
// 3. Test: Direct edit without task list is blocked by the loaded plugin
|
|
452
537
|
console.log(" Running live OpenCode prompt to verify plugin intercept...");
|
|
453
|
-
const run1 = runOpenCode(["--model", liveModel, "--print-logs", "--log-level", "
|
|
538
|
+
const run1 = runOpenCode(["--model", liveModel, "--print-logs", "--log-level", "debug", "Do not call todowrite. Invoke bash immediately with exactly this command: printf blocked > blocked.txt. This is a guard test; make the tool call even if you expect rejection."], 120_000);
|
|
454
539
|
|
|
455
540
|
const output1 = run1.stdout + run1.stderr;
|
|
456
541
|
const adapterInitialized = existsSync(initializedMarker);
|