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/v2-todo.ts
CHANGED
|
@@ -2,12 +2,27 @@
|
|
|
2
2
|
* V2 todo reconstruction shared by the plugin entrypoint and its V1 SDK
|
|
3
3
|
* adapter.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Version boundary, because the two generations need different treatment:
|
|
6
|
+
*
|
|
7
|
+
* - **OpenCode 2.0.x** exposes no todo endpoint and its builtin tool registry
|
|
8
|
+
* has no `todowrite` (the V1 name). Todo state only exists when an ACP agent
|
|
9
|
+
* supplies its own `todowrite` tool, so the list is reconstructed from
|
|
10
|
+
* session message history.
|
|
11
|
+
* - **OpenCode 2.1.x** reintroduces a builtin `todowrite` behind the
|
|
12
|
+
* `experimental.todo` config key. When that tool is registered, a session
|
|
13
|
+
* with no todo part has never planned, which must gate mutations rather than
|
|
14
|
+
* fail open.
|
|
15
|
+
*
|
|
16
|
+
* Capability is therefore probed, not inferred from history: `detectTodoCapability`
|
|
17
|
+
* asks the host's tool registry (`ctx.tool.list()`) whether `todowrite` is
|
|
18
|
+
* registered. History tells you whether a session planned; the registry tells
|
|
19
|
+
* you whether planning was possible. Only the second question decides whether
|
|
20
|
+
* an empty list means "not yet planned" or "cannot plan".
|
|
21
|
+
*
|
|
22
|
+
* This module distinguishes "definitively empty" from "cannot determine", which
|
|
23
|
+
* the policy layer relies on to decide whether to enforce the active-todo gate.
|
|
24
|
+
* Todos are never evidence: a `todowrite` call replaces the whole list, so a
|
|
25
|
+
* model can mark everything completed without doing the work.
|
|
11
26
|
*/
|
|
12
27
|
|
|
13
28
|
import type { TodoItem } from "./types.ts";
|
|
@@ -19,6 +34,50 @@ export interface TodoScanContext {
|
|
|
19
34
|
};
|
|
20
35
|
}
|
|
21
36
|
|
|
37
|
+
/** Minimal tool-registry surface needed to probe for `todowrite`. */
|
|
38
|
+
export interface TodoCapabilityContext {
|
|
39
|
+
tool?: {
|
|
40
|
+
list?: () => Promise<unknown>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether this host can plan at all.
|
|
46
|
+
*
|
|
47
|
+
* - `available`: the registry reports `todowrite`, so an empty list means the
|
|
48
|
+
* session never planned and the gate must enforce.
|
|
49
|
+
* - `unavailable`: the registry answered and has no `todowrite`, so the gate
|
|
50
|
+
* must fail open or builtin-only sessions deadlock.
|
|
51
|
+
* - `unknown`: the registry could not be read. Treat as unavailable for
|
|
52
|
+
* enforcement, but say so out loud (the caller logs once per session).
|
|
53
|
+
*/
|
|
54
|
+
export type TodoCapability = "available" | "unavailable" | "unknown";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Probe the host's tool registry for `todowrite`.
|
|
58
|
+
*
|
|
59
|
+
* Tool ids on the host are effective names, so a namespaced or prefixed
|
|
60
|
+
* registration (`todowrite`, `todo.write`) still counts. A registry that throws
|
|
61
|
+
* or is absent reports `unknown` rather than `unavailable`, because "we could
|
|
62
|
+
* not ask" must never be reported as "the host said no".
|
|
63
|
+
*/
|
|
64
|
+
export async function detectTodoCapability(ctx: TodoCapabilityContext | undefined): Promise<TodoCapability> {
|
|
65
|
+
const list = ctx?.tool?.list;
|
|
66
|
+
if (typeof list !== "function") return "unknown";
|
|
67
|
+
try {
|
|
68
|
+
const entries = await list.call(ctx!.tool);
|
|
69
|
+
if (!Array.isArray(entries)) return "unknown";
|
|
70
|
+
return entries.some((entry) => {
|
|
71
|
+
const name = (entry as { name?: unknown; id?: unknown } | undefined)?.name ?? (entry as { id?: unknown } | undefined)?.id;
|
|
72
|
+
return typeof name === "string" && /^todowrite$/i.test(name);
|
|
73
|
+
})
|
|
74
|
+
? "available"
|
|
75
|
+
: "unavailable";
|
|
76
|
+
} catch {
|
|
77
|
+
return "unknown";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
22
81
|
/**
|
|
23
82
|
* V2 tool parts carry the tool name in `tool` (the V1 field was `name`); ACP
|
|
24
83
|
* agents may still report either shape, so accept both.
|
|
@@ -29,6 +88,66 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
|
|
|
29
88
|
return undefined;
|
|
30
89
|
}
|
|
31
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Proven capability per plugin instance, for reporting and for the fail-open
|
|
93
|
+
* warning. Held on `globalThis` via `Symbol.for` because OpenCode can load the
|
|
94
|
+
* same package from two module paths (a cache copy plus a source checkout), and
|
|
95
|
+
* the capability answer must agree between them.
|
|
96
|
+
*/
|
|
97
|
+
const TODO_CAPABILITY = Symbol.for("opencode-workflow-guard.todo-capability.v1");
|
|
98
|
+
const capabilityRegistry = globalThis as unknown as Record<symbol, TodoCapability | undefined>;
|
|
99
|
+
|
|
100
|
+
/** Last probed capability. `unknown` before setup, which fails open. */
|
|
101
|
+
export function recordedTodoCapability(): TodoCapability {
|
|
102
|
+
return capabilityRegistry[TODO_CAPABILITY] ?? "unknown";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Record the probed capability. Returns the previous value so the caller can
|
|
107
|
+
* tell whether this is the first probe for the process.
|
|
108
|
+
*/
|
|
109
|
+
export function recordTodoCapability(capability: TodoCapability): TodoCapability {
|
|
110
|
+
const previous = recordedTodoCapability();
|
|
111
|
+
capabilityRegistry[TODO_CAPABILITY] = capability;
|
|
112
|
+
return previous;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resetTodoCapabilityForTesting(): void {
|
|
116
|
+
delete capabilityRegistry[TODO_CAPABILITY];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Sessions already warned about the fail-open path. Bounded: the warning is
|
|
121
|
+
* once per session, and a process that outlives thousands of sessions must not
|
|
122
|
+
* leak a set for every one of them.
|
|
123
|
+
*/
|
|
124
|
+
const WARNED_SESSIONS = Symbol.for("opencode-workflow-guard.todo-warned.v2");
|
|
125
|
+
const warnedRegistry = globalThis as unknown as Record<symbol, Set<string> | undefined>;
|
|
126
|
+
const warnedSessions: Set<string> =
|
|
127
|
+
warnedRegistry[WARNED_SESSIONS] ?? (warnedRegistry[WARNED_SESSIONS] = new Set());
|
|
128
|
+
const MAX_WARNED_SESSIONS = 512;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Report the active-todo gate failing open, once per session.
|
|
132
|
+
*
|
|
133
|
+
* Silent fail-open is how a rails policy becomes decorative: the gate stops
|
|
134
|
+
* enforcing and nothing says so. Returns true when this call was the first for
|
|
135
|
+
* the session, so the caller can journal it exactly once.
|
|
136
|
+
*/
|
|
137
|
+
export function reportTodoGateFailOpen(sessionID: string | undefined, capability: TodoCapability): boolean {
|
|
138
|
+
const key = sessionID ?? "<no-session>";
|
|
139
|
+
if (warnedSessions.has(key)) return false;
|
|
140
|
+
if (warnedSessions.size >= MAX_WARNED_SESSIONS) warnedSessions.clear();
|
|
141
|
+
warnedSessions.add(key);
|
|
142
|
+
try {
|
|
143
|
+
console.warn(
|
|
144
|
+
`[workflow-guard] warn: active-todo gate is not enforcing for session ${key}: ` +
|
|
145
|
+
`todoCapability=${capability}. Mutations are allowed without an active todo.`,
|
|
146
|
+
);
|
|
147
|
+
} catch {}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
32
151
|
/**
|
|
33
152
|
* Reconstruct one session's current todo list from its message history. The
|
|
34
153
|
* newest applied `todowrite` tool call's input is the current state.
|
|
@@ -37,7 +156,13 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
|
|
|
37
156
|
* - `TodoItem[]` when a `todowrite` part defines the list, including `[]` when
|
|
38
157
|
* the newest applied call emptied it;
|
|
39
158
|
* - `undefined` when todo state is unknown: the history cannot be read, or the
|
|
40
|
-
* session contains no `todowrite` part
|
|
159
|
+
* session contains no `todowrite` part and the capability is absent.
|
|
160
|
+
*
|
|
161
|
+
* `capability` decides what an empty history means. When the host reports
|
|
162
|
+
* `available`, a session with no `todowrite` part has never planned, so the
|
|
163
|
+
* result is `[]` and the mutation gate enforces. When it is `unavailable` or
|
|
164
|
+
* `unknown`, an empty history stays `undefined` so builtin-only sessions fail
|
|
165
|
+
* open instead of deadlocking on a tool they cannot call.
|
|
41
166
|
*
|
|
42
167
|
* Callers must treat `undefined` as "cannot determine" (fail open) and `[]` as
|
|
43
168
|
* "definitively no active todos" (enforce). Collapsing the two is what
|
|
@@ -46,6 +171,7 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
|
|
|
46
171
|
export async function scanSessionTodos(
|
|
47
172
|
ctx: TodoScanContext,
|
|
48
173
|
sessionID: string,
|
|
174
|
+
capability: TodoCapability = "unknown",
|
|
49
175
|
): Promise<TodoItem[] | undefined> {
|
|
50
176
|
try {
|
|
51
177
|
const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
|
|
@@ -66,6 +192,10 @@ export async function scanSessionTodos(
|
|
|
66
192
|
if (Array.isArray(todos)) return todos as TodoItem[];
|
|
67
193
|
}
|
|
68
194
|
}
|
|
195
|
+
// No applied `todowrite` part. With a registered tool that means the
|
|
196
|
+
// session never planned, which must gate. Without one, the session
|
|
197
|
+
// cannot plan at all, so the gate has nothing to enforce.
|
|
198
|
+
if (capability === "available") return [];
|
|
69
199
|
// A `todowrite` part exists but none is applied yet (e.g. still
|
|
70
200
|
// streaming): the list is in flux, report it as empty rather than
|
|
71
201
|
// unknown so the gate stays closed until the write settles.
|
package/src/lib/version.ts
CHANGED
|
@@ -19,3 +19,25 @@ export function loadedPluginVersion(moduleUrl: string = import.meta.url): string
|
|
|
19
19
|
return undefined;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The OpenCode release this build is qualified against, read from the pinned
|
|
25
|
+
* `@opencode/plugin` dependency in the same manifest.
|
|
26
|
+
*
|
|
27
|
+
* Pinning the host API stops an unreviewed major from floating in, but a
|
|
28
|
+
* published guard can still be installed into a host that is newer or older
|
|
29
|
+
* than the one it was verified on. Reporting the qualified pair next to the
|
|
30
|
+
* loaded host version makes that mismatch visible before it becomes a silent
|
|
31
|
+
* no-op, which is the failure mode the migration guide names.
|
|
32
|
+
*/
|
|
33
|
+
export function qualifiedHostVersion(moduleUrl: string = import.meta.url): string | undefined {
|
|
34
|
+
try {
|
|
35
|
+
const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", moduleUrl)), "utf8")) as {
|
|
36
|
+
dependencies?: Record<string, unknown>;
|
|
37
|
+
};
|
|
38
|
+
const pinned = manifest.dependencies?.["@opencode/plugin"];
|
|
39
|
+
return typeof pinned === "string" && /^\d+\.\d+\.\d+/.test(pinned) ? pinned : undefined;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
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." };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy 25: Live-Documentation Research Floor.
|
|
3
|
+
*
|
|
4
|
+
* Failure mode this closes: after a handful of failed attempts, an agent keeps
|
|
5
|
+
* guessing from its training data instead of consulting current documentation,
|
|
6
|
+
* a live API reference, or a web source. Prior art calls the remedy "iterative
|
|
7
|
+
* grounding" (De-Hallucinator, arXiv 2401.01701) or a knowledge-lookup gate
|
|
8
|
+
* ("writes are refused unless a knowledge lookup has run in that turn").
|
|
9
|
+
*
|
|
10
|
+
* Scope: the floor blocks only mutation and shell tools - the surfaces where a
|
|
11
|
+
* guessed API, flag, or documented behavior actually gets written down. It
|
|
12
|
+
* never blocks a research tool, so the escape hatch is always the thing the
|
|
13
|
+
* agent should have reached for first.
|
|
14
|
+
*
|
|
15
|
+
* Semantics: the floor counts tool failures that occur *since the session last
|
|
16
|
+
* grounded itself in a live source*. A successful research call clears the
|
|
17
|
+
* debt; an unrelated successful tool does NOT, because "the guess happened to
|
|
18
|
+
* work once" is not the same as having consulted the documentation. This is
|
|
19
|
+
* deliberately stronger than the existing circuit breaker, whose counter resets
|
|
20
|
+
* on any success and so would be bypassed by a benign command.
|
|
21
|
+
*
|
|
22
|
+
* State is module-level, like `file-claims.ts`: verification/review evidence
|
|
23
|
+
* maps in `state.ts` are process-global by design so parent and subagent
|
|
24
|
+
* sessions share freshness, and the research floor follows that convention.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Tool names that consult live/external sources (builtin + common aliases). */
|
|
28
|
+
export const RESEARCH_TOOL_NAMES = new Set([
|
|
29
|
+
"websearch",
|
|
30
|
+
"webfetch",
|
|
31
|
+
"web_search",
|
|
32
|
+
"web_fetch",
|
|
33
|
+
"search_web",
|
|
34
|
+
"fetch_page",
|
|
35
|
+
"fetch_url",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* MCP tool names arrive namespaced (`mcp__<server>__<tool>`, `<server>_<tool>`),
|
|
40
|
+
* so a docs *server* is what makes a lookup live. Matching only documented
|
|
41
|
+
* server and API names keeps `read_wiki_index` and
|
|
42
|
+
* `guard_review_followup_resolve` from clearing debt as if they researched.
|
|
43
|
+
*/
|
|
44
|
+
const RESEARCH_SERVER_TOKENS = new Set([
|
|
45
|
+
"context7",
|
|
46
|
+
"deepwiki",
|
|
47
|
+
"tavily",
|
|
48
|
+
"brave",
|
|
49
|
+
"exa",
|
|
50
|
+
"kagi",
|
|
51
|
+
"perplexity",
|
|
52
|
+
"firecrawl",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tool-name segments that name a documentation lookup API. Names are split on
|
|
57
|
+
* non-alphanumerics first, so a multi-segment name matches on its parts: the
|
|
58
|
+
* `get-library-docs` tool matches via the `context7` server token.
|
|
59
|
+
*/
|
|
60
|
+
const RESEARCH_API_TOKENS = new Set(["docssearch", "searchdocs"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A research tool is a recognised builtin/alias name, a documented MCP source
|
|
64
|
+
* (matched by server name), or an operator-declared name from `researchTools`.
|
|
65
|
+
*/
|
|
66
|
+
export function isResearchTool(toolName: string, extraPatterns: readonly string[] = []): boolean {
|
|
67
|
+
if (typeof toolName !== "string") return false;
|
|
68
|
+
const lower = toolName.toLowerCase();
|
|
69
|
+
if (RESEARCH_TOOL_NAMES.has(lower)) return true;
|
|
70
|
+
if (lower.includes("resolve-library")) return true;
|
|
71
|
+
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
|
|
72
|
+
if (tokens.some((token) => RESEARCH_SERVER_TOKENS.has(token) || RESEARCH_API_TOKENS.has(token))) return true;
|
|
73
|
+
return extraPatterns.some((pattern) => pattern.length > 0 && lower.includes(pattern));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MAX_TRACKED_SESSIONS = 4096;
|
|
77
|
+
|
|
78
|
+
/** Per-session failures accrued since the session last consulted a live source. */
|
|
79
|
+
export class LiveDocsResearchFloor {
|
|
80
|
+
private readonly failuresSinceResearch = new Map<string, number>();
|
|
81
|
+
|
|
82
|
+
private bucket(sessionID: string): number {
|
|
83
|
+
return this.failuresSinceResearch.get(sessionID) ?? 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
recordFailure(sessionID: string): void {
|
|
87
|
+
if (!sessionID) return;
|
|
88
|
+
this.failuresSinceResearch.set(sessionID, this.bucket(sessionID) + 1);
|
|
89
|
+
if (this.failuresSinceResearch.size > MAX_TRACKED_SESSIONS) {
|
|
90
|
+
this.failuresSinceResearch.delete(this.failuresSinceResearch.keys().next().value!);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A successful live-source lookup clears the session's research debt. */
|
|
95
|
+
recordResearch(sessionID: string): void {
|
|
96
|
+
if (!sessionID) return;
|
|
97
|
+
this.failuresSinceResearch.delete(sessionID);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
failureCount(sessionID: string): number {
|
|
101
|
+
return this.bucket(sessionID);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns a block reason when a non-research tool call must first consult a
|
|
106
|
+
* live source, or `undefined` when the call may proceed. Research tools are
|
|
107
|
+
* never gated, and calls below the failure threshold proceed.
|
|
108
|
+
*/
|
|
109
|
+
gatingReason(sessionID: string, toolName: string, threshold: number, extraPatterns: readonly string[] = []): string | undefined {
|
|
110
|
+
if (!sessionID) return undefined;
|
|
111
|
+
if (!Number.isInteger(threshold) || threshold < 1) return undefined;
|
|
112
|
+
if (isResearchTool(toolName, extraPatterns)) return undefined;
|
|
113
|
+
const count = this.bucket(sessionID);
|
|
114
|
+
if (count < threshold) return undefined;
|
|
115
|
+
return (
|
|
116
|
+
`Blocked: ${count} tool failure(s) in this session and no live documentation lookup since. ` +
|
|
117
|
+
`Stop guessing from training data and consult current sources first with 'websearch' or 'webfetch' ` +
|
|
118
|
+
`(or an MCP documentation tool), then retry. If the failure is environmental rather than a knowledge gap, ` +
|
|
119
|
+
`inspect the underlying error before retrying instead. If this session has no research tool at all, ` +
|
|
120
|
+
`an operator can disable the floor with requireLiveDocs: false or WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
clearSession(sessionID: string): void {
|
|
125
|
+
this.failuresSinceResearch.delete(sessionID);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const floor = new LiveDocsResearchFloor();
|
|
130
|
+
|
|
131
|
+
/** Record a tool failure for the session's research debt. */
|
|
132
|
+
export function recordLiveDocsFailure(sessionID: string): void {
|
|
133
|
+
floor.recordFailure(sessionID);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Record that the session successfully consulted a live source. */
|
|
137
|
+
export function recordResearchLookup(sessionID: string): void {
|
|
138
|
+
floor.recordResearch(sessionID);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Return the session's failures-since-research count (for tests/inspection). */
|
|
142
|
+
export function liveDocsFailureCount(sessionID: string): number {
|
|
143
|
+
return floor.failureCount(sessionID);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Policy decision helper for the enforcement hooks: a block reason when a
|
|
148
|
+
* mutation/shell call must first research, otherwise `undefined`.
|
|
149
|
+
*/
|
|
150
|
+
export function liveDocsGatingReason(sessionID: string, toolName: string, threshold: number, extraPatterns: readonly string[] = []): string | undefined {
|
|
151
|
+
return floor.gatingReason(sessionID, toolName, threshold, extraPatterns);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Drop a session's floor state (session deletion). */
|
|
155
|
+
export function clearLiveDocsFloor(sessionID: string): void {
|
|
156
|
+
floor.clearSession(sessionID);
|
|
157
|
+
}
|
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;
|
|
@@ -85,3 +85,13 @@ export function staleWriteReason(path: string, sessionID: string): string | unde
|
|
|
85
85
|
export function clearReadFingerprints(sessionID: string): void {
|
|
86
86
|
reads.delete(sessionID);
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Number of distinct files the session has read or authored (a successful
|
|
91
|
+
* mutation seeds an observation too). Policy 26 uses this as reading-coverage
|
|
92
|
+
* evidence: a sweeping repository claim backed by one or two touched files is
|
|
93
|
+
* a skim, not a review.
|
|
94
|
+
*/
|
|
95
|
+
export function sessionReadFileCount(sessionID: string): number {
|
|
96
|
+
return reads.get(sessionID)?.size ?? 0;
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy 26: Sweeping-Claim Grounding check (observability-only).
|
|
3
|
+
*
|
|
4
|
+
* Second failure mode: an agent skims one or two files and then asserts
|
|
5
|
+
* sweeping facts about the repository ("every caller does X", "the entire
|
|
6
|
+
* codebase uses Y", "nothing else references Z"). Prior art is explicit that
|
|
7
|
+
* adding more context does not fix this: the EPISTEMIC LEDGER work (arXiv
|
|
8
|
+
* 2602.07549) calls it a "bare assertion" - a claim with no supporting
|
|
9
|
+
* retrieval - and the agent-control-tower study found agents self-reporting
|
|
10
|
+
* completion/facts they never verified, with small models overclaiming most.
|
|
11
|
+
*
|
|
12
|
+
* This policy therefore does NOT block. Blocking on natural-language repo
|
|
13
|
+
* claims is false-positive prone (a grep-heavy investigation may legitimately
|
|
14
|
+
* support "no other callers" while touching few files), and Policy 24 already
|
|
15
|
+
* keeps this class of check observability-only. Instead it journals a mismatch
|
|
16
|
+
* between a sweeping claim and thin read coverage so the overclaim is visible
|
|
17
|
+
* in the audit trail and the app log.
|
|
18
|
+
*
|
|
19
|
+
* Coverage signal: distinct files this session has read or authored
|
|
20
|
+
* (`sessionReadFileCount`). A sweeping claim backed by fewer than
|
|
21
|
+
* `MIN_SWEEPING_READ_COVERAGE` touched files is a skim, but treat a future
|
|
22
|
+
* search/grep evidence channel as the authoritative breadth signal when it
|
|
23
|
+
* exists - this is a conservative floor, not a verdict.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { sessionReadFileCount } from "./stale-write.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A sweeping claim is an explicit whole-repo/whole-set assertion. Kept narrow
|
|
30
|
+
* to high-signal phrasings so casual wording ("check the whole file", "all
|
|
31
|
+
* files under src") does not trip it; bare file quantifiers are deliberately
|
|
32
|
+
* excluded and only named set nouns (codebase, repository, project, packages,
|
|
33
|
+
* modules, components, sources) are matched.
|
|
34
|
+
*/
|
|
35
|
+
const SWEEPING_CLAIM_RE =
|
|
36
|
+
/\b(?:the\s+)?(?:entire|whole|all|every)\s+(?:codebase|repository|repo|project|application|packages?|modules?|components?|sources?)\b|\b(?:throughout|across)\s+the\s+(?:codebase|repository|repo|project|code|app|application)\b|\bno\s+other\s+(?:place|occurrence|usage|reference|references|callers?|usages?)\b|\bnothing\s+(?:else\s+)?(?:uses?|references?|calls?|depends\s+on)\b|\bnot\s+used\s+anywhere\s+else\b/i;
|
|
37
|
+
|
|
38
|
+
/** Distinct files that must be touched before a sweeping claim is "backed". */
|
|
39
|
+
export const MIN_SWEEPING_READ_COVERAGE = 2;
|
|
40
|
+
|
|
41
|
+
export interface SweepingClaimCheck {
|
|
42
|
+
sweeping: boolean;
|
|
43
|
+
claim?: string;
|
|
44
|
+
readFileCount: number;
|
|
45
|
+
reason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function checkSweepingClaims(
|
|
49
|
+
text: string,
|
|
50
|
+
context?: { sessionID?: string },
|
|
51
|
+
): SweepingClaimCheck {
|
|
52
|
+
const readFileCount =
|
|
53
|
+
context?.sessionID !== undefined ? sessionReadFileCount(context.sessionID) : 0;
|
|
54
|
+
if (!text) return { sweeping: false, readFileCount };
|
|
55
|
+
|
|
56
|
+
const match = text.match(SWEEPING_CLAIM_RE);
|
|
57
|
+
if (!match) return { sweeping: false, readFileCount };
|
|
58
|
+
|
|
59
|
+
if (readFileCount >= MIN_SWEEPING_READ_COVERAGE) {
|
|
60
|
+
return { sweeping: true, claim: match[0], readFileCount };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
sweeping: true,
|
|
65
|
+
claim: match[0],
|
|
66
|
+
readFileCount,
|
|
67
|
+
reason:
|
|
68
|
+
`the response makes a sweeping repository claim ("${match[0]}") but this session has read or authored only ` +
|
|
69
|
+
`${readFileCount} distinct file(s). Verify the claim with a broader search/read before stating it as fact.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
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
|
|