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/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 { effectiveTodosWithOwner } from "../src/policies/todo.ts";
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,