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
package/src/lib/worktree.ts
CHANGED
|
@@ -197,6 +197,40 @@ export function createGitWorktree(
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Validate a worktree directory the HOST created before the guard accepts it.
|
|
202
|
+
*
|
|
203
|
+
* The host is a widening authority only in the sense that it may choose the
|
|
204
|
+
* directory (its configured `worktree.directory`). It is never an authority
|
|
205
|
+
* that the directory is safe: the guard re-checks the filesystem and the git
|
|
206
|
+
* registration itself, and refuses the primary working tree. A host adapter
|
|
207
|
+
* that returns a bogus path therefore fails closed.
|
|
208
|
+
*/
|
|
209
|
+
export function validateHostWorktreeDirectory(
|
|
210
|
+
directory: string,
|
|
211
|
+
root = getWorkspaceRoot(),
|
|
212
|
+
): { ok: boolean; error?: string } {
|
|
213
|
+
if (!directory || typeof directory !== "string") {
|
|
214
|
+
return { ok: false, error: "Host worktree create returned no directory." };
|
|
215
|
+
}
|
|
216
|
+
const resolved = resolve(directory);
|
|
217
|
+
if (!existsSync(resolved)) {
|
|
218
|
+
return { ok: false, error: `Host declared worktree '${directory}' does not exist.` };
|
|
219
|
+
}
|
|
220
|
+
const registered = registeredWorktreePaths(root);
|
|
221
|
+
if (!registered) {
|
|
222
|
+
return { ok: false, error: "Failed to list registered worktrees - cannot validate the host worktree." };
|
|
223
|
+
}
|
|
224
|
+
const resolvedRegistered = registered.map((path) => resolve(path));
|
|
225
|
+
if (!resolvedRegistered.includes(resolved)) {
|
|
226
|
+
return { ok: false, error: `Host declared worktree '${directory}' is not a registered worktree of this repository.` };
|
|
227
|
+
}
|
|
228
|
+
if (resolvedRegistered[0] === resolved) {
|
|
229
|
+
return { ok: false, error: "Host declared the primary working tree as a worktree - refusing." };
|
|
230
|
+
}
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
233
|
+
|
|
200
234
|
function registeredWorktreePaths(root: string): string[] | undefined {
|
|
201
235
|
const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
202
236
|
cwd: root,
|
|
@@ -215,23 +249,37 @@ function registeredWorktreePaths(root: string): string[] | undefined {
|
|
|
215
249
|
export function cleanupGitWorktree(
|
|
216
250
|
worktreePath: string,
|
|
217
251
|
root = getWorkspaceRoot(),
|
|
252
|
+
options?: {
|
|
253
|
+
/**
|
|
254
|
+
* Directories the HOST declares as its worktrees for this project
|
|
255
|
+
* (`ctx.worktree.list`). This is the only way cleanup may reach a
|
|
256
|
+
* worktree outside the guard's own storage directory: the host is the
|
|
257
|
+
* authority that the directory belongs to its inventory, and the
|
|
258
|
+
* registration and primary-tree checks below still apply. Omitting the
|
|
259
|
+
* list keeps the original storage-only behaviour.
|
|
260
|
+
*/
|
|
261
|
+
hostWorktrees?: readonly string[];
|
|
262
|
+
},
|
|
218
263
|
): { success: boolean; error?: string } {
|
|
219
264
|
try {
|
|
220
265
|
const resolved = resolve(worktreePath);
|
|
221
266
|
const storageBase = resolve(getWorktreeStorageDir(root));
|
|
267
|
+
const hostOwned = Boolean(options?.hostWorktrees?.some((path) => resolve(path) === resolved));
|
|
268
|
+
const withinStorage = resolved === storageBase || resolved.startsWith(storageBase + sep);
|
|
269
|
+
if (!withinStorage && !hostOwned) {
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}) and is not a host-declared worktree.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
222
275
|
if (!existsSync(worktreePath)) {
|
|
223
276
|
// A registered worktree whose directory is gone (manual removal, a
|
|
224
277
|
// crashed add, external cleanup) still holds a stale admin entry that
|
|
225
278
|
// keeps it in `git worktree list` as prunable, and must still be
|
|
226
|
-
// cleanable. Anything outside storage
|
|
227
|
-
// The primary working tree is never at
|
|
228
|
-
// admin dirs under .git/worktrees/<id>,
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
success: false,
|
|
232
|
-
error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
279
|
+
// cleanable. Anything outside storage (unless the host declares it)
|
|
280
|
+
// or unregistered is refused. The primary working tree is never at
|
|
281
|
+
// risk: prune only removes admin dirs under .git/worktrees/<id>,
|
|
282
|
+
// which the main tree lacks.
|
|
235
283
|
const registeredMissing = registeredWorktreePaths(root);
|
|
236
284
|
if (!registeredMissing) {
|
|
237
285
|
return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
|
|
@@ -252,15 +300,10 @@ export function cleanupGitWorktree(
|
|
|
252
300
|
}
|
|
253
301
|
|
|
254
302
|
// Ownership validation: the path must live under the configured worktree
|
|
255
|
-
// storage directory
|
|
256
|
-
// refuses arbitrary directories, other
|
|
257
|
-
// working tree - cleanup must never be
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
success: false,
|
|
261
|
-
error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
303
|
+
// storage directory (or be declared by the host) AND be a registered
|
|
304
|
+
// worktree of this repository. This refuses arbitrary directories, other
|
|
305
|
+
// repos' worktrees, and the primary working tree - cleanup must never be
|
|
306
|
+
// an unrestricted delete primitive.
|
|
264
307
|
const registered = registeredWorktreePaths(root);
|
|
265
308
|
if (!registered) {
|
|
266
309
|
return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
|
package/src/policies/mcp.ts
CHANGED
|
@@ -3,7 +3,13 @@ export const MCP_MUTATION_VERB_RE =
|
|
|
3
3
|
export const MCP_READ_ONLY_RE =
|
|
4
4
|
/(?:^|_)(?:get|list|search|show|query|describe|find|read|status|diff|log)(?:_|$)/;
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Static guarded live-system server tokens.
|
|
8
|
+
*
|
|
9
|
+
* These stay in force regardless of host inventory, because tool names are
|
|
10
|
+
* model-supplied text and the MCP policy is a live-system rail.
|
|
11
|
+
*/
|
|
12
|
+
export const GUARDED_MCP_SERVERS: ReadonlySet<string> = new Set([
|
|
7
13
|
"github",
|
|
8
14
|
"gh",
|
|
9
15
|
"azure",
|
|
@@ -13,12 +19,51 @@ export const GUARDED_MCP_SERVERS = new Set([
|
|
|
13
19
|
"azuredevops",
|
|
14
20
|
]);
|
|
15
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Tokens learned from the HOST's MCP inventory (`ctx.mcp.list()`), offered by
|
|
24
|
+
* `lib/mcp-inventory.ts` when a server connects or its tools change.
|
|
25
|
+
*
|
|
26
|
+
* Widen-only. A server the operator removed contributes nothing new, but the
|
|
27
|
+
* static defaults above are never narrowed, so removing a server cannot
|
|
28
|
+
* unguard a tool that still names it. Empty on hosts without the API, which
|
|
29
|
+
* leaves behaviour identical to the static list.
|
|
30
|
+
*
|
|
31
|
+
* Held on `globalThis` via `Symbol.for` because OpenCode can load the same
|
|
32
|
+
* package from two distinct module paths (a cache copy plus a source
|
|
33
|
+
* checkout), and each copy would otherwise get its own set.
|
|
34
|
+
*/
|
|
35
|
+
const HOST_GUARDED_MCP_TOKENS = Symbol.for("opencode-workflow-guard.mcp-tokens.v1");
|
|
36
|
+
const tokenRegistry = globalThis as unknown as Record<symbol, Set<string> | undefined>;
|
|
37
|
+
const hostGuardedMcpTokens: Set<string> =
|
|
38
|
+
tokenRegistry[HOST_GUARDED_MCP_TOKENS] ?? (tokenRegistry[HOST_GUARDED_MCP_TOKENS] = new Set());
|
|
39
|
+
|
|
40
|
+
/** Register host-declared server tokens. Returns the tokens that were new. */
|
|
41
|
+
export function addGuardedMcpTokens(tokens: Iterable<string>): string[] {
|
|
42
|
+
const added: string[] = [];
|
|
43
|
+
for (const token of tokens) {
|
|
44
|
+
const normalized = String(token).toLowerCase().trim();
|
|
45
|
+
if (!normalized || GUARDED_MCP_SERVERS.has(normalized) || hostGuardedMcpTokens.has(normalized)) continue;
|
|
46
|
+
hostGuardedMcpTokens.add(normalized);
|
|
47
|
+
added.push(normalized);
|
|
48
|
+
}
|
|
49
|
+
return added;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resetGuardedMcpTokensForTesting(): void {
|
|
53
|
+
hostGuardedMcpTokens.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Effective guarded token set: static defaults plus host-declared widening. */
|
|
57
|
+
export function guardedMcpServers(): ReadonlySet<string> {
|
|
58
|
+
return new Set([...GUARDED_MCP_SERVERS, ...hostGuardedMcpTokens]);
|
|
59
|
+
}
|
|
60
|
+
|
|
16
61
|
export function mcpMutationTool(toolName: string): string | undefined {
|
|
17
62
|
if (!MCP_MUTATION_VERB_RE.test(toolName) || MCP_READ_ONLY_RE.test(toolName)) {
|
|
18
63
|
return undefined;
|
|
19
64
|
}
|
|
20
65
|
const tokens = toolName.toLowerCase().split(/[^a-z0-9]+/);
|
|
21
|
-
if (tokens.some((t) => GUARDED_MCP_SERVERS.has(t))) {
|
|
66
|
+
if (tokens.some((t) => GUARDED_MCP_SERVERS.has(t) || hostGuardedMcpTokens.has(t))) {
|
|
22
67
|
return "GitHub/Azure DevOps";
|
|
23
68
|
}
|
|
24
69
|
return undefined;
|
package/src/policies/tamper.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const PROTECTED_PATH_REASON =
|
|
|
11
11
|
"these manually in configuration or the UI.";
|
|
12
12
|
|
|
13
13
|
const GT = String.fromCharCode(62);
|
|
14
|
-
const V_LIST = ["sed\\s+-i", "tee", "mv", "cp", "rm", "chmod", "chown", "ln", "install", "truncate", "dd"].join("|");
|
|
14
|
+
const V_LIST = ["sed\\s+-i", "tee", "mv", "cp", "rm", "chmod", "chown", "ln", "install", "truncate", "dd", "patch", "perl\\s+-[^\\s]*i"].join("|");
|
|
15
15
|
const C_LIST = ["open" + "code\\.jsonc?", "\\.config\\/open" + "code", "\\.open" + "code\\/"].join("|");
|
|
16
16
|
const U_LIST = ["\\.config\\/open" + "code\\/(?:plugins|ui)\\/"].join("|");
|
|
17
17
|
|
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
|
@@ -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,
|
|
@@ -88,6 +89,7 @@ import {
|
|
|
88
89
|
getSessionWorkspace,
|
|
89
90
|
getSessionBoundWorkspace,
|
|
90
91
|
registerSessionWorkspaceFromToolCall,
|
|
92
|
+
resolveInheritedWorkspace,
|
|
91
93
|
resolveSessionBoundWorkspace,
|
|
92
94
|
resolveEffectiveWorkspace,
|
|
93
95
|
} from "./lib/state.ts";
|
|
@@ -123,6 +125,7 @@ export {
|
|
|
123
125
|
getSessionWorkspace,
|
|
124
126
|
getSessionBoundWorkspace,
|
|
125
127
|
registerSessionWorkspaceFromToolCall,
|
|
128
|
+
resolveInheritedWorkspace,
|
|
126
129
|
resolveSessionBoundWorkspace,
|
|
127
130
|
resolveEffectiveWorkspace,
|
|
128
131
|
findGitRoot,
|
|
@@ -200,7 +203,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
|
|
|
200
203
|
: "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
|
|
201
204
|
}
|
|
202
205
|
|
|
203
|
-
export { loadedPluginVersion } from "./lib/version.ts";
|
|
206
|
+
export { loadedPluginVersion, qualifiedHostVersion } from "./lib/version.ts";
|
|
204
207
|
|
|
205
208
|
// ── Verification engine ──────────────────────────────────────────────────────
|
|
206
209
|
import {
|
|
@@ -492,7 +495,13 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
492
495
|
const record = asRecord(args);
|
|
493
496
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
494
497
|
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
495
|
-
registerSessionWorkspaceFromToolCall(input.sessionID, {
|
|
498
|
+
await registerSessionWorkspaceFromToolCall(input.sessionID, {
|
|
499
|
+
workdir,
|
|
500
|
+
filePath,
|
|
501
|
+
hostWorktree,
|
|
502
|
+
toolWorktree,
|
|
503
|
+
mutating: EDIT_TOOL_NAMES.has(input.tool) || SHELL_TOOL_NAMES.has(input.tool),
|
|
504
|
+
});
|
|
496
505
|
}
|
|
497
506
|
if (input.tool === "read") {
|
|
498
507
|
const target = editTargets(args, toolWorktree)[0];
|
|
@@ -613,7 +622,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
613
622
|
priorityContextBlocks.push(
|
|
614
623
|
`## Active Tasks${attribution}\n` +
|
|
615
624
|
lines.join("\n") +
|
|
616
|
-
"\nComplete tasks efficiently
|
|
625
|
+
"\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
626
|
);
|
|
618
627
|
}
|
|
619
628
|
|
|
@@ -731,7 +740,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
731
740
|
if (description.includes("verification evidence")) return;
|
|
732
741
|
output.description =
|
|
733
742
|
description +
|
|
734
|
-
"\n\nWorkflow Guard lifecycle:
|
|
743
|
+
"\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
744
|
return;
|
|
736
745
|
}
|
|
737
746
|
if (EDIT_TOOL_NAMES.has(input.toolID)) {
|
|
@@ -810,7 +819,10 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
810
819
|
}
|
|
811
820
|
if (event?.type === "session.deleted") {
|
|
812
821
|
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
813
|
-
if (typeof sessionID === "string")
|
|
822
|
+
if (typeof sessionID === "string") {
|
|
823
|
+
toolOutcomes.clearSession(sessionID);
|
|
824
|
+
clearTodoSessionState(sessionID);
|
|
825
|
+
}
|
|
814
826
|
}
|
|
815
827
|
if (event?.type === "session.idle") {
|
|
816
828
|
const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
|