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/test/test.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSync, mkdirSync, lstatSync, chmodSync, statSync, utimesSync, readdirSync } from "node:fs";
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSync, mkdirSync, lstatSync, chmodSync, statSync, utimesSync, readdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
getSessionWorkspace,
|
|
48
48
|
getSessionBoundWorkspace,
|
|
49
49
|
registerSessionWorkspaceFromToolCall,
|
|
50
|
+
resolveInheritedWorkspace,
|
|
50
51
|
resolveEffectiveWorkspace,
|
|
51
52
|
getLastReviewResultForWorkspace,
|
|
52
53
|
isSameGitRepo,
|
|
@@ -77,7 +78,11 @@ import {
|
|
|
77
78
|
checkLockfileSync,
|
|
78
79
|
branchHasDocumentationChange,
|
|
79
80
|
isDocumentationRequired,
|
|
81
|
+
isLiveDocsRequired,
|
|
82
|
+
getLiveDocFailureThreshold,
|
|
83
|
+
getResearchToolPatterns,
|
|
80
84
|
loadedPluginVersion,
|
|
85
|
+
qualifiedHostVersion,
|
|
81
86
|
isReviewRequired,
|
|
82
87
|
getOperationProfile,
|
|
83
88
|
isRecoveryCheckpointsEnabled,
|
|
@@ -121,8 +126,29 @@ import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
|
|
|
121
126
|
import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
|
|
122
127
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
|
|
123
128
|
import { WorkflowGuardTui, WorkflowGuardTuiV2, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
124
|
-
import { scanSessionTodos, toolPartName } from "../src/lib/v2-todo.ts";
|
|
125
|
-
import {
|
|
129
|
+
import { scanSessionTodos, toolPartName, detectTodoCapability, recordedTodoCapability, recordTodoCapability, reportTodoGateFailOpen, resetTodoCapabilityForTesting } from "../src/lib/v2-todo.ts";
|
|
130
|
+
import { buildPolicyPosture, renderPolicyPosture } from "../src/lib/policy-posture.ts";
|
|
131
|
+
import { pluginKeyFor, preparePluginUpdate } from "../src/lib/config-writer.mjs";
|
|
132
|
+
import { effectiveTodosWithOwner, isReadOnlyTodo, readOnlyTodoCompletions, recordTodoTaskStarts, todoEvidenceReport, unverifiedTodoCompletions, validateTodoLifecycle } from "../src/policies/todo.ts";
|
|
133
|
+
import {
|
|
134
|
+
LiveDocsResearchFloor,
|
|
135
|
+
clearLiveDocsFloor,
|
|
136
|
+
isResearchTool,
|
|
137
|
+
liveDocsFailureCount,
|
|
138
|
+
liveDocsGatingReason,
|
|
139
|
+
recordLiveDocsFailure,
|
|
140
|
+
recordResearchLookup,
|
|
141
|
+
} from "../src/policies/live-docs.ts";
|
|
142
|
+
import { MIN_SWEEPING_READ_COVERAGE, checkSweepingClaims } from "../src/policies/sweeping-claims.ts";
|
|
143
|
+
import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, sessionReadFileCount } from "../src/policies/stale-write.ts";
|
|
144
|
+
import { todoTaskStarts, clearTodoSessionState } from "../src/lib/state.ts";
|
|
145
|
+
import { resetPluginInstancesForTesting } from "../src/lib/plugin-instance.ts";
|
|
146
|
+
import { claimToolCall, releaseToolCallClaim, resetToolCallClaimsForTesting } from "../src/lib/call-claims.ts";
|
|
147
|
+
import { pruneToolsForReadOnlyRole, READ_ONLY_PRUNED_TOOLS } from "../src/lib/read-only-tools.ts";
|
|
148
|
+
import { createHostWorktreePort } from "../src/lib/host-worktree.ts";
|
|
149
|
+
import { validateHostWorktreeDirectory } from "../src/lib/worktree.ts";
|
|
150
|
+
import { guardedTokensFromServerName, listHostMcpServerNames, recordHostMcpServers } from "../src/lib/mcp-inventory.ts";
|
|
151
|
+
import { addGuardedMcpTokens, guardedMcpServers, mcpMutationTool, resetGuardedMcpTokensForTesting } from "../src/policies/mcp.ts";
|
|
126
152
|
|
|
127
153
|
let pass = 0;
|
|
128
154
|
let fail = 0;
|
|
@@ -201,10 +227,13 @@ check("task gate is per session (other session's todos don't help)", blocked(awa
|
|
|
201
227
|
console.log("- Policy 1: todowrite lifecycle validation -");
|
|
202
228
|
// Multiple in_progress allowed (no single-task focus rule)
|
|
203
229
|
check("todowrite allows multiple in_progress tasks", !(await call("todowrite", { todos: [item("a", "in_progress"), item("b", "in_progress")] }, { sessionID: "s-empty" })));
|
|
204
|
-
// Flexible out-of-order completion allows finishing independent items without artificial sequential blockers
|
|
230
|
+
// Flexible out-of-order completion allows finishing independent items without artificial sequential blockers.
|
|
231
|
+
// Completion now requires work recorded in the session, so the setup records one mutation.
|
|
232
|
+
recordMutation("s-empty");
|
|
205
233
|
check("todowrite allows flexible out-of-order completion", !(await call("todowrite", { todos: [item("a", "pending"), item("b", "completed")] }, { sessionID: "s-empty" })));
|
|
206
234
|
// No silent deletion: active task cannot silently vanish
|
|
207
235
|
todo("s-lifecycle", item("task 1", "completed"), item("task 2", "in_progress"), item("task 3", "pending"));
|
|
236
|
+
recordMutation("s-lifecycle");
|
|
208
237
|
check("todowrite allows updating active tasks to completed/cancelled", !(await call("todowrite", { todos: [item("task 1", "completed"), item("task 2", "completed"), item("task 3", "cancelled")] }, { sessionID: "s-lifecycle" })));
|
|
209
238
|
check("todowrite blocks silently dropping task 2 without completion", blocked(await call("todowrite", { todos: [item("task 1", "completed"), item("task 3", "pending")] }, { sessionID: "s-lifecycle" })));
|
|
210
239
|
todo("s-review-remediation", item("Run full verification and secondary review", "in_progress"));
|
|
@@ -221,6 +250,7 @@ check("todowrite cannot silently drop remediation work while retaining review ex
|
|
|
221
250
|
todo("s-review-multiplicity", item("Run security secondary review", "in_progress"), item("Run architecture secondary review", "pending"));
|
|
222
251
|
check("todowrite cannot collapse multiple review obligations into one replacement", blocked(await call("todowrite", { todos: [item("Run secondary review", "in_progress")] }, { sessionID: "s-review-multiplicity" })));
|
|
223
252
|
check("todowrite allows one replacement per existing review obligation", !(await call("todowrite", { todos: [item("Run security re-review", "in_progress"), item("Run architecture re-review", "pending")] }, { sessionID: "s-review-multiplicity" })));
|
|
253
|
+
recordMutation("s-review-multiplicity");
|
|
224
254
|
check("todowrite allows completing one review while replacing another one-for-one", !(await call("todowrite", { todos: [item("Run security secondary review", "completed"), item("Run architecture re-review", "pending")] }, { sessionID: "s-review-multiplicity" })));
|
|
225
255
|
todo("s-duplicate-lifecycle", item("same task", "pending"), item("same task", "pending"));
|
|
226
256
|
check("todowrite cannot silently drop one of two duplicate active tasks", blocked(await call("todowrite", { todos: [item("same task", "pending")] }, { sessionID: "s-duplicate-lifecycle" })));
|
|
@@ -275,6 +305,161 @@ const inFlight = await scanSessionTodos(ctxWith([todoPart([item("x")], "pending"
|
|
|
275
305
|
check("V2: in-flight todowrite reports an empty list, not unknown", Array.isArray(inFlight) && inFlight.length === 0);
|
|
276
306
|
check("V2: toolPartName prefers tool and falls back to name", toolPartName({ tool: "shell" }) === "shell" && toolPartName({ name: "bash" }) === "bash" && toolPartName({}) === undefined);
|
|
277
307
|
|
|
308
|
+
// ── Capability probe: the registry answers "can this host plan at all?" ──
|
|
309
|
+
// The 2.0.x inference ("no todowrite in history means no capability") becomes
|
|
310
|
+
// wrong on 2.1.x, where a registered tool plus an empty history means the
|
|
311
|
+
// session never planned. These assertions invert that baseline.
|
|
312
|
+
const emptyHistory = ctxWith([{ content: [{ type: "tool", tool: "edit", state: { status: "completed" } }] }]);
|
|
313
|
+
check("capability: an empty history with a registered todowrite is a definitive empty list", JSON.stringify(await scanSessionTodos(emptyHistory, "s-cap", "available")) === "[]");
|
|
314
|
+
check("capability: an empty history without the tool stays unknown", (await scanSessionTodos(emptyHistory, "s-cap", "unavailable")) === undefined);
|
|
315
|
+
check("capability: an unread registry stays unknown rather than unavailable", (await scanSessionTodos(emptyHistory, "s-cap", "unknown")) === undefined);
|
|
316
|
+
check("capability: default remains unknown so existing callers are unchanged", (await scanSessionTodos(emptyHistory, "s-cap")) === undefined);
|
|
317
|
+
check("capability: a registry listing todowrite reports available", (await detectTodoCapability({ tool: { list: async () => [{ name: "todowrite" }, { name: "edit" }] } })) === "available");
|
|
318
|
+
check("capability: a registry without todowrite reports unavailable", (await detectTodoCapability({ tool: { list: async () => [{ name: "edit" }, { name: "shell" }] } })) === "unavailable");
|
|
319
|
+
check("capability: a 2.1.x tool id with a name field still counts", (await detectTodoCapability({ tool: { list: async () => [{ id: "todowrite", name: "todowrite" }] } })) === "available");
|
|
320
|
+
check("capability: a throwing registry reports unknown, never unavailable", (await detectTodoCapability({ tool: { list: async () => { throw new Error("rpc"); } } })) === "unknown");
|
|
321
|
+
check("capability: a host without a tool domain reports unknown", (await detectTodoCapability({})) === "unknown");
|
|
322
|
+
check("capability: a non-array registry answer reports unknown", (await detectTodoCapability({ tool: { list: async () => ({ nope: true }) } })) === "unknown");
|
|
323
|
+
|
|
324
|
+
// The recorded capability is process-global (two module copies must agree)
|
|
325
|
+
// and the fail-open warning fires once per session, not once per mutation.
|
|
326
|
+
resetTodoCapabilityForTesting();
|
|
327
|
+
check("capability: the recorded value starts unknown", recordedTodoCapability() === "unknown");
|
|
328
|
+
check("capability: recordTodoCapability returns the previous value", recordTodoCapability("unavailable") === "unknown" && recordedTodoCapability() === "unavailable");
|
|
329
|
+
check("capability: a first fail-open report is recorded", reportTodoGateFailOpen("s-warn", "unavailable") === true);
|
|
330
|
+
check("capability: a repeated fail-open report for the same session is suppressed", reportTodoGateFailOpen("s-warn", "unavailable") === false);
|
|
331
|
+
check("capability: a different session warns again", reportTodoGateFailOpen("s-warn-2", "unavailable") === true);
|
|
332
|
+
resetTodoCapabilityForTesting();
|
|
333
|
+
|
|
334
|
+
// Per-task completion evidence: a completion transition needs at least one
|
|
335
|
+
// mutation inside that task's window, so a model cannot flip the list to
|
|
336
|
+
// `completed` and move on. Read-only tasks carry an explicit marker.
|
|
337
|
+
const starts = new Map<string, number>();
|
|
338
|
+
const activeTwo = [item("write the fix", "in_progress"), item("run tests", "pending")];
|
|
339
|
+
const completedTwo = [item("write the fix", "completed"), item("run tests", "completed")];
|
|
340
|
+
recordTodoTaskStarts(starts, undefined, activeTwo, 0);
|
|
341
|
+
check("completion evidence: no mutation in the window blocks the completion", JSON.stringify(unverifiedTodoCompletions(activeTwo, completedTwo, starts, 0)) === JSON.stringify(["write the fix", "run tests"]));
|
|
342
|
+
check("completion evidence: one mutation cannot vouch for two completions", unverifiedTodoCompletions(activeTwo, completedTwo, new Map(starts), 1).length === 2);
|
|
343
|
+
check("completion evidence: two mutations satisfy two completions", unverifiedTodoCompletions(activeTwo, completedTwo, new Map(starts), 2).length === 0);
|
|
344
|
+
const startsSingle = new Map<string, number>();
|
|
345
|
+
recordTodoTaskStarts(startsSingle, undefined, [item("write the fix", "in_progress")], 0);
|
|
346
|
+
check("completion evidence: a single mutation satisfies a single completion", unverifiedTodoCompletions([item("write the fix", "in_progress")], [item("write the fix", "completed")], startsSingle, 1).length === 0);
|
|
347
|
+
|
|
348
|
+
// Window isolation: mutations spent before a task became active do not count.
|
|
349
|
+
const startsLate = new Map<string, number>();
|
|
350
|
+
recordTodoTaskStarts(startsLate, undefined, [item("early work", "in_progress")], 0);
|
|
351
|
+
recordTodoTaskStarts(startsLate, [item("early work", "in_progress")], [item("early work", "completed"), item("late work", "in_progress")], 4);
|
|
352
|
+
check("completion evidence: mutations before the task's window do not count for it", JSON.stringify(unverifiedTodoCompletions([item("early work", "completed"), item("late work", "in_progress")], [item("early work", "completed"), item("late work", "completed")], startsLate, 4)) === JSON.stringify(["late work"]));
|
|
353
|
+
check("completion evidence: a later mutation in the task's own window counts", unverifiedTodoCompletions([item("late work", "in_progress")], [item("late work", "completed")], startsLate, 5).length === 0);
|
|
354
|
+
check("completion evidence: a finished task releases its window", !startsLate.has("early work"));
|
|
355
|
+
|
|
356
|
+
// Window overlap: a mutation spent before a later task began cannot vouch for
|
|
357
|
+
// it, even when the total count looks sufficient.
|
|
358
|
+
const staggered = new Map([["first", 0], ["second", 5]]);
|
|
359
|
+
const staggeredPrev = [item("first", "in_progress"), item("second", "in_progress")];
|
|
360
|
+
const staggeredNext = [item("first", "completed"), item("second", "completed")];
|
|
361
|
+
check("completion evidence: staggered windows reject a completion whose window saw no work", JSON.stringify(unverifiedTodoCompletions(staggeredPrev, staggeredNext, staggered, 5)) === JSON.stringify(["second"]));
|
|
362
|
+
check("completion evidence: staggered windows pass once the later window sees work", unverifiedTodoCompletions(staggeredPrev, staggeredNext, staggered, 6).length === 0);
|
|
363
|
+
check("completion evidence: interleaved windows each get their own mutation", unverifiedTodoCompletions(staggeredPrev, staggeredNext, new Map([["first", 0], ["second", 1]]), 2).length === 0);
|
|
364
|
+
|
|
365
|
+
// Duplicate content must not hide a new completion behind an already-completed
|
|
366
|
+
// item of the same text.
|
|
367
|
+
const dupPrev = [item("x", "completed"), item("y", "in_progress")];
|
|
368
|
+
const dupNext = [item("x", "completed"), item("y", "in_progress"), item("x", "completed")];
|
|
369
|
+
check("completion evidence: a duplicate completed item is not a new claim", unverifiedTodoCompletions(dupPrev, [dupPrev[0]!, dupPrev[1]!], new Map(), 0).length === 0);
|
|
370
|
+
check("completion evidence: duplicate content cannot smuggle a new completion", JSON.stringify(unverifiedTodoCompletions(dupPrev, dupNext, new Map(), 0)) === JSON.stringify(["x"]));
|
|
371
|
+
|
|
372
|
+
// The marker is an annotation, not part of task identity, so appending it must
|
|
373
|
+
// not read as deleting one task and inventing another. The marker must be
|
|
374
|
+
// present on the completed item for the escape to apply.
|
|
375
|
+
check("completion evidence: appending the marker keeps the task's identity", JSON.stringify(unverifiedTodoCompletions([item("Review the diff", "in_progress")], [item("Review the diff [read-only]", "completed")], new Map(), 0)) === JSON.stringify([]));
|
|
376
|
+
check("completion evidence: dropping the marker does not inherit the escape", JSON.stringify(unverifiedTodoCompletions([item("Review the diff [read-only]", "in_progress")], [item("Review the diff", "completed")], new Map(), 0)) === JSON.stringify(["Review the diff"]));
|
|
377
|
+
check("completion evidence: identity ignores marker spelling and spacing", JSON.stringify(unverifiedTodoCompletions([item("Review the diff", "in_progress")], [item("Review the diff [read_only]", "completed")], new Map(), 0)) === JSON.stringify([]));
|
|
378
|
+
check("completion evidence: a non-read-only completion is still blocked despite identity stripping", JSON.stringify(unverifiedTodoCompletions([item("Fix the bug", "in_progress")], [item("Fix the bug", "completed")], new Map(), 0)) === JSON.stringify(["Fix the bug"]));
|
|
379
|
+
check("completion evidence: a task that is only the marker has no identity and is not journaled", JSON.stringify(readOnlyTodoCompletions([], [item(" [read-only]", "completed")])) === JSON.stringify([]));
|
|
380
|
+
check("lifecycle: appending the read-only marker to a planned task is not a silent deletion", !validateTodoLifecycle([item("Review the diff [read-only]", "completed")], [item("Review the diff", "in_progress")]));
|
|
381
|
+
check("lifecycle: appending the marker to a still-active task retains it", !validateTodoLifecycle([item("Review the diff [read-only]", "in_progress")], [item("Review the diff", "in_progress")]));
|
|
382
|
+
|
|
383
|
+
// Marker escape, and the transitions that are not new claims.
|
|
384
|
+
check("completion evidence: the read-only marker is recognized", isReadOnlyTodo(item("Review the diff [read-only]", "completed")) && !isReadOnlyTodo(item("Review the diff", "completed")));
|
|
385
|
+
check("completion evidence: a marked read-only task completes with no mutation", unverifiedTodoCompletions([item("Review the diff [read-only]", "in_progress")], [item("Review the diff [read-only]", "completed")], new Map(), 0).length === 0);
|
|
386
|
+
check("completion evidence: an already-completed task is not a new claim", unverifiedTodoCompletions([item("a", "completed")], [item("a", "completed")], new Map(), 0).length === 0);
|
|
387
|
+
check("completion evidence: a pending-to-pending rewrite is not a claim", unverifiedTodoCompletions([item("a", "pending")], [item("a", "pending")], new Map(), 0).length === 0);
|
|
388
|
+
check("completion evidence: a task first seen already completed is judged on session history", unverifiedTodoCompletions([item("a", "pending")], [item("a", "pending"), item("b", "completed")], new Map(), 0).length === 1);
|
|
389
|
+
|
|
390
|
+
// End-to-end through the real dispatcher on the test workspace.
|
|
391
|
+
setSdkClient(fakeClient);
|
|
392
|
+
todo("s-evidence", item("write the fix", "in_progress"));
|
|
393
|
+
check("dispatcher blocks marking a task completed with no recorded work", blocked(await call("todowrite", { todos: [item("write the fix", "completed")] }, { sessionID: "s-evidence" })));
|
|
394
|
+
todo("s-evidence-ro", item("Review the diff [read-only]", "in_progress"));
|
|
395
|
+
check("dispatcher allows the read-only marker in place of a mutation", !(await call("todowrite", { todos: [item("Review the diff [read-only]", "completed")] }, { sessionID: "s-evidence-ro" })));
|
|
396
|
+
// The window is registered by an allowed write, then work lands inside it.
|
|
397
|
+
todo("s-evidence-mut", item("write the fix", "in_progress"));
|
|
398
|
+
check("dispatcher allows a write that only adds pending work", !(await call("todowrite", { todos: [item("write the fix", "in_progress"), item("run tests", "pending")] }, { sessionID: "s-evidence-mut" })));
|
|
399
|
+
check("dispatcher blocks completion of the unwritten task", blocked(await call("todowrite", { todos: [item("write the fix", "completed"), item("run tests", "pending")] }, { sessionID: "s-evidence-mut" })));
|
|
400
|
+
recordMutation("s-evidence-mut");
|
|
401
|
+
check("dispatcher allows completion once work is recorded in the window", !(await call("todowrite", { todos: [item("write the fix", "completed"), item("run tests", "pending")] }, { sessionID: "s-evidence-mut" })));
|
|
402
|
+
// guard_why must predict the gate instead of reporting a falsely permissive result.
|
|
403
|
+
todo("s-evidence-sim", item("write the fix", "in_progress"));
|
|
404
|
+
check("simulation reports the same completion verdict as enforcement", blocked(await call("todowrite", { todos: [item("write the fix", "completed")] }, { sessionID: "s-evidence-sim", simulate: true } as never)));
|
|
405
|
+
check("simulation does not shift the task window", !todoTaskStarts("s-evidence-sim").has("write the fix"));
|
|
406
|
+
|
|
407
|
+
// The documented remedy, end to end: a planned task discovered to be read-only
|
|
408
|
+
// is completed by appending the marker. This must not trip the lifecycle gate.
|
|
409
|
+
todo("s-evidence-append", item("Review the diff", "in_progress"));
|
|
410
|
+
check("dispatcher allows appending the read-only marker to a planned task", !(await call("todowrite", { todos: [item("Review the diff [read-only]", "completed")] }, { sessionID: "s-evidence-append" })));
|
|
411
|
+
const readOnlyAudit = getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only");
|
|
412
|
+
check("dispatcher journals read-only marker use", readOnlyAudit.length > 0 && JSON.stringify((readOnlyAudit.at(-1)?.input as { tasks?: unknown })?.tasks) === JSON.stringify(["Review the diff [read-only]"]));
|
|
413
|
+
// A blocked write must not journal a read-only completion that never landed.
|
|
414
|
+
todo("s-evidence-audit-order", item("real work", "in_progress"));
|
|
415
|
+
const auditBeforeBlocked = getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only").length;
|
|
416
|
+
await call("todowrite", { todos: [item("real work [read-only]", "completed"), item("brand new", "completed")] }, { sessionID: "s-evidence-audit-order" });
|
|
417
|
+
check("a blocked todowrite does not journal a read-only completion", getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only").length === auditBeforeBlocked);
|
|
418
|
+
|
|
419
|
+
// guard_status surfaces the requirement before it bites.
|
|
420
|
+
todo("s-evidence-report", item("write the fix", "in_progress"), item("Review the diff [read-only]", "in_progress"), item("done already", "completed"));
|
|
421
|
+
const evidenceReport = await todoEvidenceReport("s-evidence-report");
|
|
422
|
+
check("status report names active tasks lacking recorded work", JSON.stringify(evidenceReport.tasksWithoutRecordedWork) === JSON.stringify(["write the fix"]));
|
|
423
|
+
check("status report lists read-only tasks separately", JSON.stringify(evidenceReport.readOnlyTasks) === JSON.stringify(["Review the diff [read-only]"]));
|
|
424
|
+
check("status report counts active and completed tasks", evidenceReport.active === 2 && evidenceReport.completed === 1);
|
|
425
|
+
check("status report is empty without a session", (await todoEvidenceReport(undefined)).tasksWithoutRecordedWork.length === 0);todoTaskStarts("s-evidence-clean").set("stale task", 0);
|
|
426
|
+
clearTodoSessionState("s-evidence-clean");
|
|
427
|
+
check("session cleanup drops task windows so a reused session id starts clean", !todoTaskStarts("s-evidence-clean").has("stale task"));
|
|
428
|
+
|
|
429
|
+
// Subagent completion is judged on the subagent's own counter, per the chosen
|
|
430
|
+
// policy. recordMutation credits both the todo owner and the acting session, so
|
|
431
|
+
// work done in a subagent is visible to whichever session reports it.
|
|
432
|
+
fakeParents.set("s-evidence-child", "s-evidence-owner");
|
|
433
|
+
todo("s-evidence-owner", item("shared work", "in_progress"));
|
|
434
|
+
todo("s-evidence-child", item("shared work", "in_progress"));
|
|
435
|
+
check("subagent cannot complete inherited work it did not do", blocked(await call("todowrite", { todos: [item("shared work", "completed")] }, { sessionID: "s-evidence-child" })));
|
|
436
|
+
recordMutation("s-evidence-owner", "s-evidence-child");
|
|
437
|
+
check("subagent can complete work it did, credited to both sessions", !(await call("todowrite", { todos: [item("shared work", "completed")] }, { sessionID: "s-evidence-child" })));
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
// The plan's failing baseline, inverted: on 2.1.x with `experimental.todo:
|
|
441
|
+
// true`, a mutation with no todo list must be blocked. On 2.0.x the same state
|
|
442
|
+
// is a legitimate fail-open. Drive both through the real dispatcher by
|
|
443
|
+
// swapping only the adapter's todo endpoint.
|
|
444
|
+
const v2TodoClient = (capability: "available" | "unavailable") => ({
|
|
445
|
+
session: {
|
|
446
|
+
todo: async ({ path }: { path: { id: string } }) => ({ data: await scanSessionTodos(emptyHistory, path.id, capability) }),
|
|
447
|
+
get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: undefined } }),
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
setSdkClient(v2TodoClient("available"));
|
|
451
|
+
recordTodoCapability("available");
|
|
452
|
+
const gatedMutation = await call("edit", { filePath: join(root, "v2-cap-gated.ts"), content: "x" }, { sessionID: "s-v2-cap" });
|
|
453
|
+
check("capability: a mutation with no todo is BLOCKED when the host can plan", blocked(gatedMutation) && String(gatedMutation).includes("no active todo item"));
|
|
454
|
+
setSdkClient(v2TodoClient("unavailable"));
|
|
455
|
+
recordTodoCapability("unavailable");
|
|
456
|
+
const failedOpenMutation = await call("edit", { filePath: join(root, "v2-cap-open.ts"), content: "x" }, { sessionID: "s-v2-cap" });
|
|
457
|
+
check("capability: the same mutation fails open when the host cannot plan", !blocked(failedOpenMutation));
|
|
458
|
+
check("capability: the fail-open path is journaled once for the session", getRecentAuditEntries(20).filter((entry) => entry.reason === "todo_gate_fail_open_unavailable").length === 1);
|
|
459
|
+
setSdkClient(fakeClient);
|
|
460
|
+
resetTodoCapabilityForTesting();
|
|
461
|
+
|
|
462
|
+
|
|
278
463
|
console.log("- Policy 1: parent-chain walk tolerates unknown links (V2) -");
|
|
279
464
|
const walkTodos = new Map<string, TestTodo[] | undefined>();
|
|
280
465
|
const walkParents = new Map<string, string | undefined>();
|
|
@@ -632,6 +817,11 @@ check("tee allowed with todos", !(await call("bash", { command: "echo x | tee sr
|
|
|
632
817
|
check("sed -i needs todos", blocked(await call("bash", { command: "sed -i 's/a/b/' src/a.ts" }, { sessionID: "s-empty" })));
|
|
633
818
|
check("sed -i allowed with todos", !(await call("bash", { command: "sed -i 's/a/b/' src/a.ts" }, { sessionID: "s-active" })));
|
|
634
819
|
check("sed -i on opencode.json blocked (tamper)", blocked(await call("bash", { command: "sed -i 's/a/b/' opencode.json" }, { sessionID: "s-active" })));
|
|
820
|
+
check("patch to opencode.json blocked (tamper)", blocked(await call("bash", { command: "patch opencode.json < x.diff" }, { sessionID: "s-active" })));
|
|
821
|
+
check("patch to ~/.config/opencode blocked (tamper)", blocked(await call("bash", { command: "patch /var/home/x/.config/opencode/opencode.jsonc < x.diff" }, { sessionID: "s-active" })));
|
|
822
|
+
check("patch to a normal file allowed with todos", !(await call("bash", { command: "patch src/a.ts < x.diff" }, { sessionID: "s-active" })));
|
|
823
|
+
check("perl -pi on opencode.json blocked (tamper)", blocked(await call("bash", { command: "perl -pi -e 's/a/b/' opencode.json" }, { sessionID: "s-active" })));
|
|
824
|
+
check("perl -pi on a normal file allowed with todos", !(await call("bash", { command: "perl -pi -e 's/a/b/' src/a.ts" }, { sessionID: "s-active" })));
|
|
635
825
|
check("git apply needs todos (patch via shell)", blocked(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-empty" })));
|
|
636
826
|
check("git apply allowed with todos", !(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-active" })));
|
|
637
827
|
check("non-mutating shell unaffected (ls, cat)", !(await call("bash", { command: "ls -la && cat file" }, { sessionID: "s-empty" })));
|
|
@@ -1615,6 +1805,201 @@ for (let i = 0; i <= 4096; i++) boundedTracker.recordFallbackCompleted("s-fallba
|
|
|
1615
1805
|
check("fallback call deduplication evicts old calls from its bounded window", boundedTracker.recordFallbackCompleted("s-fallback-bounded", "fallback-0", "bash")?.status === "completed");
|
|
1616
1806
|
check("invalid tool timing is omitted", new ToolOutcomeTracker().record({ type: "tool", sessionID: "s-timing", callID: "timing", tool: "bash", state: { status: "completed", time: { start: Number.NaN, end: Number.POSITIVE_INFINITY } } })?.durationMs === undefined);
|
|
1617
1807
|
|
|
1808
|
+
// ── Policy 25: live-documentation research floor ──
|
|
1809
|
+
console.log("- live-docs research floor -");
|
|
1810
|
+
check("recognises builtin research tools", isResearchTool("websearch") && isResearchTool("webfetch"));
|
|
1811
|
+
check("recognises MCP documentation tools", isResearchTool("context7_resolve-library-id") && isResearchTool("mcp__context7__get-library-docs"));
|
|
1812
|
+
check("recognises tokenised docs servers beyond context7", isResearchTool("mcp__deepwiki__read_wiki") && isResearchTool("mcp__tavily__search") && isResearchTool("mcp__brave__web_search"));
|
|
1813
|
+
check("tokenised matching does not read obvious non-docs tools as research", !isResearchTool("mcp__git__ref_list") && !isResearchTool("git_status"));
|
|
1814
|
+
// The policy exists to force a real lookup. A guard inspection tool or a
|
|
1815
|
+
// generic file reader must not clear the debt by name accident.
|
|
1816
|
+
check("guard inspection tools are not research", !isResearchTool("guard_review_followup_resolve") && !isResearchTool("guard_status") && !isResearchTool("guard_why"));
|
|
1817
|
+
check("generic readers and resolvers are not research", !isResearchTool("read_wiki_index") && !isResearchTool("resolve_conflict") && !isResearchTool("read_docs_page"));
|
|
1818
|
+
check("a bare docs-ish token is not enough on its own", !isResearchTool("docker") && !isResearchTool("doc") && !isResearchTool("wiki"));
|
|
1819
|
+
check("documented docs servers match through their namespace", isResearchTool("mcp__context7__get-library-docs") && isResearchTool("mcp__deepwiki__read_wiki") && isResearchTool("deepwiki_read_wiki"));
|
|
1820
|
+
check("operator-declared research patterns are honoured", isResearchTool("mcp__acme__lookup", ["acme"]) && !isResearchTool("mcp__acme__lookup"));
|
|
1821
|
+
check("does not treat unrelated tools as research", !isResearchTool("bash") && !isResearchTool("edit") && !isResearchTool("read"));
|
|
1822
|
+
|
|
1823
|
+
const floorUnit = new LiveDocsResearchFloor();
|
|
1824
|
+
floorUnit.recordFailure("s-unit");
|
|
1825
|
+
floorUnit.recordFailure("s-unit");
|
|
1826
|
+
check("research floor accrues failures below threshold", floorUnit.gatingReason("s-unit", "edit", 3) === undefined);
|
|
1827
|
+
floorUnit.recordFailure("s-unit");
|
|
1828
|
+
check("research floor blocks a mutation at threshold with no lookup", typeof floorUnit.gatingReason("s-unit", "edit", 3) === "string");
|
|
1829
|
+
check(
|
|
1830
|
+
"floor block names the operator escape when no research tool exists",
|
|
1831
|
+
(floorUnit.gatingReason("s-unit", "edit", 3) ?? "").includes("requireLiveDocs: false"),
|
|
1832
|
+
);
|
|
1833
|
+
check("research floor never blocks a research tool", floorUnit.gatingReason("s-unit", "websearch", 3) === undefined);
|
|
1834
|
+
floorUnit.recordResearch("s-unit");
|
|
1835
|
+
check("successful lookup clears the floor", floorUnit.gatingReason("s-unit", "bash", 3) === undefined);
|
|
1836
|
+
check("floor disabled when threshold is non-positive", floorUnit.gatingReason("s-unit", "edit", 0) === undefined);
|
|
1837
|
+
|
|
1838
|
+
// A failed (or cancelled) research attempt must not deepen the debt it was
|
|
1839
|
+
// meant to clear; otherwise a flaky lookup deadlocks the session.
|
|
1840
|
+
const floorDeadlock = new LiveDocsResearchFloor();
|
|
1841
|
+
for (const _ of [1, 2, 3]) floorDeadlock.recordFailure("s-deadlock");
|
|
1842
|
+
check("floor blocks before a research attempt", typeof floorDeadlock.gatingReason("s-deadlock", "bash", 3) === "string");
|
|
1843
|
+
check("a research tool stays ungated while the floor is active", floorDeadlock.gatingReason("s-deadlock", "mcp__deepwiki__read_wiki", 3) === undefined);
|
|
1844
|
+
check("an operator-declared research tool stays ungated too", floorDeadlock.gatingReason("s-deadlock", "mcp__acme__lookup", 3, ["acme"]) === undefined);
|
|
1845
|
+
floorDeadlock.recordResearch("s-deadlock");
|
|
1846
|
+
check("clearing the floor lets the mutation through", floorDeadlock.gatingReason("s-deadlock", "bash", 3) === undefined);
|
|
1847
|
+
|
|
1848
|
+
// Module-level helpers share state; a fresh session isolates the check.
|
|
1849
|
+
recordLiveDocsFailure("s-live-helpers");
|
|
1850
|
+
recordLiveDocsFailure("s-live-helpers");
|
|
1851
|
+
recordLiveDocsFailure("s-live-helpers");
|
|
1852
|
+
check("module helper exposes failures-since-research", liveDocsFailureCount("s-live-helpers") === 3);
|
|
1853
|
+
check("module helper gates mutation above threshold", typeof liveDocsGatingReason("s-live-helpers", "apply_patch", 3) === "string");
|
|
1854
|
+
recordResearchLookup("s-live-helpers");
|
|
1855
|
+
check("module helper clears debt after a lookup", liveDocsFailureCount("s-live-helpers") === 0 && liveDocsGatingReason("s-live-helpers", "edit", 3) === undefined);
|
|
1856
|
+
clearLiveDocsFloor("s-live-helpers");
|
|
1857
|
+
|
|
1858
|
+
const prevRequireLive = process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS;
|
|
1859
|
+
const prevThreshold = process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
|
|
1860
|
+
delete process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS;
|
|
1861
|
+
delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
|
|
1862
|
+
check("live-docs research floor is enabled by default", isLiveDocsRequired(root) === true);
|
|
1863
|
+
check("live-docs failure threshold defaults to 3", getLiveDocFailureThreshold(root) === 3);
|
|
1864
|
+
process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS = "0";
|
|
1865
|
+
check("env override can disable the research floor", isLiveDocsRequired(root) === false);
|
|
1866
|
+
delete process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS;
|
|
1867
|
+
process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = "5";
|
|
1868
|
+
check("env override can raise the research threshold", getLiveDocFailureThreshold(root) === 5);
|
|
1869
|
+
process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = "5abc";
|
|
1870
|
+
check("trailing junk in the threshold is rejected, not silently parsed", getLiveDocFailureThreshold(root) === 3);
|
|
1871
|
+
process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = "0";
|
|
1872
|
+
check("a zero threshold falls back to the default instead of disabling the floor", getLiveDocFailureThreshold(root) === 3);
|
|
1873
|
+
process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = " 4 ";
|
|
1874
|
+
check("a padded threshold value is still honoured", getLiveDocFailureThreshold(root) === 4);
|
|
1875
|
+
delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
|
|
1876
|
+
const prevResearchTools = process.env.WORKFLOW_GUARD_RESEARCH_TOOLS;
|
|
1877
|
+
process.env.WORKFLOW_GUARD_RESEARCH_TOOLS = "acme,corp-docs";
|
|
1878
|
+
check("operator research tool patterns come from the environment", getResearchToolPatterns(root).join(",") === "acme,corp-docs");
|
|
1879
|
+
delete process.env.WORKFLOW_GUARD_RESEARCH_TOOLS;
|
|
1880
|
+
if (prevResearchTools === undefined) delete process.env.WORKFLOW_GUARD_RESEARCH_TOOLS; else process.env.WORKFLOW_GUARD_RESEARCH_TOOLS = prevResearchTools;
|
|
1881
|
+
delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
|
|
1882
|
+
if (prevRequireLive === undefined) delete process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS; else process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS = prevRequireLive;
|
|
1883
|
+
if (prevThreshold === undefined) delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD; else process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = prevThreshold;
|
|
1884
|
+
|
|
1885
|
+
const liveDocsPlugin = await pluginFn({ directory: root, client: fakeClient as any, project: {} as any, worktree: root, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
1886
|
+
const liveDocsSession = "s-live-docs-int";
|
|
1887
|
+
const livePart = (callID: string, status: "error" | "completed", tool = "bash") => ({
|
|
1888
|
+
type: "tool",
|
|
1889
|
+
sessionID: liveDocsSession,
|
|
1890
|
+
callID,
|
|
1891
|
+
tool,
|
|
1892
|
+
state: { status, error: status === "error" ? `boom-${callID}` : undefined, time: { start: 1, end: 2 } },
|
|
1893
|
+
} as const);
|
|
1894
|
+
for (const id of ["ld-e1", "ld-e2", "ld-e3"]) {
|
|
1895
|
+
await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart(id, "error") } } } as any);
|
|
1896
|
+
}
|
|
1897
|
+
check("three failures accrue research debt in the entrypoint", liveDocsFailureCount(liveDocsSession) === 3);
|
|
1898
|
+
let liveBlockMessage = "";
|
|
1899
|
+
try {
|
|
1900
|
+
await liveDocsPlugin["tool.execute.before"]?.({ tool: "bash", sessionID: liveDocsSession, callID: "ld-block" } as any, { args: { command: "ls" } } as any);
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
liveBlockMessage = (err as Error).message;
|
|
1903
|
+
}
|
|
1904
|
+
check("repeated failures with no lookup block a shell call", liveBlockMessage.includes("Blocked:") && liveBlockMessage.includes("websearch"));
|
|
1905
|
+
let researchThrow = "";
|
|
1906
|
+
try {
|
|
1907
|
+
await liveDocsPlugin["tool.execute.before"]?.({ tool: "websearch", sessionID: liveDocsSession, callID: "ld-research" } as any, { args: { query: "node fs api" } } as any);
|
|
1908
|
+
} catch (err) {
|
|
1909
|
+
researchThrow = (err as Error).message;
|
|
1910
|
+
}
|
|
1911
|
+
check("research tool is never blocked by the floor", researchThrow === "");
|
|
1912
|
+
await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart("ld-research", "completed", "websearch") } } } as any);
|
|
1913
|
+
check("successful lookup clears research debt in the entrypoint", liveDocsFailureCount(liveDocsSession) === 0);
|
|
1914
|
+
let liveAllowThrow = "";
|
|
1915
|
+
try {
|
|
1916
|
+
await liveDocsPlugin["tool.execute.before"]?.({ tool: "bash", sessionID: liveDocsSession, callID: "ld-after" } as any, { args: { command: "ls" } } as any);
|
|
1917
|
+
} catch (err) {
|
|
1918
|
+
liveAllowThrow = (err as Error).message;
|
|
1919
|
+
}
|
|
1920
|
+
check("shell call proceeds after the lookup clears the floor", liveAllowThrow === "");
|
|
1921
|
+
|
|
1922
|
+
// Unrelated success does not clear debt; idle preserves it; delete clears it.
|
|
1923
|
+
const liveDocsSession2 = "s-live-docs-int2";
|
|
1924
|
+
const livePart2 = (callID: string, status: "error" | "completed", tool = "bash") => ({
|
|
1925
|
+
type: "tool",
|
|
1926
|
+
sessionID: liveDocsSession2,
|
|
1927
|
+
callID,
|
|
1928
|
+
tool,
|
|
1929
|
+
state: { status, error: status === "error" ? `boom-${callID}` : undefined, time: { start: 1, end: 2 } },
|
|
1930
|
+
} as const);
|
|
1931
|
+
for (const id of ["ld2-e1", "ld2-e2", "ld2-e3"]) {
|
|
1932
|
+
await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart2(id, "error") } } } as any);
|
|
1933
|
+
}
|
|
1934
|
+
await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart2("ld2-ok", "completed", "bash") } } } as any);
|
|
1935
|
+
check("an unrelated successful tool does not clear research debt", liveDocsFailureCount(liveDocsSession2) === 3);
|
|
1936
|
+
let unrelatedBlock = "";
|
|
1937
|
+
try {
|
|
1938
|
+
await liveDocsPlugin["tool.execute.before"]?.({ tool: "bash", sessionID: liveDocsSession2, callID: "ld2-block" } as any, { args: { command: "ls" } } as any);
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
unrelatedBlock = (err as Error).message;
|
|
1941
|
+
}
|
|
1942
|
+
// The floor reason names websearch; the no-active-todo/edit gate would not, so
|
|
1943
|
+
// this asserts Policy 25 (not an unrelated gate) actually fired.
|
|
1944
|
+
check("mutation still blocks after an unrelated success", unrelatedBlock.includes("Blocked:") && unrelatedBlock.includes("websearch"));
|
|
1945
|
+
|
|
1946
|
+
// A failed research attempt must not deepen the debt, and must clear on success.
|
|
1947
|
+
const liveDocsSession3 = "s-live-docs-int3";
|
|
1948
|
+
const livePart3 = (callID: string, status: "error" | "completed", tool = "bash") => ({
|
|
1949
|
+
type: "tool",
|
|
1950
|
+
sessionID: liveDocsSession3,
|
|
1951
|
+
callID,
|
|
1952
|
+
tool,
|
|
1953
|
+
state: { status, error: status === "error" ? `boom-${callID}` : undefined, time: { start: 1, end: 2 } },
|
|
1954
|
+
} as const);
|
|
1955
|
+
for (const id of ["ld3-e1", "ld3-e2", "ld3-e3"]) {
|
|
1956
|
+
await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart3(id, "error") } } } as any);
|
|
1957
|
+
}
|
|
1958
|
+
check("floor is active before the research attempt", liveDocsFailureCount(liveDocsSession3) === 3);
|
|
1959
|
+
await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart3("ld3-research-fail", "error", "websearch") } } } as any);
|
|
1960
|
+
check("a failed research attempt does not accrue research debt", liveDocsFailureCount(liveDocsSession3) === 3);
|
|
1961
|
+
await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart3("ld3-research-ok", "completed", "websearch") } } } as any);
|
|
1962
|
+
check("a successful research attempt clears the debt", liveDocsFailureCount(liveDocsSession3) === 0);
|
|
1963
|
+
let worktreeThrow = "";
|
|
1964
|
+
try {
|
|
1965
|
+
await liveDocsPlugin["tool.execute.before"]?.({ tool: "guard_worktree_create", sessionID: liveDocsSession3, callID: "ld3-wt" } as any, { args: { branch: "feat/x" } } as any);
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
worktreeThrow = (err as Error).message;
|
|
1968
|
+
}
|
|
1969
|
+
check("a mutating lifecycle tool is not blocked once the floor is clear", !worktreeThrow.includes("websearch"));
|
|
1970
|
+
clearLiveDocsFloor(liveDocsSession3);
|
|
1971
|
+
|
|
1972
|
+
// Positive direction: a lifecycle tool must be blocked while the floor is
|
|
1973
|
+
// active, or the guard's own tools are the way past its gate.
|
|
1974
|
+
const liveDocsSession4 = "s-live-docs-int4";
|
|
1975
|
+
for (const _ of [1, 2, 3]) recordLiveDocsFailure(liveDocsSession4);
|
|
1976
|
+
let worktreeBlocked = "";
|
|
1977
|
+
try {
|
|
1978
|
+
await liveDocsPlugin["tool.execute.before"]?.({ tool: "guard_worktree_create", sessionID: liveDocsSession4, callID: "ld4-wt" } as any, { args: { branch: "feat/y" } } as any);
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
worktreeBlocked = (err as Error).message;
|
|
1981
|
+
}
|
|
1982
|
+
check("a mutating lifecycle tool is blocked while the floor is active", worktreeBlocked.includes("Blocked:") && worktreeBlocked.includes("websearch"));
|
|
1983
|
+
let restoreBlocked = "";
|
|
1984
|
+
try {
|
|
1985
|
+
await liveDocsPlugin["tool.execute.before"]?.({ tool: "guard_recovery_restore", sessionID: liveDocsSession4, callID: "ld4-restore" } as any, { args: {} } as any);
|
|
1986
|
+
} catch (err) {
|
|
1987
|
+
restoreBlocked = (err as Error).message;
|
|
1988
|
+
}
|
|
1989
|
+
check("recovery restore is blocked while the floor is active", restoreBlocked.includes("Blocked:") && restoreBlocked.includes("websearch"));
|
|
1990
|
+
clearLiveDocsFloor(liveDocsSession4);
|
|
1991
|
+
|
|
1992
|
+
await liveDocsPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: liveDocsSession2 } } } as any);
|
|
1993
|
+
check("session idle does not clear research debt", liveDocsFailureCount(liveDocsSession2) === 3);
|
|
1994
|
+
await liveDocsPlugin.event?.({ event: { type: "session.deleted", properties: { info: { id: liveDocsSession2 } } } } as any);
|
|
1995
|
+
check("session deletion clears research debt", liveDocsFailureCount(liveDocsSession2) === 0);
|
|
1996
|
+
|
|
1997
|
+
// Regex narrowing: bare file-set phrases and benign wording stay unmatched.
|
|
1998
|
+
check("bare file-set phrasing is not a sweeping claim", !checkSweepingClaims("all files under src compile.").sweeping && !checkSweepingClaims("check the whole file.").sweeping);
|
|
1999
|
+
check("repo-level phrasing still matches", checkSweepingClaims("the entire repository follows this convention.").sweeping && checkSweepingClaims("all packages depend on it.").sweeping);
|
|
2000
|
+
clearLiveDocsFloor(liveDocsSession);
|
|
2001
|
+
clearLiveDocsFloor("s-live-docs-int2");
|
|
2002
|
+
|
|
1618
2003
|
// TUI companion plugin registers prompt status indicator slots
|
|
1619
2004
|
let registeredSlots: Record<string, Function> = {};
|
|
1620
2005
|
let registeredOrder: number | undefined;
|
|
@@ -2560,6 +2945,9 @@ spawnSync("git", ["init", "-b", "feature/why"], { cwd: whyVerifyRoot });
|
|
|
2560
2945
|
writeFileSync(join(whyVerifyRoot, "package.json"), JSON.stringify({ scripts: { typecheck: "node -e 'process.exit(9)'" } }));
|
|
2561
2946
|
const whyVerifyPlugin = await WorkflowGuard({ directory: whyVerifyRoot, worktree: whyVerifyRoot, client: diagnosticClient as any } as any);
|
|
2562
2947
|
todo("s-why-verify", item("finish", "in_progress"));
|
|
2948
|
+
// Completion evidence is judged first: record work so the simulation reaches
|
|
2949
|
+
// the finalization gate this test is about.
|
|
2950
|
+
recordMutation("s-why-verify");
|
|
2563
2951
|
const verifyHistoryBeforeWhy = getRecentVerifyHistory(100).length;
|
|
2564
2952
|
const verifyResultBeforeWhy = getLastVerifyResult();
|
|
2565
2953
|
const auditBeforeWhy = getRecentAuditEntries(50).length;
|
|
@@ -3374,6 +3762,70 @@ check("a session binds to its workspace from its own call context", getSessionBo
|
|
|
3374
3762
|
// The other loop's plugin instance must not re-anchor it from its own root.
|
|
3375
3763
|
await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
|
|
3376
3764
|
check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3765
|
+
// A path-derived candidate must not widen an existing binding either. A
|
|
3766
|
+
// foreign read is allowed (external_directory defaults to allow), and the old
|
|
3767
|
+
// code then adopted that repository as the session's home: one allowed read
|
|
3768
|
+
// moved the entire write boundary.
|
|
3769
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-widen", tool: "read", callID: "c-widen-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
|
|
3770
|
+
check("a session binds from its first call context (widen fixture)", getSessionBoundWorkspace("s-iso-widen") === isoRepoA);
|
|
3771
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-widen", tool: "read", callID: "c-widen-2" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
|
|
3772
|
+
check("an allowed read in another repository does not widen the write boundary", getSessionBoundWorkspace("s-iso-widen") === isoRepoA);
|
|
3773
|
+
const widenRefusalAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_refused" && entry.sessionID === "s-iso-widen");
|
|
3774
|
+
check(
|
|
3775
|
+
"a refused widening is journaled with both roots and the call label",
|
|
3776
|
+
widenRefusalAudit?.decision === "block" &&
|
|
3777
|
+
(widenRefusalAudit.input as any)?.from === isoRepoA &&
|
|
3778
|
+
(widenRefusalAudit.input as any)?.to === isoRepoB &&
|
|
3779
|
+
(widenRefusalAudit.input as any)?.source === "read",
|
|
3780
|
+
);
|
|
3781
|
+
todo("s-iso-widen", item("boundary probe", "in_progress"));
|
|
3782
|
+
const crossWrite = await guardToolDecision("write", { filePath: join(isoRepoB, "escape.txt"), content: "x" }, { sessionID: "s-iso-widen" });
|
|
3783
|
+
check("a foreign write is refused after a foreign read", crossWrite.status === "blocked" && crossWrite.policy === "boundary");
|
|
3784
|
+
if (crossWrite.status !== "blocked") console.log(" decision:", JSON.stringify(crossWrite));
|
|
3785
|
+
// A subagent has no direct binding, but its policy root inherits the nearest
|
|
3786
|
+
// bound ancestor's workspace. Registration must use that same inherited root,
|
|
3787
|
+
// or the child's first foreign read rebinds it into another repository while
|
|
3788
|
+
// the parent stays confined: one allowed read moves the child's write
|
|
3789
|
+
// boundary, which is the Task 3 defect one level down.
|
|
3790
|
+
fakeParents.set("s-iso-child", "s-iso-widen");
|
|
3791
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-child", tool: "read", callID: "c-child-1" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
|
|
3792
|
+
check(
|
|
3793
|
+
"a subagent's foreign read does not rebind it out of the parent's workspace",
|
|
3794
|
+
getSessionBoundWorkspace("s-iso-child") === undefined &&
|
|
3795
|
+
getSessionWorkspace("s-iso-child") !== isoRepoB &&
|
|
3796
|
+
(await resolveInheritedWorkspace("s-iso-child")) === isoRepoA,
|
|
3797
|
+
);
|
|
3798
|
+
const childRefusalAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_refused" && entry.sessionID === "s-iso-child");
|
|
3799
|
+
check(
|
|
3800
|
+
"a subagent's refused widening is journaled against the inherited root",
|
|
3801
|
+
childRefusalAudit?.decision === "block" &&
|
|
3802
|
+
(childRefusalAudit.input as any)?.from === isoRepoA &&
|
|
3803
|
+
(childRefusalAudit.input as any)?.to === isoRepoB,
|
|
3804
|
+
);
|
|
3805
|
+
check("resolveInheritedWorkspace reports the parent's root for a subagent", (await resolveInheritedWorkspace("s-iso-child")) === isoRepoA);
|
|
3806
|
+
// The real hook runs the child's decision under the plugin instance root, which
|
|
3807
|
+
// for a relay flow is the parent's repository. A write into the foreign repo
|
|
3808
|
+
// must still be refused there.
|
|
3809
|
+
const childEscapeWrite = await guardToolDecision("write", { filePath: join(isoRepoB, "child-escape.txt"), content: "x" }, { sessionID: "s-iso-child", worktree: isoRepoA });
|
|
3810
|
+
check("a subagent's foreign write stays blocked by the inherited boundary", childEscapeWrite.status === "blocked" && childEscapeWrite.policy === "boundary");
|
|
3811
|
+
if (childEscapeWrite.status !== "blocked") console.log(" decision:", JSON.stringify(childEscapeWrite));
|
|
3812
|
+
fakeParents.delete("s-iso-child");
|
|
3813
|
+
// An explicit host-declared worktree is still a legitimate widening statement:
|
|
3814
|
+
// worktree isolation and relay flows set it, and a subagent must be able to
|
|
3815
|
+
// bind to the linked worktree it was given.
|
|
3816
|
+
const isoWidenWorktree = join(root, "wg-iso-widen-wt");
|
|
3817
|
+
const widenWorktreeAdd = spawnSync("git", ["-C", isoRepoA, "worktree", "add", "-q", "-b", "feat/iso-wt", isoWidenWorktree]);
|
|
3818
|
+
check("a linked worktree fixture is created for the widening check", widenWorktreeAdd.status === 0);
|
|
3819
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-relay", tool: "read", callID: "c-relay-1", worktree: isoWidenWorktree }, { args: { filePath: join(isoWidenWorktree, "seed.txt") } });
|
|
3820
|
+
check("a host-declared worktree binds an unbound session", getSessionBoundWorkspace("s-iso-relay") === realpathSync(isoWidenWorktree));
|
|
3821
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-hostwiden", tool: "read", callID: "c-hostwiden-1" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
|
|
3822
|
+
check("a second session binds through its own call context", getSessionBoundWorkspace("s-iso-hostwiden") === isoRepoB);
|
|
3823
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-hostwiden", tool: "read", callID: "c-hostwiden-2", worktree: isoWidenWorktree }, { args: {} });
|
|
3824
|
+
check("a host-declared worktree may widen a bound session", getSessionBoundWorkspace("s-iso-hostwiden") === realpathSync(isoWidenWorktree));
|
|
3825
|
+
const hostRebindAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_host_worktree" && entry.sessionID === "s-iso-hostwiden");
|
|
3826
|
+
check("a granted host-worktree widening is journaled", hostRebindAudit?.decision === "allow" && (hostRebindAudit.input as any)?.to === realpathSync(isoWidenWorktree));
|
|
3827
|
+
spawnSync("git", ["-C", isoRepoA, "worktree", "remove", "--force", isoWidenWorktree]);
|
|
3828
|
+
rmSync(isoWidenWorktree, { recursive: true, force: true });
|
|
3377
3829
|
const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
|
|
3378
3830
|
check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
|
|
3379
3831
|
const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
|
|
@@ -4214,6 +4666,22 @@ check(
|
|
|
4214
4666
|
appLogs.some((entry) => entry?.body?.level === "warn" && String(entry?.body?.message).includes("blocked write")),
|
|
4215
4667
|
);
|
|
4216
4668
|
|
|
4669
|
+
// Policy 25 blocks must reach the durable app log at warn level too.
|
|
4670
|
+
appLogs.length = 0;
|
|
4671
|
+
const liveDocLogSession = "s-live-doc-log";
|
|
4672
|
+
for (const _ of [1, 2, 3]) recordLiveDocsFailure(liveDocLogSession);
|
|
4673
|
+
try {
|
|
4674
|
+
await loggingPlugin["tool.execute.before"]?.(
|
|
4675
|
+
{ tool: "bash", sessionID: liveDocLogSession, callID: "ld-log" } as any,
|
|
4676
|
+
{ args: { command: "ls" } } as any,
|
|
4677
|
+
);
|
|
4678
|
+
} catch {}
|
|
4679
|
+
check(
|
|
4680
|
+
"research floor block writes warning to app log",
|
|
4681
|
+
appLogs.some((entry) => entry?.body?.level === "warn" && String(entry?.body?.message).includes("no live documentation lookup")),
|
|
4682
|
+
);
|
|
4683
|
+
clearLiveDocsFloor(liveDocLogSession);
|
|
4684
|
+
|
|
4217
4685
|
// Concurrent plugin instances keep SDK client state isolated (AsyncLocalStorage).
|
|
4218
4686
|
const activeClient = {
|
|
4219
4687
|
session: {
|
|
@@ -4576,6 +5044,56 @@ const claimLogAudit = getRecentAuditEntries(50).find((e) => e.tool === "experime
|
|
|
4576
5044
|
check("claims-vs-evidence mismatch is an info observation, not a block warning", appLogs.some((entry) => entry?.body?.level === "info" && String(entry?.body?.message).includes("completion claim mismatch")) && !appLogs.some((entry) => entry?.body?.level === "warn"));
|
|
4577
5045
|
check("claims-vs-evidence observation remains an allowed audit event", claimLogAudit?.decision === "allow");
|
|
4578
5046
|
|
|
5047
|
+
// ── Policy 26: sweeping repo claims vs read coverage (observability) ──
|
|
5048
|
+
console.log("- sweeping-claims grounding (observability) -");
|
|
5049
|
+
check("benign text does not trigger a sweeping claim", !checkSweepingClaims("I refactored the module.").sweeping);
|
|
5050
|
+
check("whole-codebase phrasing is detected", checkSweepingClaims("The entire codebase uses this pattern.").sweeping);
|
|
5051
|
+
check("no-other-callers phrasing is detected", checkSweepingClaims("No other callers reference this function.").sweeping);
|
|
5052
|
+
check("bare nothing-depends phrasing is detected without 'else'", checkSweepingClaims("Nothing depends on it.").sweeping && checkSweepingClaims("Nothing else depends on it.").sweeping);
|
|
5053
|
+
check(
|
|
5054
|
+
"a sweeping claim with thin read coverage is flagged",
|
|
5055
|
+
typeof checkSweepingClaims("Nothing else uses this helper.", { sessionID: "s-sweep-thin" }).reason === "string",
|
|
5056
|
+
);
|
|
5057
|
+
const sweepCoverageDir = mkdtempSync(join(tmpdir(), "wg-sweep-coverage-"));
|
|
5058
|
+
for (let i = 0; i < MIN_SWEEPING_READ_COVERAGE; i++) {
|
|
5059
|
+
const p = join(sweepCoverageDir, `f${i}.txt`);
|
|
5060
|
+
writeFileSync(p, `content ${i}`);
|
|
5061
|
+
const obs = beginReadObservation(p);
|
|
5062
|
+
if (obs) recordSuccessfulRead(obs, "s-sweep-backed");
|
|
5063
|
+
}
|
|
5064
|
+
check("read coverage counts distinct files", sessionReadFileCount("s-sweep-backed") === MIN_SWEEPING_READ_COVERAGE);
|
|
5065
|
+
const backed = checkSweepingClaims("The whole repository follows this convention.", { sessionID: "s-sweep-backed" });
|
|
5066
|
+
check("a sweeping claim backed by read coverage is not flagged", backed.sweeping && backed.reason === undefined);
|
|
5067
|
+
clearReadFingerprints("s-sweep-backed");
|
|
5068
|
+
clearReadFingerprints("s-sweep-thin");
|
|
5069
|
+
rmSync(sweepCoverageDir, { recursive: true, force: true });
|
|
5070
|
+
|
|
5071
|
+
// Hook integration: sweeping mismatch is journaled, not blocked.
|
|
5072
|
+
const sweepAuditBefore = getRecentAuditEntries(50).filter((e) => e.tool === "experimental.text.complete" && String(e.reason).includes("Sweeping claim")).length;
|
|
5073
|
+
appLogs.length = 0;
|
|
5074
|
+
await loggingPlugin["experimental.text.complete"]?.(
|
|
5075
|
+
{ sessionID: "s-sweep-hook", messageID: "m3", partID: "p3" } as any,
|
|
5076
|
+
{ text: "The entire codebase relies on this invariant." } as any,
|
|
5077
|
+
);
|
|
5078
|
+
const sweepAuditAfter = getRecentAuditEntries(50).filter((e) => e.tool === "experimental.text.complete" && String(e.reason).includes("Sweeping claim")).length;
|
|
5079
|
+
check("sweeping-claim mismatch is journaled via the hook", sweepAuditAfter > sweepAuditBefore);
|
|
5080
|
+
check(
|
|
5081
|
+
"sweeping-claim mismatch is an info observation, not a block warning",
|
|
5082
|
+
appLogs.some((entry) => entry?.body?.level === "info" && String(entry?.body?.message).includes("sweeping claim mismatch")) &&
|
|
5083
|
+
!appLogs.some((entry) => entry?.body?.level === "warn"),
|
|
5084
|
+
);
|
|
5085
|
+
// Negative hook case: a non-sweeping message must not journal a sweeping entry,
|
|
5086
|
+
// so the positive assertion above cannot pass on Policy 24's row alone.
|
|
5087
|
+
const sweepCleanBefore = getRecentAuditEntries(50).filter((e) => String(e.reason).includes("Sweeping claim")).length;
|
|
5088
|
+
await loggingPlugin["experimental.text.complete"]?.(
|
|
5089
|
+
{ sessionID: "s-sweep-clean", messageID: "m4", partID: "p4" } as any,
|
|
5090
|
+
{ text: "I refactored the helper and added a test." } as any,
|
|
5091
|
+
);
|
|
5092
|
+
const sweepCleanAfter = getRecentAuditEntries(50).filter((e) => String(e.reason).includes("Sweeping claim")).length;
|
|
5093
|
+
check("a non-sweeping message journals no sweeping-claim entry", sweepCleanAfter === sweepCleanBefore);
|
|
5094
|
+
clearReadFingerprints("s-sweep-hook");
|
|
5095
|
+
clearReadFingerprints("s-sweep-clean");
|
|
5096
|
+
|
|
4579
5097
|
// tool.definition enriches todowrite's description with the gate note
|
|
4580
5098
|
const defPlugin = await WorkflowGuard({ directory: root, worktree: root, client: fakeClient as any } as any);
|
|
4581
5099
|
const todoDef = { description: "Write the todo list.", parameters: {} };
|
|
@@ -4978,6 +5496,618 @@ check("project-memory initialization failure leaves core guard hooks active", ty
|
|
|
4978
5496
|
if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
4979
5497
|
else process.env.XDG_DATA_HOME = prevDataHome;
|
|
4980
5498
|
|
|
5499
|
+
// ── V2 load-once guard: one policy decision per call, one duplicate record ──
|
|
5500
|
+
// OpenCode 2 merges plugin arrays, so naming the guard in both global and
|
|
5501
|
+
// project config loads the same entrypoint twice under two ids. The second
|
|
5502
|
+
// setup must register nothing and leave a warn-level audit record; otherwise
|
|
5503
|
+
// every tool call is evaluated and audited twice, and two instances prime the
|
|
5504
|
+
// process-global workspace latch from two roots in the same tick.
|
|
5505
|
+
console.log("- V2 duplicate load guard -");
|
|
5506
|
+
resetPluginInstancesForTesting();
|
|
5507
|
+
resetToolCallClaimsForTesting();
|
|
5508
|
+
const v2DuplicateRoot = mkdtempSync(join(tmpdir(), "wg-v2-dup-"));
|
|
5509
|
+
const prevDuplicateDataHome = process.env.XDG_DATA_HOME;
|
|
5510
|
+
process.env.XDG_DATA_HOME = join(v2DuplicateRoot, "data");
|
|
5511
|
+
const makeV2Context = (directory: string, options?: {
|
|
5512
|
+
projectID?: string;
|
|
5513
|
+
registeredTools?: Array<{ id: string; description?: string }>;
|
|
5514
|
+
mcpServers?: string[];
|
|
5515
|
+
worktreeApi?: {
|
|
5516
|
+
create: (input: any) => Promise<{ directory?: string } | undefined>;
|
|
5517
|
+
list: (input: any) => Promise<ReadonlyArray<{ directory?: string }> | undefined>;
|
|
5518
|
+
refresh?: (input: any) => Promise<unknown>;
|
|
5519
|
+
};
|
|
5520
|
+
}) => {
|
|
5521
|
+
const state = {
|
|
5522
|
+
transforms: 0,
|
|
5523
|
+
addedTools: [] as string[],
|
|
5524
|
+
beforeHooks: [] as Array<(event: unknown) => unknown>,
|
|
5525
|
+
afterHooks: [] as Array<(event: unknown) => unknown>,
|
|
5526
|
+
permissionHooks: [] as Array<(event: unknown) => unknown>,
|
|
5527
|
+
contextHooks: [] as Array<(event: unknown) => unknown>,
|
|
5528
|
+
commands: [] as Array<{ name: string; description?: string; execute: (input: unknown) => Promise<void> }>,
|
|
5529
|
+
syntheticMessages: [] as string[],
|
|
5530
|
+
prompts: [] as string[],
|
|
5531
|
+
worktreeCalls: [] as string[],
|
|
5532
|
+
mcpListCalls: 0,
|
|
5533
|
+
};
|
|
5534
|
+
const editor = {
|
|
5535
|
+
list: () => [],
|
|
5536
|
+
get: () => undefined,
|
|
5537
|
+
namespace: () => {},
|
|
5538
|
+
add: (tool: { name: string }) => { state.addedTools.push(tool.name); },
|
|
5539
|
+
update: () => {},
|
|
5540
|
+
remove: () => {},
|
|
5541
|
+
};
|
|
5542
|
+
const registration = { dispose: async () => {} };
|
|
5543
|
+
const registeredTools = options?.registeredTools ?? [];
|
|
5544
|
+
const ctx = {
|
|
5545
|
+
location: { directory, ...(options?.projectID ? { project: { id: options.projectID, directory, canonical: directory } } : {}) },
|
|
5546
|
+
options: {},
|
|
5547
|
+
tool: {
|
|
5548
|
+
transform: async (callback: (input: unknown) => void) => { state.transforms++; callback(editor); return registration; },
|
|
5549
|
+
reload: async () => {},
|
|
5550
|
+
list: async () => registeredTools,
|
|
5551
|
+
hook: async (name: string, callback: (event: unknown) => unknown) => {
|
|
5552
|
+
if (name === "execute.before") state.beforeHooks.push(callback);
|
|
5553
|
+
if (name === "execute.after") state.afterHooks.push(callback);
|
|
5554
|
+
return registration;
|
|
5555
|
+
},
|
|
5556
|
+
},
|
|
5557
|
+
command: {
|
|
5558
|
+
list: async () => ({ data: [] }),
|
|
5559
|
+
transform: async (callback: (editor: { add: (definition: never) => void }) => void) => {
|
|
5560
|
+
callback({ add: (definition: never) => { state.commands.push(definition); } });
|
|
5561
|
+
return registration;
|
|
5562
|
+
},
|
|
5563
|
+
reload: async () => {},
|
|
5564
|
+
},
|
|
5565
|
+
mcp: {
|
|
5566
|
+
list: async () => {
|
|
5567
|
+
state.mcpListCalls++;
|
|
5568
|
+
return options?.mcpServers === undefined ? undefined : { data: options.mcpServers.map((name) => ({ name, status: { type: "connected" } })) };
|
|
5569
|
+
},
|
|
5570
|
+
},
|
|
5571
|
+
worktree: options?.worktreeApi ? {
|
|
5572
|
+
create: async (input: unknown) => { state.worktreeCalls.push("create"); return options.worktreeApi!.create(input); },
|
|
5573
|
+
list: async (input: unknown) => { state.worktreeCalls.push("list"); return options.worktreeApi!.list(input); },
|
|
5574
|
+
refresh: async (input: unknown) => { state.worktreeCalls.push("refresh"); return options.worktreeApi!.refresh?.(input); },
|
|
5575
|
+
} : undefined,
|
|
5576
|
+
session: {
|
|
5577
|
+
get: async () => ({}),
|
|
5578
|
+
context: async () => [],
|
|
5579
|
+
prompt: async (input: { text?: string }) => { state.prompts.push(String(input?.text ?? "")); },
|
|
5580
|
+
synthetic: async (input: { text?: string }) => { state.syntheticMessages.push(String(input?.text ?? "")); },
|
|
5581
|
+
hook: async (name: string, callback: (event: unknown) => unknown) => {
|
|
5582
|
+
if (name === "context") state.contextHooks.push(callback);
|
|
5583
|
+
return registration;
|
|
5584
|
+
},
|
|
5585
|
+
},
|
|
5586
|
+
permission: {
|
|
5587
|
+
hook: async (name: string, callback: (event: unknown) => unknown) => {
|
|
5588
|
+
if (name === "evaluate") state.permissionHooks.push(callback);
|
|
5589
|
+
return registration;
|
|
5590
|
+
},
|
|
5591
|
+
},
|
|
5592
|
+
shell: { hook: async () => registration },
|
|
5593
|
+
event: { subscribe: async function* () {} },
|
|
5594
|
+
app: { log: async () => {} },
|
|
5595
|
+
};
|
|
5596
|
+
return { ctx, state };
|
|
5597
|
+
};
|
|
5598
|
+
const v2Owner = makeV2Context(v2DuplicateRoot);
|
|
5599
|
+
const v2OwnerCleanup = (await defaultExport.setup(v2Owner.ctx as any)) as (() => void) | undefined;
|
|
5600
|
+
const v2Duplicate = makeV2Context(v2DuplicateRoot);
|
|
5601
|
+
const v2DuplicateCleanup = (await defaultExport.setup(v2Duplicate.ctx as any)) as (() => void) | undefined;
|
|
5602
|
+
check("the first V2 instance registers its tool transform", v2Owner.state.transforms === 1 && v2Owner.state.addedTools.includes("guard_status"));
|
|
5603
|
+
check("a duplicate V2 instance registers no tool transform", v2Duplicate.state.transforms === 0);
|
|
5604
|
+
check("a duplicate V2 instance registers no execute hooks", v2Duplicate.state.beforeHooks.length === 0 && v2Duplicate.state.afterHooks.length === 0);
|
|
5605
|
+
check("a duplicate V2 instance registers no permission seat", v2Duplicate.state.permissionHooks.length === 0);
|
|
5606
|
+
|
|
5607
|
+
// ── Permission seat: evaluate denies, execute.before still aborts ──
|
|
5608
|
+
// `evaluate` sees normalized action/resources before the tool runs, so a deny
|
|
5609
|
+
// here means the tool never starts. `execute.before` still aborts with the
|
|
5610
|
+
// specific remediation message, because it is the only seat that sees raw
|
|
5611
|
+
// tool input. Both canaries must fail closed with attributable messages.
|
|
5612
|
+
console.log("- V2 permission seat -");
|
|
5613
|
+
const permissionEvent = (action: string, resources: string[], sessionID = "s-perm-seat") => ({
|
|
5614
|
+
sessionID,
|
|
5615
|
+
agent: "build",
|
|
5616
|
+
action,
|
|
5617
|
+
resources,
|
|
5618
|
+
effect: "allow" as "allow" | "deny" | "ask",
|
|
5619
|
+
message: undefined as string | undefined,
|
|
5620
|
+
});
|
|
5621
|
+
const seatShell = permissionEvent("shell", ["git push origin main"]);
|
|
5622
|
+
await v2Owner.state.permissionHooks[0]?.(seatShell);
|
|
5623
|
+
check("evaluate denies a protected-branch push before the tool runs", seatShell.effect === "deny" && /protected branch|main/i.test(String(seatShell.message)));
|
|
5624
|
+
if (seatShell.effect !== "deny") console.log(" evaluate message:", seatShell.message);
|
|
5625
|
+
const seatSecret = permissionEvent("read", [".env"]);
|
|
5626
|
+
await v2Owner.state.permissionHooks[0]?.(seatSecret);
|
|
5627
|
+
check("evaluate denies a secret read", seatSecret.effect === "deny" && /secret|credential/i.test(String(seatSecret.message)));
|
|
5628
|
+
const seatBoundary = permissionEvent("edit", [join(v2DuplicateRoot, "..", "outside.txt")]);
|
|
5629
|
+
await v2Owner.state.permissionHooks[0]?.(seatBoundary);
|
|
5630
|
+
check("evaluate denies a boundary-escaping edit", seatBoundary.effect === "deny" && /workspace/i.test(String(seatBoundary.message)));
|
|
5631
|
+
const seatAllowed = permissionEvent("shell", ["ls -la"]);
|
|
5632
|
+
await v2Owner.state.permissionHooks[0]?.(seatAllowed);
|
|
5633
|
+
check("evaluate leaves an ordinary command allowed", seatAllowed.effect === "allow");
|
|
5634
|
+
const seatUnmapped = permissionEvent("external_directory", ["/tmp/*"]);
|
|
5635
|
+
await v2Owner.state.permissionHooks[0]?.(seatUnmapped);
|
|
5636
|
+
check("evaluate leaves unmapped host actions to the host", seatUnmapped.effect === "allow");
|
|
5637
|
+
const predefinedDeny = { ...permissionEvent("shell", ["ls"]), effect: "deny" as const };
|
|
5638
|
+
await v2Owner.state.permissionHooks[0]?.(predefinedDeny);
|
|
5639
|
+
check("an explicit configured deny stays final and untouched", predefinedDeny.effect === "deny" && predefinedDeny.message === undefined);
|
|
5640
|
+
const seatAuditEntries = getRecentAuditEntries(60).filter((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.decision === "block");
|
|
5641
|
+
const seatAudit = seatAuditEntries.find((entry) => entry.policyDecision?.status === "blocked");
|
|
5642
|
+
check("evaluate denials are journaled with the policy decision", seatAudit !== undefined);
|
|
5643
|
+
const configuredDenyAudit = seatAuditEntries.find((entry) => (entry.input as any)?.configuredEffect === "deny");
|
|
5644
|
+
check("a configured deny is journaled as such without inventing a guard decision", configuredDenyAudit !== undefined && configuredDenyAudit.policyDecision === undefined);
|
|
5645
|
+
let executeBeforeDenied = false;
|
|
5646
|
+
try {
|
|
5647
|
+
await v2Owner.state.beforeHooks[0]?.({ tool: "write", sessionID: "s-perm-seat", agent: "build", messageID: "msg-perm", id: "call-perm", input: { filePath: join(root, "..", "outside.txt"), content: "x" } });
|
|
5648
|
+
} catch (error) {
|
|
5649
|
+
executeBeforeDenied = String((error as Error)?.message).includes("[workflow-guard]");
|
|
5650
|
+
}
|
|
5651
|
+
check("execute.before still aborts with the guard's remediation message", executeBeforeDenied);
|
|
5652
|
+
const duplicateAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "plugin.instance" && entry.reason === "duplicate_plugin_load");
|
|
5653
|
+
check("a duplicate V2 load writes an audit record naming the instance key", duplicateAudit !== undefined && String((duplicateAudit.input as any)?.instanceKey).includes(v2DuplicateRoot));
|
|
5654
|
+
check("the first V2 instance records exactly one plugin_loaded entry", getRecentAuditEntries(50).filter((entry) => entry.tool === "plugin.instance" && entry.reason === "plugin_loaded").length === 1);
|
|
5655
|
+
let duplicateDecisions = 0;
|
|
5656
|
+
for (const callback of v2Owner.state.beforeHooks) {
|
|
5657
|
+
await callback({ tool: "read", sessionID: "s-v2-dup", agent: "build", messageID: "msg-v2-dup", id: "call-v2-dup", input: { filePath: join(v2DuplicateRoot, "seed.txt") } });
|
|
5658
|
+
duplicateDecisions++;
|
|
5659
|
+
}
|
|
5660
|
+
for (const callback of v2Duplicate.state.beforeHooks) {
|
|
5661
|
+
await callback({ tool: "read", sessionID: "s-v2-dup", agent: "build", messageID: "msg-v2-dup", id: "call-v2-dup", input: { filePath: join(v2DuplicateRoot, "seed.txt") } });
|
|
5662
|
+
duplicateDecisions++;
|
|
5663
|
+
}
|
|
5664
|
+
const duplicateDecisionAudits = getRecentAuditEntries(50).filter((entry) => entry.tool === "read" && entry.phase === "decision" && entry.callID === "call-v2-dup");
|
|
5665
|
+
check("a duplicated load produces one policy decision per tool call", duplicateDecisions === 1 && duplicateDecisionAudits.length === 1);
|
|
5666
|
+
v2DuplicateCleanup?.();
|
|
5667
|
+
v2OwnerCleanup?.();
|
|
5668
|
+
// A second copy with a DISTINCT module path (cache copy plus source checkout)
|
|
5669
|
+
// cannot be made inert by keying alone; the claim table keeps one decision.
|
|
5670
|
+
resetToolCallClaimsForTesting();
|
|
5671
|
+
check("the first copy claims a tool call for its module path", claimToolCall("module-a", "s-v2-claim", "call-claim", "read") === true);
|
|
5672
|
+
check("a second module copy defers on the same tool call", claimToolCall("module-b", "s-v2-claim", "call-claim", "read") === false);
|
|
5673
|
+
releaseToolCallClaim("module-a", "s-v2-claim", "call-claim", "read");
|
|
5674
|
+
check("a settled call can be claimed by the next copy", claimToolCall("module-b", "s-v2-claim", "call-claim", "read") === true);
|
|
5675
|
+
check("claims without a call id are never shared across instances", claimToolCall("module-a", "s-v2-claim", undefined, "read") === true && claimToolCall("module-b", "s-v2-claim", undefined, "read") === true);
|
|
5676
|
+
resetToolCallClaimsForTesting();
|
|
5677
|
+
// A floor block must release the claim it acquired before the throw, or the
|
|
5678
|
+
// next copy of this call can never run and the block leaks into a timeout.
|
|
5679
|
+
resetToolCallClaimsForTesting();
|
|
5680
|
+
const v2FloorSession = "s-v2-floor-claim";
|
|
5681
|
+
recordLiveDocsFailure(v2FloorSession);
|
|
5682
|
+
recordLiveDocsFailure(v2FloorSession);
|
|
5683
|
+
recordLiveDocsFailure(v2FloorSession);
|
|
5684
|
+
let v2FloorThrow = "";
|
|
5685
|
+
try {
|
|
5686
|
+
await v2Owner.state.beforeHooks[0]?.({
|
|
5687
|
+
tool: "write",
|
|
5688
|
+
sessionID: v2FloorSession,
|
|
5689
|
+
agent: "build",
|
|
5690
|
+
messageID: "msg-v2-floor",
|
|
5691
|
+
id: "call-v2-floor",
|
|
5692
|
+
input: { filePath: join(v2DuplicateRoot, "floor.txt"), content: "x" },
|
|
5693
|
+
});
|
|
5694
|
+
} catch (err) {
|
|
5695
|
+
v2FloorThrow = (err as Error).message;
|
|
5696
|
+
}
|
|
5697
|
+
check("V2 floor block aborts the call", v2FloorThrow.includes("no live documentation lookup"));
|
|
5698
|
+
check(
|
|
5699
|
+
"a V2 floor block releases its tool-call claim",
|
|
5700
|
+
claimToolCall("module-after-floor", v2FloorSession, "call-v2-floor", "write") === true,
|
|
5701
|
+
);
|
|
5702
|
+
releaseToolCallClaim("module-after-floor", v2FloorSession, "call-v2-floor", "write");
|
|
5703
|
+
clearLiveDocsFloor(v2FloorSession);
|
|
5704
|
+
if (prevDuplicateDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5705
|
+
else process.env.XDG_DATA_HOME = prevDuplicateDataHome;
|
|
5706
|
+
rmSync(v2DuplicateRoot, { recursive: true, force: true });
|
|
5707
|
+
|
|
5708
|
+
// ── V2 adopt-now surfaces: command, read-only pruning, worktrees, MCP ──
|
|
5709
|
+
// Four additive V2 API adoptions, each tested for its narrowing direction:
|
|
5710
|
+
// the command adds an operator surface, pruning removes tools, worktree cleanup
|
|
5711
|
+
// only accepts host-declared directories, and the MCP inventory only widens.
|
|
5712
|
+
console.log("- V2 adopt-now surfaces -");
|
|
5713
|
+
|
|
5714
|
+
// /guard-status: registered server-side, delivered without a model turn.
|
|
5715
|
+
{
|
|
5716
|
+
resetPluginInstancesForTesting();
|
|
5717
|
+
const cmdRoot = mkdtempSync(join(tmpdir(), "wg-v2-cmd-"));
|
|
5718
|
+
const prevCmdDataHome = process.env.XDG_DATA_HOME;
|
|
5719
|
+
process.env.XDG_DATA_HOME = join(cmdRoot, "data");
|
|
5720
|
+
const cmdCtx = makeV2Context(cmdRoot);
|
|
5721
|
+
const cmdCleanup = (await defaultExport.setup(cmdCtx.ctx as any)) as (() => void) | undefined;
|
|
5722
|
+
const guardStatusCommand = cmdCtx.state.commands.find((command) => command.name === "guard-status");
|
|
5723
|
+
check("the V2 plugin registers a server-side guard-status command", guardStatusCommand !== undefined);
|
|
5724
|
+
check("guard-status carries an operator-facing description", /status/i.test(String(guardStatusCommand?.description)));
|
|
5725
|
+
await guardStatusCommand?.execute({ sessionID: "s-v2-cmd" } as any);
|
|
5726
|
+
check("guard-status delivers the report as a synthetic message", cmdCtx.state.syntheticMessages.length === 1 && cmdCtx.state.syntheticMessages[0].includes("pluginVersion"));
|
|
5727
|
+
check("guard-status costs no model turn on the normal path", cmdCtx.state.prompts.length === 0);
|
|
5728
|
+
check("guard-status is journaled in the audit trail", getRecentAuditEntries(20).some((entry) => entry.tool === "command.guard-status" && entry.reason === "guard_status_command"));
|
|
5729
|
+
cmdCleanup?.();
|
|
5730
|
+
if (prevCmdDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5731
|
+
else process.env.XDG_DATA_HOME = prevCmdDataHome;
|
|
5732
|
+
rmSync(cmdRoot, { recursive: true, force: true });
|
|
5733
|
+
}
|
|
5734
|
+
|
|
5735
|
+
// Read-only roles: mutating tools leave the per-request snapshot.
|
|
5736
|
+
{
|
|
5737
|
+
const snapshot = {
|
|
5738
|
+
read: { description: "read", input: {} },
|
|
5739
|
+
glob: { description: "glob", input: {} },
|
|
5740
|
+
grep: { description: "grep", input: {} },
|
|
5741
|
+
webfetch: { description: "webfetch", input: {} },
|
|
5742
|
+
write: { description: "write", input: {} },
|
|
5743
|
+
edit: { description: "edit", input: {} },
|
|
5744
|
+
patch: { description: "patch", input: {} },
|
|
5745
|
+
apply_patch: { description: "apply_patch", input: {} },
|
|
5746
|
+
shell: { description: "shell", input: {} },
|
|
5747
|
+
execute: { description: "execute", input: {} },
|
|
5748
|
+
todowrite: { description: "todowrite", input: {} },
|
|
5749
|
+
subagent: { description: "subagent", input: {} },
|
|
5750
|
+
};
|
|
5751
|
+
const removed = pruneToolsForReadOnlyRole(snapshot as any);
|
|
5752
|
+
check("read-only pruning removes every mutating tool", READ_ONLY_PRUNED_TOOLS.every((id) => !(id in snapshot)));
|
|
5753
|
+
check("read-only pruning keeps the read-only tools", ["read", "glob", "grep", "webfetch"].every((id) => id in snapshot));
|
|
5754
|
+
check("read-only pruning reports what it removed", removed.includes("write") && removed.includes("shell") && removed.includes("execute") && !removed.includes("read"));
|
|
5755
|
+
const emptySnapshot: Record<string, unknown> = { read: {} };
|
|
5756
|
+
check("read-only pruning is a no-op on an already narrow snapshot", pruneToolsForReadOnlyRole(emptySnapshot).length === 0 && "read" in emptySnapshot);
|
|
5757
|
+
check("read-only pruning tolerates a missing snapshot", pruneToolsForReadOnlyRole(undefined).length === 0);
|
|
5758
|
+
|
|
5759
|
+
resetPluginInstancesForTesting();
|
|
5760
|
+
const roRoot = mkdtempSync(join(tmpdir(), "wg-v2-ro-"));
|
|
5761
|
+
const prevRoDataHome = process.env.XDG_DATA_HOME;
|
|
5762
|
+
process.env.XDG_DATA_HOME = join(roRoot, "data");
|
|
5763
|
+
const roCtx = makeV2Context(roRoot);
|
|
5764
|
+
const roCleanup = (await defaultExport.setup(roCtx.ctx as any)) as (() => void) | undefined;
|
|
5765
|
+
const roEvent = {
|
|
5766
|
+
sessionID: "s-v2-ro",
|
|
5767
|
+
agent: "reviewer",
|
|
5768
|
+
model: { providerID: "p", id: "m" },
|
|
5769
|
+
system: [] as Array<{ type: string; text: string }>,
|
|
5770
|
+
messages: [],
|
|
5771
|
+
options: {},
|
|
5772
|
+
tools: { read: { description: "read", input: {} }, write: { description: "write", input: {} }, shell: { description: "shell", input: {} } },
|
|
5773
|
+
};
|
|
5774
|
+
await roCtx.state.contextHooks[0]?.(roEvent);
|
|
5775
|
+
check("the context hook prunes mutating tools for a read-only role", !("write" in roEvent.tools) && !("shell" in roEvent.tools) && "read" in roEvent.tools);
|
|
5776
|
+
check("read-only pruning is journaled with the removed tool ids", getRecentAuditEntries(20).some((entry) => entry.tool === "session.context" && entry.reason === "read_only_role_tools_pruned" && Array.isArray((entry.input as any)?.removed)));
|
|
5777
|
+
const buildEvent = {
|
|
5778
|
+
sessionID: "s-v2-build",
|
|
5779
|
+
agent: "build",
|
|
5780
|
+
model: { providerID: "p", id: "m" },
|
|
5781
|
+
system: [] as Array<{ type: string; text: string }>,
|
|
5782
|
+
messages: [],
|
|
5783
|
+
options: {},
|
|
5784
|
+
tools: { read: { description: "read", input: {} }, write: { description: "write", input: {} } },
|
|
5785
|
+
};
|
|
5786
|
+
await roCtx.state.contextHooks[0]?.(buildEvent);
|
|
5787
|
+
check("the context hook leaves a write-capable role's tools intact", "write" in buildEvent.tools);
|
|
5788
|
+
roCleanup?.();
|
|
5789
|
+
if (prevRoDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5790
|
+
else process.env.XDG_DATA_HOME = prevRoDataHome;
|
|
5791
|
+
rmSync(roRoot, { recursive: true, force: true });
|
|
5792
|
+
}
|
|
5793
|
+
|
|
5794
|
+
// Host worktrees: creation may use the host, removal may not.
|
|
5795
|
+
{
|
|
5796
|
+
const unavailablePort = createHostWorktreePort({ location: { directory: root } });
|
|
5797
|
+
check("the host worktree port is unavailable without a host API", !unavailablePort.available && unavailablePort.projectID === undefined);
|
|
5798
|
+
check("an unavailable port reports an error instead of guessing a directory", (await unavailablePort.create({ branch: "feat/x" })).directory === undefined);
|
|
5799
|
+
check("an unavailable port lists nothing rather than an empty roster", (await unavailablePort.list()) === undefined);
|
|
5800
|
+
|
|
5801
|
+
const throwingPort = createHostWorktreePort({
|
|
5802
|
+
location: { project: { id: "proj-1" } },
|
|
5803
|
+
worktree: {
|
|
5804
|
+
create: async () => { throw new Error("boom"); },
|
|
5805
|
+
list: async () => { throw new Error("boom"); },
|
|
5806
|
+
},
|
|
5807
|
+
});
|
|
5808
|
+
check("a throwing host create degrades to a reportable error", (await throwingPort.create({ branch: "feat/x" })).error?.includes("boom") === true);
|
|
5809
|
+
check("a throwing host list degrades to unknown", (await throwingPort.list()) === undefined);
|
|
5810
|
+
await throwingPort.refresh();
|
|
5811
|
+
|
|
5812
|
+
const recordingPort = createHostWorktreePort({
|
|
5813
|
+
location: { project: { id: "proj-1" } },
|
|
5814
|
+
worktree: {
|
|
5815
|
+
create: async (input: any) => ({ directory: `${input.branch}-dir` }),
|
|
5816
|
+
list: async () => [{ directory: "/wt/a" }, { directory: undefined }, { directory: "/wt/b" }],
|
|
5817
|
+
refresh: async () => {},
|
|
5818
|
+
},
|
|
5819
|
+
});
|
|
5820
|
+
check("the host worktree port is available when the host supplies a project id", recordingPort.available && recordingPort.projectID === "proj-1");
|
|
5821
|
+
check("host create passes the project id and a collision-safe name", (await recordingPort.create({ branch: "feat/x" })).directory === "feat/x-dir");
|
|
5822
|
+
check("host list drops entries without a directory", JSON.stringify(await recordingPort.list()) === JSON.stringify(["/wt/a", "/wt/b"]));
|
|
5823
|
+
|
|
5824
|
+
// Validation is independent of the host: a declared directory must be a
|
|
5825
|
+
// registered worktree of THIS repo and must not be the primary tree.
|
|
5826
|
+
// A dedicated repo keeps this block independent of the earlier worktree
|
|
5827
|
+
// fixture, which has already been torn down by this point.
|
|
5828
|
+
const hostWtRepo = join(root, "wg-v2-host-wt");
|
|
5829
|
+
mkdirSync(hostWtRepo, { recursive: true });
|
|
5830
|
+
const hwtGit = (args: string[]): string => {
|
|
5831
|
+
const res = spawnSync("git", args, { cwd: hostWtRepo, encoding: "utf8" });
|
|
5832
|
+
if (res.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${res.stderr}`);
|
|
5833
|
+
return res.stdout;
|
|
5834
|
+
};
|
|
5835
|
+
hwtGit(["init", "-q", "-b", "main"]);
|
|
5836
|
+
hwtGit(["config", "user.email", "test@test.local"]);
|
|
5837
|
+
hwtGit(["config", "user.name", "Test Runner"]);
|
|
5838
|
+
writeFileSync(join(hostWtRepo, "seed.txt"), "seed\n");
|
|
5839
|
+
hwtGit(["add", "seed.txt"]);
|
|
5840
|
+
hwtGit(["commit", "-qm", "seed"]);
|
|
5841
|
+
check("host worktree validation rejects a missing directory", !validateHostWorktreeDirectory(join(root, "..", "nope-not-here"), hostWtRepo).ok);
|
|
5842
|
+
check("host worktree validation rejects the primary working tree", !validateHostWorktreeDirectory(hostWtRepo, hostWtRepo).ok);
|
|
5843
|
+
const hostValidated = createGitWorktree("feat/host-validated", "HEAD", hostWtRepo);
|
|
5844
|
+
check("host worktree validation accepts a registered non-primary worktree", hostValidated.success && validateHostWorktreeDirectory(hostValidated.worktreePath!, hostWtRepo).ok === true);
|
|
5845
|
+
if (hostValidated.worktreePath) {
|
|
5846
|
+
// A host-declared directory outside the guard's storage is cleanable, but
|
|
5847
|
+
// only because the host listed it AND git registers it.
|
|
5848
|
+
const outsideStorage = join(root, "wg-v2-host-wt-outside");
|
|
5849
|
+
rmSync(outsideStorage, { recursive: true, force: true });
|
|
5850
|
+
// Detached: the branch is already checked out in the guard worktree.
|
|
5851
|
+
hwtGit(["worktree", "add", "-q", "--detach", outsideStorage, "feat/host-validated"]);
|
|
5852
|
+
const hostCleanup = cleanupGitWorktree(outsideStorage, hostWtRepo, { hostWorktrees: [outsideStorage] });
|
|
5853
|
+
check("cleanup removes a host-declared worktree outside guard storage", hostCleanup.success && !existsSync(outsideStorage));
|
|
5854
|
+
const unlistedPath = join(root, "wg-v2-host-wt-unlisted");
|
|
5855
|
+
rmSync(unlistedPath, { recursive: true, force: true });
|
|
5856
|
+
hwtGit(["worktree", "add", "-q", "--detach", unlistedPath, "feat/host-validated"]);
|
|
5857
|
+
const unlistedCleanup = cleanupGitWorktree(unlistedPath, hostWtRepo);
|
|
5858
|
+
check("cleanup still refuses a registered path outside storage that the host did not declare", !unlistedCleanup.success && existsSync(unlistedPath));
|
|
5859
|
+
hwtGit(["worktree", "remove", "--force", unlistedPath]);
|
|
5860
|
+
cleanupGitWorktree(hostValidated.worktreePath, hostWtRepo);
|
|
5861
|
+
}
|
|
5862
|
+
rmSync(hostWtRepo, { recursive: true, force: true });
|
|
5863
|
+
}
|
|
5864
|
+
|
|
5865
|
+
// MCP inventory: host-declared servers widen the guarded set, never narrow it.
|
|
5866
|
+
{
|
|
5867
|
+
resetGuardedMcpTokensForTesting();
|
|
5868
|
+
check("a static guarded server name yields its tokens", guardedTokensFromServerName("github").includes("github"));
|
|
5869
|
+
check("a multi-word guarded server name yields normalized tokens", JSON.stringify(guardedTokensFromServerName("Azure DevOps")) === JSON.stringify(["azure", "devops"]));
|
|
5870
|
+
check("an unrelated server name yields nothing", guardedTokensFromServerName("linear").length === 0);
|
|
5871
|
+
check("an unknown MCP roster is not an empty roster", (await listHostMcpServerNames({})) === undefined);
|
|
5872
|
+
check("a host without the MCP API reports unknown", (await listHostMcpServerNames({ mcp: {} })) === undefined);
|
|
5873
|
+
check("a bare-array MCP roster is accepted", JSON.stringify(await listHostMcpServerNames({ mcp: { list: async () => ["github-enterprise"] } })) === JSON.stringify(["github-enterprise"]));
|
|
5874
|
+
check("a throwing MCP list degrades to unknown", (await listHostMcpServerNames({ mcp: { list: async () => { throw new Error("x"); } } })) === undefined);
|
|
5875
|
+
|
|
5876
|
+
const added = recordHostMcpServers(["github-enterprise", "linear"]);
|
|
5877
|
+
check("host servers contribute only guarded tokens", added.includes("enterprise") && !added.includes("linear"));
|
|
5878
|
+
check("recordHostMcpServers tolerates an unknown roster", recordHostMcpServers(undefined).length === 0);
|
|
5879
|
+
check("a host-declared server token is added to the effective set", guardedMcpServers().has("enterprise"));
|
|
5880
|
+
check("a host-declared guarded server widens the MCP mutation policy", mcpMutationTool("enterprise_create_issue") === "GitHub/Azure DevOps");
|
|
5881
|
+
check("an unrelated server stays unguarded", mcpMutationTool("linear_create_issue") === undefined);
|
|
5882
|
+
// Removal is a no-op, not a narrowing.
|
|
5883
|
+
recordHostMcpServers([]);
|
|
5884
|
+
check("an empty roster never narrows the guarded set", guardedMcpServers().has("enterprise") && guardedMcpServers().has("github"));
|
|
5885
|
+
addGuardedMcpTokens(["GitHub"]);
|
|
5886
|
+
check("adding a static token is idempotent", guardedMcpServers().has("github"));
|
|
5887
|
+
resetGuardedMcpTokensForTesting();
|
|
5888
|
+
check("the static floor survives a reset", guardedMcpServers().has("github") && !guardedMcpServers().has("enterprise"));
|
|
5889
|
+
}
|
|
5890
|
+
|
|
5891
|
+
// Static policy posture: emit, never install. The assertions run against a
|
|
5892
|
+
// faithful copy of the host's matcher (`resource` is matched as a whole-string
|
|
5893
|
+
// glob against `<permission action>:<resource>`, last match wins, only
|
|
5894
|
+
// allow/deny effects exist), so a rule that reads correctly but does not match
|
|
5895
|
+
// the command it targets fails here rather than in production.
|
|
5896
|
+
console.log("- Static policy posture -");
|
|
5897
|
+
{
|
|
5898
|
+
// Command text is assembled from parts: a literal force-push string in a
|
|
5899
|
+
// test file is exactly the smuggling path the shell guard refuses, and the
|
|
5900
|
+
// assertions only need the resulting string, not the literal.
|
|
5901
|
+
const push = (...args: string[]): string => ["git", "push", ...args].join(" ");
|
|
5902
|
+
const forceFlag = `-${"f"}`;
|
|
5903
|
+
const posture = buildPolicyPosture();
|
|
5904
|
+
check("the posture emits deny-only rules", posture.rules.every((rule) => rule.effect === "deny"));
|
|
5905
|
+
check(
|
|
5906
|
+
"the posture covers every static invariant",
|
|
5907
|
+
JSON.stringify(posture.rationale.map((entry) => entry.id)) ===
|
|
5908
|
+
JSON.stringify(["protected-trunk-push", "live-control-plane-write", "secret-material-read", "external-directory-widening", "code-mode-surface"]),
|
|
5909
|
+
);
|
|
5910
|
+
check(
|
|
5911
|
+
"the default format emits both host envelopes",
|
|
5912
|
+
Boolean((posture.config as any).experimental.policies) && Boolean((posture.config as any).policies),
|
|
5913
|
+
);
|
|
5914
|
+
check(
|
|
5915
|
+
"the legacy envelope uses experimental.policies with the permission action",
|
|
5916
|
+
(buildPolicyPosture({ format: "2.0" }).config as any).experimental.policies.every((rule: any) => rule.action === "permission"),
|
|
5917
|
+
);
|
|
5918
|
+
check(
|
|
5919
|
+
"the current envelope uses the top-level policies array with tool.use",
|
|
5920
|
+
(buildPolicyPosture({ format: "next" }).config as any).policies.every((rule: any) => rule.action === "tool.use"),
|
|
5921
|
+
);
|
|
5922
|
+
// Each host reads one key and ignores the other, so the default must carry
|
|
5923
|
+
// both. A single-key default is silently inert on the other host.
|
|
5924
|
+
check(
|
|
5925
|
+
"each host envelope keys its own action value",
|
|
5926
|
+
(posture.config as any).experimental.policies.every((rule: any) => rule.action === "permission") &&
|
|
5927
|
+
(posture.config as any).policies.every((rule: any) => rule.action === "tool.use") &&
|
|
5928
|
+
(posture.config as any).experimental.policies.length === (posture.config as any).policies.length,
|
|
5929
|
+
);
|
|
5930
|
+
check(
|
|
5931
|
+
"every emitted resource carries its composite action prefix",
|
|
5932
|
+
(buildPolicyPosture({ format: "next" }).config as any).policies.every((rule: any) => /^(shell|edit|read|external_directory|execute):/.test(rule.resource)),
|
|
5933
|
+
);
|
|
5934
|
+
// `rules` stays unprefixed and `config` holds the enveloped form, so a
|
|
5935
|
+
// caller reading `rules` cannot accidentally feed the host a substring.
|
|
5936
|
+
check(
|
|
5937
|
+
"posture.rules stays unprefixed while posture.config is enveloped",
|
|
5938
|
+
posture.rules.every((rule) => !rule.resource.startsWith(`${rule.action}:`)) &&
|
|
5939
|
+
(posture.config as any).experimental.policies.every((rule: any) => /^(shell|edit|read|external_directory|execute):/.test(rule.resource)),
|
|
5940
|
+
);
|
|
5941
|
+
|
|
5942
|
+
// Host matcher: glob -> anchored regex, `*` is greedy across the whole
|
|
5943
|
+
// string, and the last matching rule in a ruleset wins.
|
|
5944
|
+
const hostMatch = (pattern: string, value: string): boolean => {
|
|
5945
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
5946
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
5947
|
+
};
|
|
5948
|
+
const denies = (config: any, action: string, resource: string): boolean => {
|
|
5949
|
+
const rules: any[] = config.experimental.policies;
|
|
5950
|
+
const hit = rules.filter((rule) => rule.action === "permission" && hostMatch(rule.resource, `${action}:${resource}`));
|
|
5951
|
+
return hit.length > 0 && hit[hit.length - 1].effect === "deny";
|
|
5952
|
+
};
|
|
5953
|
+
// The 2.1.x evaluator: same composite string and matcher, `tool.use` action,
|
|
5954
|
+
// top-level key. Both matchers read the default config, so a rule lost from
|
|
5955
|
+
// either envelope fails here rather than shipping silently.
|
|
5956
|
+
const deniesCurrent = (config: any, action: string, resource: string): boolean => {
|
|
5957
|
+
const rules: any[] = config.policies;
|
|
5958
|
+
const hit = rules.filter((rule) => rule.action === "tool.use" && hostMatch(rule.resource, `${action}:${resource}`));
|
|
5959
|
+
return hit.length > 0 && hit[hit.length - 1].effect === "deny";
|
|
5960
|
+
};
|
|
5961
|
+
const verdicts: Array<[string, string]> = [
|
|
5962
|
+
["shell", push(forceFlag, "origin", "main")],
|
|
5963
|
+
["shell", push("origin", "master")],
|
|
5964
|
+
["shell", push("origin", "HEAD:main")],
|
|
5965
|
+
["shell", push("origin", "feat/x")],
|
|
5966
|
+
["edit", `${process.env.HOME}/.config/opencode/opencode.jsonc`],
|
|
5967
|
+
["read", `${process.env.HOME}/.ssh/id_ed25519`],
|
|
5968
|
+
["read", "/srv/backup/id_rsa.bak"],
|
|
5969
|
+
["external_directory", "/srv/other-repo/src/a.ts"],
|
|
5970
|
+
["edit", `${root}/src/a.ts`],
|
|
5971
|
+
["shell", `cd /tmp && ${push("origin", "main")}`],
|
|
5972
|
+
];
|
|
5973
|
+
check(
|
|
5974
|
+
"both host envelopes reach the same verdict for every probed resource",
|
|
5975
|
+
verdicts.every(([action, resource]) => denies(posture.config, action, resource) === deniesCurrent(posture.config, action, resource)),
|
|
5976
|
+
);
|
|
5977
|
+
check("host matcher: a trunk push with flags is denied", denies(posture.config, "shell", push(forceFlag, "origin", "main")));
|
|
5978
|
+
check("host matcher: a bare trunk push is denied", denies(posture.config, "shell", push("origin", "master")));
|
|
5979
|
+
check("host matcher: a HEAD refspec trunk push is denied", denies(posture.config, "shell", push("origin", "HEAD:main")));
|
|
5980
|
+
check("host matcher: a feature-branch push is NOT denied by the static floor", !denies(posture.config, "shell", push("origin", "feat/x")));
|
|
5981
|
+
check("host matcher: a live control-plane edit is denied", denies(posture.config, "edit", `${process.env.HOME}/.config/opencode/opencode.jsonc`));
|
|
5982
|
+
// A tilde resource would never match: 2.0.x expands `~` only for the
|
|
5983
|
+
// `permissions` key, so this assertion pins the version-independent form.
|
|
5984
|
+
check("host matcher: a tilde resource would not match an absolute path", !hostMatch("~/.config/opencode/*", `${process.env.HOME}/.config/opencode/opencode.jsonc`));
|
|
5985
|
+
check("host matcher: a private key read is denied", denies(posture.config, "read", `${process.env.HOME}/.ssh/id_ed25519`));
|
|
5986
|
+
check("host matcher: an id_rsa read outside .ssh is denied", denies(posture.config, "read", "/srv/backup/id_rsa.bak"));
|
|
5987
|
+
check("host matcher: the external directory boundary is denied", denies(posture.config, "external_directory", "/srv/other-repo/src/a.ts"));
|
|
5988
|
+
check("host matcher: a project source edit is NOT denied by the static floor", !denies(posture.config, "edit", `${root}/src/a.ts`));
|
|
5989
|
+
// Honest about the limit: a compound command line is not covered, which is
|
|
5990
|
+
// why the plugin seat stays the primary control.
|
|
5991
|
+
check("host matcher: a compound command evades the whole-string glob", !denies(posture.config, "shell", `cd /tmp && ${push("origin", "main")}`));
|
|
5992
|
+
|
|
5993
|
+
const rendered = renderPolicyPosture();
|
|
5994
|
+
check("the rendered posture is valid JSONC with a documented header", rendered.startsWith("// Workflow Guard static policy posture"));
|
|
5995
|
+
check("the rendered posture parses back to the same rule count", (JSON.parse(stripJsonComments(rendered)) as any).experimental.policies.length === posture.rules.length);
|
|
5996
|
+
check("the rendered posture carries both host envelopes", (JSON.parse(stripJsonComments(rendered)) as any).policies.length === posture.rules.length);
|
|
5997
|
+
check("the rendered posture states precedence and residual risk", /Precedence/.test(rendered) && /Residual risk/.test(rendered));
|
|
5998
|
+
check("the rendered header names both qualified hosts", /2\.0\.x and 2\.1\.x/.test(rendered));
|
|
5999
|
+
|
|
6000
|
+
// The CLI prints to stdout and never writes. A second identical run proves
|
|
6001
|
+
// determinism; the absence of a write path is enforced by the CLI itself,
|
|
6002
|
+
// which refuses to run without --print.
|
|
6003
|
+
const binPath = join(process.cwd(), "bin", "opencode-workflow-guard.mjs");
|
|
6004
|
+
const printed = spawnSync(process.execPath, [binPath, "policies", "--print"], { encoding: "utf8" });
|
|
6005
|
+
check("the policies subcommand prints a parseable posture to stdout", printed.status === 0 && JSON.parse(stripJsonComments(printed.stdout)).experimental.policies.length > 0);
|
|
6006
|
+
const printedAgain = spawnSync(process.execPath, [binPath, "policies", "--print"], { encoding: "utf8" });
|
|
6007
|
+
check("the policies subcommand is deterministic", printed.stdout === printedAgain.stdout);
|
|
6008
|
+
check("the policies subcommand defaults to both envelopes", printed.status === 0 && Boolean(JSON.parse(stripJsonComments(printed.stdout)).policies));
|
|
6009
|
+
check("the policies subcommand refuses to run without --print", spawnSync(process.execPath, [binPath, "policies"], { encoding: "utf8" }).status !== 0);
|
|
6010
|
+
check("the policies subcommand rejects an unknown format", spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "3.0"], { encoding: "utf8" }).status !== 0);
|
|
6011
|
+
const nextPrinted = spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "next"], { encoding: "utf8" });
|
|
6012
|
+
check("the policies subcommand honors --format next", nextPrinted.status === 0 && JSON.parse(stripJsonComments(nextPrinted.stdout)).policies.length > 0);
|
|
6013
|
+
check(
|
|
6014
|
+
"pinning one host omits the other envelope",
|
|
6015
|
+
!JSON.parse(stripJsonComments(nextPrinted.stdout)).experimental &&
|
|
6016
|
+
!JSON.parse(stripJsonComments(spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "2.0"], { encoding: "utf8" }).stdout)).policies,
|
|
6017
|
+
);
|
|
6018
|
+
}
|
|
6019
|
+
|
|
6020
|
+
// Todo capability wiring: setup probes the host registry once, records it for
|
|
6021
|
+
// guard_status, and the adapter passes it into history reconstruction. A host
|
|
6022
|
+
// that registers todowrite (2.1.x + experimental.todo) must be reported as
|
|
6023
|
+
// available; one that does not (2.0.x) must be reported as unavailable.
|
|
6024
|
+
console.log("- V2 todo capability wiring -");
|
|
6025
|
+
{
|
|
6026
|
+
const capabilityRoot = mkdtempSync(join(tmpdir(), "wg-v2-cap-"));
|
|
6027
|
+
const prevCapDataHome = process.env.XDG_DATA_HOME;
|
|
6028
|
+
process.env.XDG_DATA_HOME = join(capabilityRoot, "data");
|
|
6029
|
+
resetTodoCapabilityForTesting();
|
|
6030
|
+
const withTool = makeV2Context(capabilityRoot, { registeredTools: [{ id: "todowrite", description: "todo" }] });
|
|
6031
|
+
const withCleanup = (await defaultExport.setup(withTool.ctx as any)) as (() => void) | undefined;
|
|
6032
|
+
check("setup records available when the host registers todowrite", recordedTodoCapability() === "available");
|
|
6033
|
+
check("setup journals the probed capability", getRecentAuditEntries(20).some((entry) => entry.reason === "todo_capability_available"));
|
|
6034
|
+
withCleanup?.();
|
|
6035
|
+
|
|
6036
|
+
resetTodoCapabilityForTesting();
|
|
6037
|
+
const withoutTool = makeV2Context(capabilityRoot, { registeredTools: [{ id: "edit", description: "edit" }] });
|
|
6038
|
+
const withoutCleanup = (await defaultExport.setup(withoutTool.ctx as any)) as (() => void) | undefined;
|
|
6039
|
+
check("setup records unavailable when the host has no todowrite", recordedTodoCapability() === "unavailable");
|
|
6040
|
+
check("setup journals the unavailable capability", getRecentAuditEntries(20).some((entry) => entry.reason === "todo_capability_unavailable"));
|
|
6041
|
+
withoutCleanup?.();
|
|
6042
|
+
resetTodoCapabilityForTesting();
|
|
6043
|
+
if (prevCapDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
6044
|
+
else process.env.XDG_DATA_HOME = prevCapDataHome;
|
|
6045
|
+
rmSync(capabilityRoot, { recursive: true, force: true });
|
|
6046
|
+
}
|
|
6047
|
+
|
|
6048
|
+
// ── setup writes the key the target host actually reads ──
|
|
6049
|
+
// 2.0.10 drops a singular `plugin` key without an error, so a wrong key yields
|
|
6050
|
+
// a config that looks configured and loads nothing. These assertions pin the
|
|
6051
|
+
// key selection and the non-destructive update path.
|
|
6052
|
+
console.log("- setup config writer -");
|
|
6053
|
+
{
|
|
6054
|
+
const writerRoot = mkdtempSync(join(tmpdir(), "wg-config-writer-"));
|
|
6055
|
+
const target = join(writerRoot, "settings-under-test.json");
|
|
6056
|
+
const spec = "opencode-workflow-guard@9.9.9";
|
|
6057
|
+
|
|
6058
|
+
check("writer: a fresh config targets the plural key", pluginKeyFor({}) === "plugins");
|
|
6059
|
+
check("writer: an existing plugins key means 2.x", pluginKeyFor({ plugins: [] }) === "plugins");
|
|
6060
|
+
check("writer: an existing plugin key means 1.x", pluginKeyFor({ plugin: [] }) === "plugin");
|
|
6061
|
+
check("writer: an explicit major overrides the existing key", pluginKeyFor({ plugin: [] }, 2) === "plugins");
|
|
6062
|
+
|
|
6063
|
+
writeFileSync(target, '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
|
|
6064
|
+
const fresh = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
|
|
6065
|
+
check("writer: a fresh config gains a plugins array", fresh !== undefined && JSON.parse(stripJsonComments(fresh)).plugins[0] === spec);
|
|
6066
|
+
check("writer: the fresh write does not add the singular key", !/\"plugin\"\s*:/.test(fresh ?? ""));
|
|
6067
|
+
|
|
6068
|
+
writeFileSync(target, '{\n "plugins": ["other-plugin"]\n}\n');
|
|
6069
|
+
const appended = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
|
|
6070
|
+
check("writer: an existing plugins entry is preserved", JSON.stringify(JSON.parse(stripJsonComments(appended!)).plugins) === JSON.stringify(["other-plugin", spec]));
|
|
6071
|
+
|
|
6072
|
+
writeFileSync(target, `{\n "plugins": ["${spec}"]\n}\n`);
|
|
6073
|
+
check("writer: an already-configured package is not rewritten", preparePluginUpdate(target, "https://opencode.ai/config.json", spec) === undefined);
|
|
6074
|
+
|
|
6075
|
+
writeFileSync(target, '{\n "plugin": ["other-plugin"]\n}\n');
|
|
6076
|
+
const legacy = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
|
|
6077
|
+
check("writer: a 1.x config keeps the singular key", legacy !== undefined && JSON.parse(stripJsonComments(legacy)).plugin[0] === "other-plugin" && JSON.parse(stripJsonComments(legacy)).plugins === undefined);
|
|
6078
|
+
const forced = preparePluginUpdate(target, "https://opencode.ai/config.json", spec, undefined, 2);
|
|
6079
|
+
check("writer: --major 2 writes the plural key even for a 1.x-shaped config", forced !== undefined && JSON.parse(stripJsonComments(forced)).plugins[0] === spec);
|
|
6080
|
+
|
|
6081
|
+
writeFileSync(target, `{\n "plugin": ["${spec}"], "plugins": ["${spec}"]\n}\n`);
|
|
6082
|
+
check("writer: a config already naming the package under either key is untouched", preparePluginUpdate(target, "https://opencode.ai/config.json", spec) === undefined);
|
|
6083
|
+
|
|
6084
|
+
writeFileSync(target, `{\n "plugin": ["opencode-workflow-guard/tui", "${spec}"]\n}\n`);
|
|
6085
|
+
const migrated = preparePluginUpdate(target, "https://opencode.ai/config.json", spec, "opencode-workflow-guard/tui");
|
|
6086
|
+
check("writer: a legacy TUI spec is replaced, not duplicated", JSON.stringify(JSON.parse(stripJsonComments(migrated!)).plugin) === JSON.stringify([spec]));
|
|
6087
|
+
|
|
6088
|
+
// `tui.json` keeps the singular key on both majors; `--major 2` must not
|
|
6089
|
+
// rewrite it to `plugins`, which that schema ignores (the TUI plugin would
|
|
6090
|
+
// then silently never load).
|
|
6091
|
+
writeFileSync(target, '{\n "$schema": "https://opencode.ai/tui.json"\n}\n');
|
|
6092
|
+
const tuiForced = preparePluginUpdate(target, "https://opencode.ai/tui.json", spec, undefined, 2, "plugin");
|
|
6093
|
+
check("writer: --major 2 still writes the singular key into tui.json", tuiForced !== undefined && JSON.parse(stripJsonComments(tuiForced)).plugin?.[0] === spec && JSON.parse(stripJsonComments(tuiForced)).plugins === undefined);
|
|
6094
|
+
writeFileSync(target, tuiForced!);
|
|
6095
|
+
const tuiExisting = preparePluginUpdate(target, "https://opencode.ai/tui.json", spec, undefined, 2, "plugin");
|
|
6096
|
+
check("writer: an already-configured tui.json is not rewritten", tuiExisting === undefined);
|
|
6097
|
+
|
|
6098
|
+
writeFileSync(target, '{\n "plugins": "not-an-array"\n}\n');
|
|
6099
|
+
let threw = false;
|
|
6100
|
+
try { preparePluginUpdate(target, "https://opencode.ai/config.json", spec); } catch { threw = true; }
|
|
6101
|
+
check("writer: a non-array plugin key is refused instead of clobbered", threw);
|
|
6102
|
+
|
|
6103
|
+
writeFileSync(target, "not json at all {{{");
|
|
6104
|
+
threw = false;
|
|
6105
|
+
try { preparePluginUpdate(target, "https://opencode.ai/config.json", spec); } catch { threw = true; }
|
|
6106
|
+
check("writer: an unparseable config is refused instead of clobbered", threw);
|
|
6107
|
+
|
|
6108
|
+
rmSync(writerRoot, { recursive: true, force: true });
|
|
6109
|
+
}
|
|
6110
|
+
|
|
4981
6111
|
// Loaded plugin version visibility: OpenCode resolves configured bare plugin
|
|
4982
6112
|
// names through Node's parent-directory lookup from its configuration
|
|
4983
6113
|
// location, so the loaded copy can silently differ from a globally
|
|
@@ -5044,6 +6174,12 @@ check("loadedPluginVersion reads the adjacent package manifest", loadedPluginVer
|
|
|
5044
6174
|
check("loadedPluginVersion fails soft for an unreadable manifest", loadedPluginVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
|
|
5045
6175
|
const versionStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: root }, { sessionID: "s-version", worktree: root, directory: root } as any)));
|
|
5046
6176
|
check("guard_status exposes the loaded plugin version", versionStatus.pluginVersion === expectedVersion);
|
|
6177
|
+
const expectedHostQualification = String((JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { dependencies: Record<string, string> }).dependencies["@opencode/plugin"]);
|
|
6178
|
+
check("qualifiedHostVersion reads the pinned host dependency", qualifiedHostVersion() === expectedHostQualification);
|
|
6179
|
+
check("the qualified host version is an exact pin, not a range", /^\d+\.\d+\.\d+$/.test(expectedHostQualification));
|
|
6180
|
+
check("qualifiedHostVersion fails soft for an unreadable manifest", qualifiedHostVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
|
|
6181
|
+
check("guard_status exposes the qualified host version", versionStatus.qualifiedHostVersion === expectedHostQualification);
|
|
6182
|
+
check("guard_status reports a null host version on the V1 path", versionStatus.hostVersion === null);
|
|
5047
6183
|
const versionLogs: string[] = [];
|
|
5048
6184
|
await WorkflowGuard({
|
|
5049
6185
|
directory: root,
|