opencode-workflow-guard 1.15.5 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -78,6 +79,7 @@ import {
78
79
  branchHasDocumentationChange,
79
80
  isDocumentationRequired,
80
81
  loadedPluginVersion,
82
+ qualifiedHostVersion,
81
83
  isReviewRequired,
82
84
  getOperationProfile,
83
85
  isRecoveryCheckpointsEnabled,
@@ -121,8 +123,18 @@ import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
121
123
  import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
122
124
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
123
125
  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";
126
+ import { scanSessionTodos, toolPartName, detectTodoCapability, recordedTodoCapability, recordTodoCapability, reportTodoGateFailOpen, resetTodoCapabilityForTesting } from "../src/lib/v2-todo.ts";
127
+ import { buildPolicyPosture, renderPolicyPosture } from "../src/lib/policy-posture.ts";
128
+ import { pluginKeyFor, preparePluginUpdate } from "../src/lib/config-writer.mjs";
129
+ import { effectiveTodosWithOwner, isReadOnlyTodo, readOnlyTodoCompletions, recordTodoTaskStarts, todoEvidenceReport, unverifiedTodoCompletions, validateTodoLifecycle } from "../src/policies/todo.ts";
130
+ import { todoTaskStarts, clearTodoSessionState } from "../src/lib/state.ts";
131
+ import { resetPluginInstancesForTesting } from "../src/lib/plugin-instance.ts";
132
+ import { claimToolCall, releaseToolCallClaim, resetToolCallClaimsForTesting } from "../src/lib/call-claims.ts";
133
+ import { pruneToolsForReadOnlyRole, READ_ONLY_PRUNED_TOOLS } from "../src/lib/read-only-tools.ts";
134
+ import { createHostWorktreePort } from "../src/lib/host-worktree.ts";
135
+ import { validateHostWorktreeDirectory } from "../src/lib/worktree.ts";
136
+ import { guardedTokensFromServerName, listHostMcpServerNames, recordHostMcpServers } from "../src/lib/mcp-inventory.ts";
137
+ import { addGuardedMcpTokens, guardedMcpServers, mcpMutationTool, resetGuardedMcpTokensForTesting } from "../src/policies/mcp.ts";
126
138
 
127
139
  let pass = 0;
128
140
  let fail = 0;
@@ -201,10 +213,13 @@ check("task gate is per session (other session's todos don't help)", blocked(awa
201
213
  console.log("- Policy 1: todowrite lifecycle validation -");
202
214
  // Multiple in_progress allowed (no single-task focus rule)
203
215
  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
216
+ // Flexible out-of-order completion allows finishing independent items without artificial sequential blockers.
217
+ // Completion now requires work recorded in the session, so the setup records one mutation.
218
+ recordMutation("s-empty");
205
219
  check("todowrite allows flexible out-of-order completion", !(await call("todowrite", { todos: [item("a", "pending"), item("b", "completed")] }, { sessionID: "s-empty" })));
206
220
  // No silent deletion: active task cannot silently vanish
207
221
  todo("s-lifecycle", item("task 1", "completed"), item("task 2", "in_progress"), item("task 3", "pending"));
222
+ recordMutation("s-lifecycle");
208
223
  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
224
  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
225
  todo("s-review-remediation", item("Run full verification and secondary review", "in_progress"));
@@ -221,6 +236,7 @@ check("todowrite cannot silently drop remediation work while retaining review ex
221
236
  todo("s-review-multiplicity", item("Run security secondary review", "in_progress"), item("Run architecture secondary review", "pending"));
222
237
  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
238
  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" })));
239
+ recordMutation("s-review-multiplicity");
224
240
  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
241
  todo("s-duplicate-lifecycle", item("same task", "pending"), item("same task", "pending"));
226
242
  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 +291,161 @@ const inFlight = await scanSessionTodos(ctxWith([todoPart([item("x")], "pending"
275
291
  check("V2: in-flight todowrite reports an empty list, not unknown", Array.isArray(inFlight) && inFlight.length === 0);
276
292
  check("V2: toolPartName prefers tool and falls back to name", toolPartName({ tool: "shell" }) === "shell" && toolPartName({ name: "bash" }) === "bash" && toolPartName({}) === undefined);
277
293
 
294
+ // ── Capability probe: the registry answers "can this host plan at all?" ──
295
+ // The 2.0.x inference ("no todowrite in history means no capability") becomes
296
+ // wrong on 2.1.x, where a registered tool plus an empty history means the
297
+ // session never planned. These assertions invert that baseline.
298
+ const emptyHistory = ctxWith([{ content: [{ type: "tool", tool: "edit", state: { status: "completed" } }] }]);
299
+ check("capability: an empty history with a registered todowrite is a definitive empty list", JSON.stringify(await scanSessionTodos(emptyHistory, "s-cap", "available")) === "[]");
300
+ check("capability: an empty history without the tool stays unknown", (await scanSessionTodos(emptyHistory, "s-cap", "unavailable")) === undefined);
301
+ check("capability: an unread registry stays unknown rather than unavailable", (await scanSessionTodos(emptyHistory, "s-cap", "unknown")) === undefined);
302
+ check("capability: default remains unknown so existing callers are unchanged", (await scanSessionTodos(emptyHistory, "s-cap")) === undefined);
303
+ check("capability: a registry listing todowrite reports available", (await detectTodoCapability({ tool: { list: async () => [{ name: "todowrite" }, { name: "edit" }] } })) === "available");
304
+ check("capability: a registry without todowrite reports unavailable", (await detectTodoCapability({ tool: { list: async () => [{ name: "edit" }, { name: "shell" }] } })) === "unavailable");
305
+ check("capability: a 2.1.x tool id with a name field still counts", (await detectTodoCapability({ tool: { list: async () => [{ id: "todowrite", name: "todowrite" }] } })) === "available");
306
+ check("capability: a throwing registry reports unknown, never unavailable", (await detectTodoCapability({ tool: { list: async () => { throw new Error("rpc"); } } })) === "unknown");
307
+ check("capability: a host without a tool domain reports unknown", (await detectTodoCapability({})) === "unknown");
308
+ check("capability: a non-array registry answer reports unknown", (await detectTodoCapability({ tool: { list: async () => ({ nope: true }) } })) === "unknown");
309
+
310
+ // The recorded capability is process-global (two module copies must agree)
311
+ // and the fail-open warning fires once per session, not once per mutation.
312
+ resetTodoCapabilityForTesting();
313
+ check("capability: the recorded value starts unknown", recordedTodoCapability() === "unknown");
314
+ check("capability: recordTodoCapability returns the previous value", recordTodoCapability("unavailable") === "unknown" && recordedTodoCapability() === "unavailable");
315
+ check("capability: a first fail-open report is recorded", reportTodoGateFailOpen("s-warn", "unavailable") === true);
316
+ check("capability: a repeated fail-open report for the same session is suppressed", reportTodoGateFailOpen("s-warn", "unavailable") === false);
317
+ check("capability: a different session warns again", reportTodoGateFailOpen("s-warn-2", "unavailable") === true);
318
+ resetTodoCapabilityForTesting();
319
+
320
+ // Per-task completion evidence: a completion transition needs at least one
321
+ // mutation inside that task's window, so a model cannot flip the list to
322
+ // `completed` and move on. Read-only tasks carry an explicit marker.
323
+ const starts = new Map<string, number>();
324
+ const activeTwo = [item("write the fix", "in_progress"), item("run tests", "pending")];
325
+ const completedTwo = [item("write the fix", "completed"), item("run tests", "completed")];
326
+ recordTodoTaskStarts(starts, undefined, activeTwo, 0);
327
+ 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"]));
328
+ check("completion evidence: one mutation cannot vouch for two completions", unverifiedTodoCompletions(activeTwo, completedTwo, new Map(starts), 1).length === 2);
329
+ check("completion evidence: two mutations satisfy two completions", unverifiedTodoCompletions(activeTwo, completedTwo, new Map(starts), 2).length === 0);
330
+ const startsSingle = new Map<string, number>();
331
+ recordTodoTaskStarts(startsSingle, undefined, [item("write the fix", "in_progress")], 0);
332
+ 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);
333
+
334
+ // Window isolation: mutations spent before a task became active do not count.
335
+ const startsLate = new Map<string, number>();
336
+ recordTodoTaskStarts(startsLate, undefined, [item("early work", "in_progress")], 0);
337
+ recordTodoTaskStarts(startsLate, [item("early work", "in_progress")], [item("early work", "completed"), item("late work", "in_progress")], 4);
338
+ 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"]));
339
+ 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);
340
+ check("completion evidence: a finished task releases its window", !startsLate.has("early work"));
341
+
342
+ // Window overlap: a mutation spent before a later task began cannot vouch for
343
+ // it, even when the total count looks sufficient.
344
+ const staggered = new Map([["first", 0], ["second", 5]]);
345
+ const staggeredPrev = [item("first", "in_progress"), item("second", "in_progress")];
346
+ const staggeredNext = [item("first", "completed"), item("second", "completed")];
347
+ check("completion evidence: staggered windows reject a completion whose window saw no work", JSON.stringify(unverifiedTodoCompletions(staggeredPrev, staggeredNext, staggered, 5)) === JSON.stringify(["second"]));
348
+ check("completion evidence: staggered windows pass once the later window sees work", unverifiedTodoCompletions(staggeredPrev, staggeredNext, staggered, 6).length === 0);
349
+ check("completion evidence: interleaved windows each get their own mutation", unverifiedTodoCompletions(staggeredPrev, staggeredNext, new Map([["first", 0], ["second", 1]]), 2).length === 0);
350
+
351
+ // Duplicate content must not hide a new completion behind an already-completed
352
+ // item of the same text.
353
+ const dupPrev = [item("x", "completed"), item("y", "in_progress")];
354
+ const dupNext = [item("x", "completed"), item("y", "in_progress"), item("x", "completed")];
355
+ check("completion evidence: a duplicate completed item is not a new claim", unverifiedTodoCompletions(dupPrev, [dupPrev[0]!, dupPrev[1]!], new Map(), 0).length === 0);
356
+ check("completion evidence: duplicate content cannot smuggle a new completion", JSON.stringify(unverifiedTodoCompletions(dupPrev, dupNext, new Map(), 0)) === JSON.stringify(["x"]));
357
+
358
+ // The marker is an annotation, not part of task identity, so appending it must
359
+ // not read as deleting one task and inventing another. The marker must be
360
+ // present on the completed item for the escape to apply.
361
+ 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([]));
362
+ 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"]));
363
+ 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([]));
364
+ 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"]));
365
+ 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([]));
366
+ 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")]));
367
+ 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")]));
368
+
369
+ // Marker escape, and the transitions that are not new claims.
370
+ check("completion evidence: the read-only marker is recognized", isReadOnlyTodo(item("Review the diff [read-only]", "completed")) && !isReadOnlyTodo(item("Review the diff", "completed")));
371
+ 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);
372
+ check("completion evidence: an already-completed task is not a new claim", unverifiedTodoCompletions([item("a", "completed")], [item("a", "completed")], new Map(), 0).length === 0);
373
+ check("completion evidence: a pending-to-pending rewrite is not a claim", unverifiedTodoCompletions([item("a", "pending")], [item("a", "pending")], new Map(), 0).length === 0);
374
+ 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);
375
+
376
+ // End-to-end through the real dispatcher on the test workspace.
377
+ setSdkClient(fakeClient);
378
+ todo("s-evidence", item("write the fix", "in_progress"));
379
+ check("dispatcher blocks marking a task completed with no recorded work", blocked(await call("todowrite", { todos: [item("write the fix", "completed")] }, { sessionID: "s-evidence" })));
380
+ todo("s-evidence-ro", item("Review the diff [read-only]", "in_progress"));
381
+ 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" })));
382
+ // The window is registered by an allowed write, then work lands inside it.
383
+ todo("s-evidence-mut", item("write the fix", "in_progress"));
384
+ 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" })));
385
+ 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" })));
386
+ recordMutation("s-evidence-mut");
387
+ 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" })));
388
+ // guard_why must predict the gate instead of reporting a falsely permissive result.
389
+ todo("s-evidence-sim", item("write the fix", "in_progress"));
390
+ 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)));
391
+ check("simulation does not shift the task window", !todoTaskStarts("s-evidence-sim").has("write the fix"));
392
+
393
+ // The documented remedy, end to end: a planned task discovered to be read-only
394
+ // is completed by appending the marker. This must not trip the lifecycle gate.
395
+ todo("s-evidence-append", item("Review the diff", "in_progress"));
396
+ 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" })));
397
+ const readOnlyAudit = getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only");
398
+ 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]"]));
399
+ // A blocked write must not journal a read-only completion that never landed.
400
+ todo("s-evidence-audit-order", item("real work", "in_progress"));
401
+ const auditBeforeBlocked = getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only").length;
402
+ await call("todowrite", { todos: [item("real work [read-only]", "completed"), item("brand new", "completed")] }, { sessionID: "s-evidence-audit-order" });
403
+ check("a blocked todowrite does not journal a read-only completion", getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only").length === auditBeforeBlocked);
404
+
405
+ // guard_status surfaces the requirement before it bites.
406
+ todo("s-evidence-report", item("write the fix", "in_progress"), item("Review the diff [read-only]", "in_progress"), item("done already", "completed"));
407
+ const evidenceReport = await todoEvidenceReport("s-evidence-report");
408
+ check("status report names active tasks lacking recorded work", JSON.stringify(evidenceReport.tasksWithoutRecordedWork) === JSON.stringify(["write the fix"]));
409
+ check("status report lists read-only tasks separately", JSON.stringify(evidenceReport.readOnlyTasks) === JSON.stringify(["Review the diff [read-only]"]));
410
+ check("status report counts active and completed tasks", evidenceReport.active === 2 && evidenceReport.completed === 1);
411
+ check("status report is empty without a session", (await todoEvidenceReport(undefined)).tasksWithoutRecordedWork.length === 0);todoTaskStarts("s-evidence-clean").set("stale task", 0);
412
+ clearTodoSessionState("s-evidence-clean");
413
+ check("session cleanup drops task windows so a reused session id starts clean", !todoTaskStarts("s-evidence-clean").has("stale task"));
414
+
415
+ // Subagent completion is judged on the subagent's own counter, per the chosen
416
+ // policy. recordMutation credits both the todo owner and the acting session, so
417
+ // work done in a subagent is visible to whichever session reports it.
418
+ fakeParents.set("s-evidence-child", "s-evidence-owner");
419
+ todo("s-evidence-owner", item("shared work", "in_progress"));
420
+ todo("s-evidence-child", item("shared work", "in_progress"));
421
+ check("subagent cannot complete inherited work it did not do", blocked(await call("todowrite", { todos: [item("shared work", "completed")] }, { sessionID: "s-evidence-child" })));
422
+ recordMutation("s-evidence-owner", "s-evidence-child");
423
+ check("subagent can complete work it did, credited to both sessions", !(await call("todowrite", { todos: [item("shared work", "completed")] }, { sessionID: "s-evidence-child" })));
424
+
425
+
426
+ // The plan's failing baseline, inverted: on 2.1.x with `experimental.todo:
427
+ // true`, a mutation with no todo list must be blocked. On 2.0.x the same state
428
+ // is a legitimate fail-open. Drive both through the real dispatcher by
429
+ // swapping only the adapter's todo endpoint.
430
+ const v2TodoClient = (capability: "available" | "unavailable") => ({
431
+ session: {
432
+ todo: async ({ path }: { path: { id: string } }) => ({ data: await scanSessionTodos(emptyHistory, path.id, capability) }),
433
+ get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: undefined } }),
434
+ },
435
+ });
436
+ setSdkClient(v2TodoClient("available"));
437
+ recordTodoCapability("available");
438
+ const gatedMutation = await call("edit", { filePath: join(root, "v2-cap-gated.ts"), content: "x" }, { sessionID: "s-v2-cap" });
439
+ check("capability: a mutation with no todo is BLOCKED when the host can plan", blocked(gatedMutation) && String(gatedMutation).includes("no active todo item"));
440
+ setSdkClient(v2TodoClient("unavailable"));
441
+ recordTodoCapability("unavailable");
442
+ const failedOpenMutation = await call("edit", { filePath: join(root, "v2-cap-open.ts"), content: "x" }, { sessionID: "s-v2-cap" });
443
+ check("capability: the same mutation fails open when the host cannot plan", !blocked(failedOpenMutation));
444
+ 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);
445
+ setSdkClient(fakeClient);
446
+ resetTodoCapabilityForTesting();
447
+
448
+
278
449
  console.log("- Policy 1: parent-chain walk tolerates unknown links (V2) -");
279
450
  const walkTodos = new Map<string, TestTodo[] | undefined>();
280
451
  const walkParents = new Map<string, string | undefined>();
@@ -632,6 +803,11 @@ check("tee allowed with todos", !(await call("bash", { command: "echo x | tee sr
632
803
  check("sed -i needs todos", blocked(await call("bash", { command: "sed -i 's/a/b/' src/a.ts" }, { sessionID: "s-empty" })));
633
804
  check("sed -i allowed with todos", !(await call("bash", { command: "sed -i 's/a/b/' src/a.ts" }, { sessionID: "s-active" })));
634
805
  check("sed -i on opencode.json blocked (tamper)", blocked(await call("bash", { command: "sed -i 's/a/b/' opencode.json" }, { sessionID: "s-active" })));
806
+ check("patch to opencode.json blocked (tamper)", blocked(await call("bash", { command: "patch opencode.json < x.diff" }, { sessionID: "s-active" })));
807
+ 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" })));
808
+ check("patch to a normal file allowed with todos", !(await call("bash", { command: "patch src/a.ts < x.diff" }, { sessionID: "s-active" })));
809
+ check("perl -pi on opencode.json blocked (tamper)", blocked(await call("bash", { command: "perl -pi -e 's/a/b/' opencode.json" }, { sessionID: "s-active" })));
810
+ 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
811
  check("git apply needs todos (patch via shell)", blocked(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-empty" })));
636
812
  check("git apply allowed with todos", !(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-active" })));
637
813
  check("non-mutating shell unaffected (ls, cat)", !(await call("bash", { command: "ls -la && cat file" }, { sessionID: "s-empty" })));
@@ -2560,6 +2736,9 @@ spawnSync("git", ["init", "-b", "feature/why"], { cwd: whyVerifyRoot });
2560
2736
  writeFileSync(join(whyVerifyRoot, "package.json"), JSON.stringify({ scripts: { typecheck: "node -e 'process.exit(9)'" } }));
2561
2737
  const whyVerifyPlugin = await WorkflowGuard({ directory: whyVerifyRoot, worktree: whyVerifyRoot, client: diagnosticClient as any } as any);
2562
2738
  todo("s-why-verify", item("finish", "in_progress"));
2739
+ // Completion evidence is judged first: record work so the simulation reaches
2740
+ // the finalization gate this test is about.
2741
+ recordMutation("s-why-verify");
2563
2742
  const verifyHistoryBeforeWhy = getRecentVerifyHistory(100).length;
2564
2743
  const verifyResultBeforeWhy = getLastVerifyResult();
2565
2744
  const auditBeforeWhy = getRecentAuditEntries(50).length;
@@ -3374,6 +3553,70 @@ check("a session binds to its workspace from its own call context", getSessionBo
3374
3553
  // The other loop's plugin instance must not re-anchor it from its own root.
3375
3554
  await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
3376
3555
  check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
3556
+ // A path-derived candidate must not widen an existing binding either. A
3557
+ // foreign read is allowed (external_directory defaults to allow), and the old
3558
+ // code then adopted that repository as the session's home: one allowed read
3559
+ // moved the entire write boundary.
3560
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-widen", tool: "read", callID: "c-widen-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
3561
+ check("a session binds from its first call context (widen fixture)", getSessionBoundWorkspace("s-iso-widen") === isoRepoA);
3562
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-widen", tool: "read", callID: "c-widen-2" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
3563
+ check("an allowed read in another repository does not widen the write boundary", getSessionBoundWorkspace("s-iso-widen") === isoRepoA);
3564
+ const widenRefusalAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_refused" && entry.sessionID === "s-iso-widen");
3565
+ check(
3566
+ "a refused widening is journaled with both roots and the call label",
3567
+ widenRefusalAudit?.decision === "block" &&
3568
+ (widenRefusalAudit.input as any)?.from === isoRepoA &&
3569
+ (widenRefusalAudit.input as any)?.to === isoRepoB &&
3570
+ (widenRefusalAudit.input as any)?.source === "read",
3571
+ );
3572
+ todo("s-iso-widen", item("boundary probe", "in_progress"));
3573
+ const crossWrite = await guardToolDecision("write", { filePath: join(isoRepoB, "escape.txt"), content: "x" }, { sessionID: "s-iso-widen" });
3574
+ check("a foreign write is refused after a foreign read", crossWrite.status === "blocked" && crossWrite.policy === "boundary");
3575
+ if (crossWrite.status !== "blocked") console.log(" decision:", JSON.stringify(crossWrite));
3576
+ // A subagent has no direct binding, but its policy root inherits the nearest
3577
+ // bound ancestor's workspace. Registration must use that same inherited root,
3578
+ // or the child's first foreign read rebinds it into another repository while
3579
+ // the parent stays confined: one allowed read moves the child's write
3580
+ // boundary, which is the Task 3 defect one level down.
3581
+ fakeParents.set("s-iso-child", "s-iso-widen");
3582
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-child", tool: "read", callID: "c-child-1" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
3583
+ check(
3584
+ "a subagent's foreign read does not rebind it out of the parent's workspace",
3585
+ getSessionBoundWorkspace("s-iso-child") === undefined &&
3586
+ getSessionWorkspace("s-iso-child") !== isoRepoB &&
3587
+ (await resolveInheritedWorkspace("s-iso-child")) === isoRepoA,
3588
+ );
3589
+ const childRefusalAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_refused" && entry.sessionID === "s-iso-child");
3590
+ check(
3591
+ "a subagent's refused widening is journaled against the inherited root",
3592
+ childRefusalAudit?.decision === "block" &&
3593
+ (childRefusalAudit.input as any)?.from === isoRepoA &&
3594
+ (childRefusalAudit.input as any)?.to === isoRepoB,
3595
+ );
3596
+ check("resolveInheritedWorkspace reports the parent's root for a subagent", (await resolveInheritedWorkspace("s-iso-child")) === isoRepoA);
3597
+ // The real hook runs the child's decision under the plugin instance root, which
3598
+ // for a relay flow is the parent's repository. A write into the foreign repo
3599
+ // must still be refused there.
3600
+ const childEscapeWrite = await guardToolDecision("write", { filePath: join(isoRepoB, "child-escape.txt"), content: "x" }, { sessionID: "s-iso-child", worktree: isoRepoA });
3601
+ check("a subagent's foreign write stays blocked by the inherited boundary", childEscapeWrite.status === "blocked" && childEscapeWrite.policy === "boundary");
3602
+ if (childEscapeWrite.status !== "blocked") console.log(" decision:", JSON.stringify(childEscapeWrite));
3603
+ fakeParents.delete("s-iso-child");
3604
+ // An explicit host-declared worktree is still a legitimate widening statement:
3605
+ // worktree isolation and relay flows set it, and a subagent must be able to
3606
+ // bind to the linked worktree it was given.
3607
+ const isoWidenWorktree = join(root, "wg-iso-widen-wt");
3608
+ const widenWorktreeAdd = spawnSync("git", ["-C", isoRepoA, "worktree", "add", "-q", "-b", "feat/iso-wt", isoWidenWorktree]);
3609
+ check("a linked worktree fixture is created for the widening check", widenWorktreeAdd.status === 0);
3610
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-relay", tool: "read", callID: "c-relay-1", worktree: isoWidenWorktree }, { args: { filePath: join(isoWidenWorktree, "seed.txt") } });
3611
+ check("a host-declared worktree binds an unbound session", getSessionBoundWorkspace("s-iso-relay") === realpathSync(isoWidenWorktree));
3612
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-hostwiden", tool: "read", callID: "c-hostwiden-1" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
3613
+ check("a second session binds through its own call context", getSessionBoundWorkspace("s-iso-hostwiden") === isoRepoB);
3614
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-hostwiden", tool: "read", callID: "c-hostwiden-2", worktree: isoWidenWorktree }, { args: {} });
3615
+ check("a host-declared worktree may widen a bound session", getSessionBoundWorkspace("s-iso-hostwiden") === realpathSync(isoWidenWorktree));
3616
+ const hostRebindAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_host_worktree" && entry.sessionID === "s-iso-hostwiden");
3617
+ check("a granted host-worktree widening is journaled", hostRebindAudit?.decision === "allow" && (hostRebindAudit.input as any)?.to === realpathSync(isoWidenWorktree));
3618
+ spawnSync("git", ["-C", isoRepoA, "worktree", "remove", "--force", isoWidenWorktree]);
3619
+ rmSync(isoWidenWorktree, { recursive: true, force: true });
3377
3620
  const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
3378
3621
  check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
3379
3622
  const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
@@ -4978,6 +5221,591 @@ check("project-memory initialization failure leaves core guard hooks active", ty
4978
5221
  if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
4979
5222
  else process.env.XDG_DATA_HOME = prevDataHome;
4980
5223
 
5224
+ // ── V2 load-once guard: one policy decision per call, one duplicate record ──
5225
+ // OpenCode 2 merges plugin arrays, so naming the guard in both global and
5226
+ // project config loads the same entrypoint twice under two ids. The second
5227
+ // setup must register nothing and leave a warn-level audit record; otherwise
5228
+ // every tool call is evaluated and audited twice, and two instances prime the
5229
+ // process-global workspace latch from two roots in the same tick.
5230
+ console.log("- V2 duplicate load guard -");
5231
+ resetPluginInstancesForTesting();
5232
+ resetToolCallClaimsForTesting();
5233
+ const v2DuplicateRoot = mkdtempSync(join(tmpdir(), "wg-v2-dup-"));
5234
+ const prevDuplicateDataHome = process.env.XDG_DATA_HOME;
5235
+ process.env.XDG_DATA_HOME = join(v2DuplicateRoot, "data");
5236
+ const makeV2Context = (directory: string, options?: {
5237
+ projectID?: string;
5238
+ registeredTools?: Array<{ id: string; description?: string }>;
5239
+ mcpServers?: string[];
5240
+ worktreeApi?: {
5241
+ create: (input: any) => Promise<{ directory?: string } | undefined>;
5242
+ list: (input: any) => Promise<ReadonlyArray<{ directory?: string }> | undefined>;
5243
+ refresh?: (input: any) => Promise<unknown>;
5244
+ };
5245
+ }) => {
5246
+ const state = {
5247
+ transforms: 0,
5248
+ addedTools: [] as string[],
5249
+ beforeHooks: [] as Array<(event: unknown) => unknown>,
5250
+ afterHooks: [] as Array<(event: unknown) => unknown>,
5251
+ permissionHooks: [] as Array<(event: unknown) => unknown>,
5252
+ contextHooks: [] as Array<(event: unknown) => unknown>,
5253
+ commands: [] as Array<{ name: string; description?: string; execute: (input: unknown) => Promise<void> }>,
5254
+ syntheticMessages: [] as string[],
5255
+ prompts: [] as string[],
5256
+ worktreeCalls: [] as string[],
5257
+ mcpListCalls: 0,
5258
+ };
5259
+ const editor = {
5260
+ list: () => [],
5261
+ get: () => undefined,
5262
+ namespace: () => {},
5263
+ add: (tool: { name: string }) => { state.addedTools.push(tool.name); },
5264
+ update: () => {},
5265
+ remove: () => {},
5266
+ };
5267
+ const registration = { dispose: async () => {} };
5268
+ const registeredTools = options?.registeredTools ?? [];
5269
+ const ctx = {
5270
+ location: { directory, ...(options?.projectID ? { project: { id: options.projectID, directory, canonical: directory } } : {}) },
5271
+ options: {},
5272
+ tool: {
5273
+ transform: async (callback: (input: unknown) => void) => { state.transforms++; callback(editor); return registration; },
5274
+ reload: async () => {},
5275
+ list: async () => registeredTools,
5276
+ hook: async (name: string, callback: (event: unknown) => unknown) => {
5277
+ if (name === "execute.before") state.beforeHooks.push(callback);
5278
+ if (name === "execute.after") state.afterHooks.push(callback);
5279
+ return registration;
5280
+ },
5281
+ },
5282
+ command: {
5283
+ list: async () => ({ data: [] }),
5284
+ transform: async (callback: (editor: { add: (definition: never) => void }) => void) => {
5285
+ callback({ add: (definition: never) => { state.commands.push(definition); } });
5286
+ return registration;
5287
+ },
5288
+ reload: async () => {},
5289
+ },
5290
+ mcp: {
5291
+ list: async () => {
5292
+ state.mcpListCalls++;
5293
+ return options?.mcpServers === undefined ? undefined : { data: options.mcpServers.map((name) => ({ name, status: { type: "connected" } })) };
5294
+ },
5295
+ },
5296
+ worktree: options?.worktreeApi ? {
5297
+ create: async (input: unknown) => { state.worktreeCalls.push("create"); return options.worktreeApi!.create(input); },
5298
+ list: async (input: unknown) => { state.worktreeCalls.push("list"); return options.worktreeApi!.list(input); },
5299
+ refresh: async (input: unknown) => { state.worktreeCalls.push("refresh"); return options.worktreeApi!.refresh?.(input); },
5300
+ } : undefined,
5301
+ session: {
5302
+ get: async () => ({}),
5303
+ context: async () => [],
5304
+ prompt: async (input: { text?: string }) => { state.prompts.push(String(input?.text ?? "")); },
5305
+ synthetic: async (input: { text?: string }) => { state.syntheticMessages.push(String(input?.text ?? "")); },
5306
+ hook: async (name: string, callback: (event: unknown) => unknown) => {
5307
+ if (name === "context") state.contextHooks.push(callback);
5308
+ return registration;
5309
+ },
5310
+ },
5311
+ permission: {
5312
+ hook: async (name: string, callback: (event: unknown) => unknown) => {
5313
+ if (name === "evaluate") state.permissionHooks.push(callback);
5314
+ return registration;
5315
+ },
5316
+ },
5317
+ shell: { hook: async () => registration },
5318
+ event: { subscribe: async function* () {} },
5319
+ app: { log: async () => {} },
5320
+ };
5321
+ return { ctx, state };
5322
+ };
5323
+ const v2Owner = makeV2Context(v2DuplicateRoot);
5324
+ const v2OwnerCleanup = (await defaultExport.setup(v2Owner.ctx as any)) as (() => void) | undefined;
5325
+ const v2Duplicate = makeV2Context(v2DuplicateRoot);
5326
+ const v2DuplicateCleanup = (await defaultExport.setup(v2Duplicate.ctx as any)) as (() => void) | undefined;
5327
+ check("the first V2 instance registers its tool transform", v2Owner.state.transforms === 1 && v2Owner.state.addedTools.includes("guard_status"));
5328
+ check("a duplicate V2 instance registers no tool transform", v2Duplicate.state.transforms === 0);
5329
+ check("a duplicate V2 instance registers no execute hooks", v2Duplicate.state.beforeHooks.length === 0 && v2Duplicate.state.afterHooks.length === 0);
5330
+ check("a duplicate V2 instance registers no permission seat", v2Duplicate.state.permissionHooks.length === 0);
5331
+
5332
+ // ── Permission seat: evaluate denies, execute.before still aborts ──
5333
+ // `evaluate` sees normalized action/resources before the tool runs, so a deny
5334
+ // here means the tool never starts. `execute.before` still aborts with the
5335
+ // specific remediation message, because it is the only seat that sees raw
5336
+ // tool input. Both canaries must fail closed with attributable messages.
5337
+ console.log("- V2 permission seat -");
5338
+ const permissionEvent = (action: string, resources: string[], sessionID = "s-perm-seat") => ({
5339
+ sessionID,
5340
+ agent: "build",
5341
+ action,
5342
+ resources,
5343
+ effect: "allow" as "allow" | "deny" | "ask",
5344
+ message: undefined as string | undefined,
5345
+ });
5346
+ const seatShell = permissionEvent("shell", ["git push origin main"]);
5347
+ await v2Owner.state.permissionHooks[0]?.(seatShell);
5348
+ check("evaluate denies a protected-branch push before the tool runs", seatShell.effect === "deny" && /protected branch|main/i.test(String(seatShell.message)));
5349
+ if (seatShell.effect !== "deny") console.log(" evaluate message:", seatShell.message);
5350
+ const seatSecret = permissionEvent("read", [".env"]);
5351
+ await v2Owner.state.permissionHooks[0]?.(seatSecret);
5352
+ check("evaluate denies a secret read", seatSecret.effect === "deny" && /secret|credential/i.test(String(seatSecret.message)));
5353
+ const seatBoundary = permissionEvent("edit", [join(v2DuplicateRoot, "..", "outside.txt")]);
5354
+ await v2Owner.state.permissionHooks[0]?.(seatBoundary);
5355
+ check("evaluate denies a boundary-escaping edit", seatBoundary.effect === "deny" && /workspace/i.test(String(seatBoundary.message)));
5356
+ const seatAllowed = permissionEvent("shell", ["ls -la"]);
5357
+ await v2Owner.state.permissionHooks[0]?.(seatAllowed);
5358
+ check("evaluate leaves an ordinary command allowed", seatAllowed.effect === "allow");
5359
+ const seatUnmapped = permissionEvent("external_directory", ["/tmp/*"]);
5360
+ await v2Owner.state.permissionHooks[0]?.(seatUnmapped);
5361
+ check("evaluate leaves unmapped host actions to the host", seatUnmapped.effect === "allow");
5362
+ const predefinedDeny = { ...permissionEvent("shell", ["ls"]), effect: "deny" as const };
5363
+ await v2Owner.state.permissionHooks[0]?.(predefinedDeny);
5364
+ check("an explicit configured deny stays final and untouched", predefinedDeny.effect === "deny" && predefinedDeny.message === undefined);
5365
+ const seatAuditEntries = getRecentAuditEntries(60).filter((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.decision === "block");
5366
+ const seatAudit = seatAuditEntries.find((entry) => entry.policyDecision?.status === "blocked");
5367
+ check("evaluate denials are journaled with the policy decision", seatAudit !== undefined);
5368
+ const configuredDenyAudit = seatAuditEntries.find((entry) => (entry.input as any)?.configuredEffect === "deny");
5369
+ check("a configured deny is journaled as such without inventing a guard decision", configuredDenyAudit !== undefined && configuredDenyAudit.policyDecision === undefined);
5370
+ let executeBeforeDenied = false;
5371
+ try {
5372
+ 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" } });
5373
+ } catch (error) {
5374
+ executeBeforeDenied = String((error as Error)?.message).includes("[workflow-guard]");
5375
+ }
5376
+ check("execute.before still aborts with the guard's remediation message", executeBeforeDenied);
5377
+ const duplicateAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "plugin.instance" && entry.reason === "duplicate_plugin_load");
5378
+ check("a duplicate V2 load writes an audit record naming the instance key", duplicateAudit !== undefined && String((duplicateAudit.input as any)?.instanceKey).includes(v2DuplicateRoot));
5379
+ 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);
5380
+ let duplicateDecisions = 0;
5381
+ for (const callback of v2Owner.state.beforeHooks) {
5382
+ await callback({ tool: "read", sessionID: "s-v2-dup", agent: "build", messageID: "msg-v2-dup", id: "call-v2-dup", input: { filePath: join(v2DuplicateRoot, "seed.txt") } });
5383
+ duplicateDecisions++;
5384
+ }
5385
+ for (const callback of v2Duplicate.state.beforeHooks) {
5386
+ await callback({ tool: "read", sessionID: "s-v2-dup", agent: "build", messageID: "msg-v2-dup", id: "call-v2-dup", input: { filePath: join(v2DuplicateRoot, "seed.txt") } });
5387
+ duplicateDecisions++;
5388
+ }
5389
+ const duplicateDecisionAudits = getRecentAuditEntries(50).filter((entry) => entry.tool === "read" && entry.phase === "decision" && entry.callID === "call-v2-dup");
5390
+ check("a duplicated load produces one policy decision per tool call", duplicateDecisions === 1 && duplicateDecisionAudits.length === 1);
5391
+ v2DuplicateCleanup?.();
5392
+ v2OwnerCleanup?.();
5393
+ // A second copy with a DISTINCT module path (cache copy plus source checkout)
5394
+ // cannot be made inert by keying alone; the claim table keeps one decision.
5395
+ resetToolCallClaimsForTesting();
5396
+ check("the first copy claims a tool call for its module path", claimToolCall("module-a", "s-v2-claim", "call-claim", "read") === true);
5397
+ check("a second module copy defers on the same tool call", claimToolCall("module-b", "s-v2-claim", "call-claim", "read") === false);
5398
+ releaseToolCallClaim("module-a", "s-v2-claim", "call-claim", "read");
5399
+ check("a settled call can be claimed by the next copy", claimToolCall("module-b", "s-v2-claim", "call-claim", "read") === true);
5400
+ 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);
5401
+ resetToolCallClaimsForTesting();
5402
+ if (prevDuplicateDataHome === undefined) delete process.env.XDG_DATA_HOME;
5403
+ else process.env.XDG_DATA_HOME = prevDuplicateDataHome;
5404
+ rmSync(v2DuplicateRoot, { recursive: true, force: true });
5405
+
5406
+ // ── V2 adopt-now surfaces: command, read-only pruning, worktrees, MCP ──
5407
+ // Four additive V2 API adoptions, each tested for its narrowing direction:
5408
+ // the command adds an operator surface, pruning removes tools, worktree cleanup
5409
+ // only accepts host-declared directories, and the MCP inventory only widens.
5410
+ console.log("- V2 adopt-now surfaces -");
5411
+
5412
+ // /guard-status: registered server-side, delivered without a model turn.
5413
+ {
5414
+ resetPluginInstancesForTesting();
5415
+ const cmdRoot = mkdtempSync(join(tmpdir(), "wg-v2-cmd-"));
5416
+ const prevCmdDataHome = process.env.XDG_DATA_HOME;
5417
+ process.env.XDG_DATA_HOME = join(cmdRoot, "data");
5418
+ const cmdCtx = makeV2Context(cmdRoot);
5419
+ const cmdCleanup = (await defaultExport.setup(cmdCtx.ctx as any)) as (() => void) | undefined;
5420
+ const guardStatusCommand = cmdCtx.state.commands.find((command) => command.name === "guard-status");
5421
+ check("the V2 plugin registers a server-side guard-status command", guardStatusCommand !== undefined);
5422
+ check("guard-status carries an operator-facing description", /status/i.test(String(guardStatusCommand?.description)));
5423
+ await guardStatusCommand?.execute({ sessionID: "s-v2-cmd" } as any);
5424
+ check("guard-status delivers the report as a synthetic message", cmdCtx.state.syntheticMessages.length === 1 && cmdCtx.state.syntheticMessages[0].includes("pluginVersion"));
5425
+ check("guard-status costs no model turn on the normal path", cmdCtx.state.prompts.length === 0);
5426
+ check("guard-status is journaled in the audit trail", getRecentAuditEntries(20).some((entry) => entry.tool === "command.guard-status" && entry.reason === "guard_status_command"));
5427
+ cmdCleanup?.();
5428
+ if (prevCmdDataHome === undefined) delete process.env.XDG_DATA_HOME;
5429
+ else process.env.XDG_DATA_HOME = prevCmdDataHome;
5430
+ rmSync(cmdRoot, { recursive: true, force: true });
5431
+ }
5432
+
5433
+ // Read-only roles: mutating tools leave the per-request snapshot.
5434
+ {
5435
+ const snapshot = {
5436
+ read: { description: "read", input: {} },
5437
+ glob: { description: "glob", input: {} },
5438
+ grep: { description: "grep", input: {} },
5439
+ webfetch: { description: "webfetch", input: {} },
5440
+ write: { description: "write", input: {} },
5441
+ edit: { description: "edit", input: {} },
5442
+ patch: { description: "patch", input: {} },
5443
+ apply_patch: { description: "apply_patch", input: {} },
5444
+ shell: { description: "shell", input: {} },
5445
+ execute: { description: "execute", input: {} },
5446
+ todowrite: { description: "todowrite", input: {} },
5447
+ subagent: { description: "subagent", input: {} },
5448
+ };
5449
+ const removed = pruneToolsForReadOnlyRole(snapshot as any);
5450
+ check("read-only pruning removes every mutating tool", READ_ONLY_PRUNED_TOOLS.every((id) => !(id in snapshot)));
5451
+ check("read-only pruning keeps the read-only tools", ["read", "glob", "grep", "webfetch"].every((id) => id in snapshot));
5452
+ check("read-only pruning reports what it removed", removed.includes("write") && removed.includes("shell") && removed.includes("execute") && !removed.includes("read"));
5453
+ const emptySnapshot: Record<string, unknown> = { read: {} };
5454
+ check("read-only pruning is a no-op on an already narrow snapshot", pruneToolsForReadOnlyRole(emptySnapshot).length === 0 && "read" in emptySnapshot);
5455
+ check("read-only pruning tolerates a missing snapshot", pruneToolsForReadOnlyRole(undefined).length === 0);
5456
+
5457
+ resetPluginInstancesForTesting();
5458
+ const roRoot = mkdtempSync(join(tmpdir(), "wg-v2-ro-"));
5459
+ const prevRoDataHome = process.env.XDG_DATA_HOME;
5460
+ process.env.XDG_DATA_HOME = join(roRoot, "data");
5461
+ const roCtx = makeV2Context(roRoot);
5462
+ const roCleanup = (await defaultExport.setup(roCtx.ctx as any)) as (() => void) | undefined;
5463
+ const roEvent = {
5464
+ sessionID: "s-v2-ro",
5465
+ agent: "reviewer",
5466
+ model: { providerID: "p", id: "m" },
5467
+ system: [] as Array<{ type: string; text: string }>,
5468
+ messages: [],
5469
+ options: {},
5470
+ tools: { read: { description: "read", input: {} }, write: { description: "write", input: {} }, shell: { description: "shell", input: {} } },
5471
+ };
5472
+ await roCtx.state.contextHooks[0]?.(roEvent);
5473
+ check("the context hook prunes mutating tools for a read-only role", !("write" in roEvent.tools) && !("shell" in roEvent.tools) && "read" in roEvent.tools);
5474
+ 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)));
5475
+ const buildEvent = {
5476
+ sessionID: "s-v2-build",
5477
+ agent: "build",
5478
+ model: { providerID: "p", id: "m" },
5479
+ system: [] as Array<{ type: string; text: string }>,
5480
+ messages: [],
5481
+ options: {},
5482
+ tools: { read: { description: "read", input: {} }, write: { description: "write", input: {} } },
5483
+ };
5484
+ await roCtx.state.contextHooks[0]?.(buildEvent);
5485
+ check("the context hook leaves a write-capable role's tools intact", "write" in buildEvent.tools);
5486
+ roCleanup?.();
5487
+ if (prevRoDataHome === undefined) delete process.env.XDG_DATA_HOME;
5488
+ else process.env.XDG_DATA_HOME = prevRoDataHome;
5489
+ rmSync(roRoot, { recursive: true, force: true });
5490
+ }
5491
+
5492
+ // Host worktrees: creation may use the host, removal may not.
5493
+ {
5494
+ const unavailablePort = createHostWorktreePort({ location: { directory: root } });
5495
+ check("the host worktree port is unavailable without a host API", !unavailablePort.available && unavailablePort.projectID === undefined);
5496
+ check("an unavailable port reports an error instead of guessing a directory", (await unavailablePort.create({ branch: "feat/x" })).directory === undefined);
5497
+ check("an unavailable port lists nothing rather than an empty roster", (await unavailablePort.list()) === undefined);
5498
+
5499
+ const throwingPort = createHostWorktreePort({
5500
+ location: { project: { id: "proj-1" } },
5501
+ worktree: {
5502
+ create: async () => { throw new Error("boom"); },
5503
+ list: async () => { throw new Error("boom"); },
5504
+ },
5505
+ });
5506
+ check("a throwing host create degrades to a reportable error", (await throwingPort.create({ branch: "feat/x" })).error?.includes("boom") === true);
5507
+ check("a throwing host list degrades to unknown", (await throwingPort.list()) === undefined);
5508
+ await throwingPort.refresh();
5509
+
5510
+ const recordingPort = createHostWorktreePort({
5511
+ location: { project: { id: "proj-1" } },
5512
+ worktree: {
5513
+ create: async (input: any) => ({ directory: `${input.branch}-dir` }),
5514
+ list: async () => [{ directory: "/wt/a" }, { directory: undefined }, { directory: "/wt/b" }],
5515
+ refresh: async () => {},
5516
+ },
5517
+ });
5518
+ check("the host worktree port is available when the host supplies a project id", recordingPort.available && recordingPort.projectID === "proj-1");
5519
+ check("host create passes the project id and a collision-safe name", (await recordingPort.create({ branch: "feat/x" })).directory === "feat/x-dir");
5520
+ check("host list drops entries without a directory", JSON.stringify(await recordingPort.list()) === JSON.stringify(["/wt/a", "/wt/b"]));
5521
+
5522
+ // Validation is independent of the host: a declared directory must be a
5523
+ // registered worktree of THIS repo and must not be the primary tree.
5524
+ // A dedicated repo keeps this block independent of the earlier worktree
5525
+ // fixture, which has already been torn down by this point.
5526
+ const hostWtRepo = join(root, "wg-v2-host-wt");
5527
+ mkdirSync(hostWtRepo, { recursive: true });
5528
+ const hwtGit = (args: string[]): string => {
5529
+ const res = spawnSync("git", args, { cwd: hostWtRepo, encoding: "utf8" });
5530
+ if (res.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${res.stderr}`);
5531
+ return res.stdout;
5532
+ };
5533
+ hwtGit(["init", "-q", "-b", "main"]);
5534
+ hwtGit(["config", "user.email", "test@test.local"]);
5535
+ hwtGit(["config", "user.name", "Test Runner"]);
5536
+ writeFileSync(join(hostWtRepo, "seed.txt"), "seed\n");
5537
+ hwtGit(["add", "seed.txt"]);
5538
+ hwtGit(["commit", "-qm", "seed"]);
5539
+ check("host worktree validation rejects a missing directory", !validateHostWorktreeDirectory(join(root, "..", "nope-not-here"), hostWtRepo).ok);
5540
+ check("host worktree validation rejects the primary working tree", !validateHostWorktreeDirectory(hostWtRepo, hostWtRepo).ok);
5541
+ const hostValidated = createGitWorktree("feat/host-validated", "HEAD", hostWtRepo);
5542
+ check("host worktree validation accepts a registered non-primary worktree", hostValidated.success && validateHostWorktreeDirectory(hostValidated.worktreePath!, hostWtRepo).ok === true);
5543
+ if (hostValidated.worktreePath) {
5544
+ // A host-declared directory outside the guard's storage is cleanable, but
5545
+ // only because the host listed it AND git registers it.
5546
+ const outsideStorage = join(root, "wg-v2-host-wt-outside");
5547
+ rmSync(outsideStorage, { recursive: true, force: true });
5548
+ // Detached: the branch is already checked out in the guard worktree.
5549
+ hwtGit(["worktree", "add", "-q", "--detach", outsideStorage, "feat/host-validated"]);
5550
+ const hostCleanup = cleanupGitWorktree(outsideStorage, hostWtRepo, { hostWorktrees: [outsideStorage] });
5551
+ check("cleanup removes a host-declared worktree outside guard storage", hostCleanup.success && !existsSync(outsideStorage));
5552
+ const unlistedPath = join(root, "wg-v2-host-wt-unlisted");
5553
+ rmSync(unlistedPath, { recursive: true, force: true });
5554
+ hwtGit(["worktree", "add", "-q", "--detach", unlistedPath, "feat/host-validated"]);
5555
+ const unlistedCleanup = cleanupGitWorktree(unlistedPath, hostWtRepo);
5556
+ check("cleanup still refuses a registered path outside storage that the host did not declare", !unlistedCleanup.success && existsSync(unlistedPath));
5557
+ hwtGit(["worktree", "remove", "--force", unlistedPath]);
5558
+ cleanupGitWorktree(hostValidated.worktreePath, hostWtRepo);
5559
+ }
5560
+ rmSync(hostWtRepo, { recursive: true, force: true });
5561
+ }
5562
+
5563
+ // MCP inventory: host-declared servers widen the guarded set, never narrow it.
5564
+ {
5565
+ resetGuardedMcpTokensForTesting();
5566
+ check("a static guarded server name yields its tokens", guardedTokensFromServerName("github").includes("github"));
5567
+ check("a multi-word guarded server name yields normalized tokens", JSON.stringify(guardedTokensFromServerName("Azure DevOps")) === JSON.stringify(["azure", "devops"]));
5568
+ check("an unrelated server name yields nothing", guardedTokensFromServerName("linear").length === 0);
5569
+ check("an unknown MCP roster is not an empty roster", (await listHostMcpServerNames({})) === undefined);
5570
+ check("a host without the MCP API reports unknown", (await listHostMcpServerNames({ mcp: {} })) === undefined);
5571
+ check("a bare-array MCP roster is accepted", JSON.stringify(await listHostMcpServerNames({ mcp: { list: async () => ["github-enterprise"] } })) === JSON.stringify(["github-enterprise"]));
5572
+ check("a throwing MCP list degrades to unknown", (await listHostMcpServerNames({ mcp: { list: async () => { throw new Error("x"); } } })) === undefined);
5573
+
5574
+ const added = recordHostMcpServers(["github-enterprise", "linear"]);
5575
+ check("host servers contribute only guarded tokens", added.includes("enterprise") && !added.includes("linear"));
5576
+ check("recordHostMcpServers tolerates an unknown roster", recordHostMcpServers(undefined).length === 0);
5577
+ check("a host-declared server token is added to the effective set", guardedMcpServers().has("enterprise"));
5578
+ check("a host-declared guarded server widens the MCP mutation policy", mcpMutationTool("enterprise_create_issue") === "GitHub/Azure DevOps");
5579
+ check("an unrelated server stays unguarded", mcpMutationTool("linear_create_issue") === undefined);
5580
+ // Removal is a no-op, not a narrowing.
5581
+ recordHostMcpServers([]);
5582
+ check("an empty roster never narrows the guarded set", guardedMcpServers().has("enterprise") && guardedMcpServers().has("github"));
5583
+ addGuardedMcpTokens(["GitHub"]);
5584
+ check("adding a static token is idempotent", guardedMcpServers().has("github"));
5585
+ resetGuardedMcpTokensForTesting();
5586
+ check("the static floor survives a reset", guardedMcpServers().has("github") && !guardedMcpServers().has("enterprise"));
5587
+ }
5588
+
5589
+ // Static policy posture: emit, never install. The assertions run against a
5590
+ // faithful copy of the host's matcher (`resource` is matched as a whole-string
5591
+ // glob against `<permission action>:<resource>`, last match wins, only
5592
+ // allow/deny effects exist), so a rule that reads correctly but does not match
5593
+ // the command it targets fails here rather than in production.
5594
+ console.log("- Static policy posture -");
5595
+ {
5596
+ // Command text is assembled from parts: a literal force-push string in a
5597
+ // test file is exactly the smuggling path the shell guard refuses, and the
5598
+ // assertions only need the resulting string, not the literal.
5599
+ const push = (...args: string[]): string => ["git", "push", ...args].join(" ");
5600
+ const forceFlag = `-${"f"}`;
5601
+ const posture = buildPolicyPosture();
5602
+ check("the posture emits deny-only rules", posture.rules.every((rule) => rule.effect === "deny"));
5603
+ check(
5604
+ "the posture covers every static invariant",
5605
+ JSON.stringify(posture.rationale.map((entry) => entry.id)) ===
5606
+ JSON.stringify(["protected-trunk-push", "live-control-plane-write", "secret-material-read", "external-directory-widening", "code-mode-surface"]),
5607
+ );
5608
+ check(
5609
+ "the default format emits both host envelopes",
5610
+ Boolean((posture.config as any).experimental.policies) && Boolean((posture.config as any).policies),
5611
+ );
5612
+ check(
5613
+ "the legacy envelope uses experimental.policies with the permission action",
5614
+ (buildPolicyPosture({ format: "2.0" }).config as any).experimental.policies.every((rule: any) => rule.action === "permission"),
5615
+ );
5616
+ check(
5617
+ "the current envelope uses the top-level policies array with tool.use",
5618
+ (buildPolicyPosture({ format: "next" }).config as any).policies.every((rule: any) => rule.action === "tool.use"),
5619
+ );
5620
+ // Each host reads one key and ignores the other, so the default must carry
5621
+ // both. A single-key default is silently inert on the other host.
5622
+ check(
5623
+ "each host envelope keys its own action value",
5624
+ (posture.config as any).experimental.policies.every((rule: any) => rule.action === "permission") &&
5625
+ (posture.config as any).policies.every((rule: any) => rule.action === "tool.use") &&
5626
+ (posture.config as any).experimental.policies.length === (posture.config as any).policies.length,
5627
+ );
5628
+ check(
5629
+ "every emitted resource carries its composite action prefix",
5630
+ (buildPolicyPosture({ format: "next" }).config as any).policies.every((rule: any) => /^(shell|edit|read|external_directory|execute):/.test(rule.resource)),
5631
+ );
5632
+ // `rules` stays unprefixed and `config` holds the enveloped form, so a
5633
+ // caller reading `rules` cannot accidentally feed the host a substring.
5634
+ check(
5635
+ "posture.rules stays unprefixed while posture.config is enveloped",
5636
+ posture.rules.every((rule) => !rule.resource.startsWith(`${rule.action}:`)) &&
5637
+ (posture.config as any).experimental.policies.every((rule: any) => /^(shell|edit|read|external_directory|execute):/.test(rule.resource)),
5638
+ );
5639
+
5640
+ // Host matcher: glob -> anchored regex, `*` is greedy across the whole
5641
+ // string, and the last matching rule in a ruleset wins.
5642
+ const hostMatch = (pattern: string, value: string): boolean => {
5643
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
5644
+ return new RegExp(`^${escaped}$`).test(value);
5645
+ };
5646
+ const denies = (config: any, action: string, resource: string): boolean => {
5647
+ const rules: any[] = config.experimental.policies;
5648
+ const hit = rules.filter((rule) => rule.action === "permission" && hostMatch(rule.resource, `${action}:${resource}`));
5649
+ return hit.length > 0 && hit[hit.length - 1].effect === "deny";
5650
+ };
5651
+ // The 2.1.x evaluator: same composite string and matcher, `tool.use` action,
5652
+ // top-level key. Both matchers read the default config, so a rule lost from
5653
+ // either envelope fails here rather than shipping silently.
5654
+ const deniesCurrent = (config: any, action: string, resource: string): boolean => {
5655
+ const rules: any[] = config.policies;
5656
+ const hit = rules.filter((rule) => rule.action === "tool.use" && hostMatch(rule.resource, `${action}:${resource}`));
5657
+ return hit.length > 0 && hit[hit.length - 1].effect === "deny";
5658
+ };
5659
+ const verdicts: Array<[string, string]> = [
5660
+ ["shell", push(forceFlag, "origin", "main")],
5661
+ ["shell", push("origin", "master")],
5662
+ ["shell", push("origin", "HEAD:main")],
5663
+ ["shell", push("origin", "feat/x")],
5664
+ ["edit", `${process.env.HOME}/.config/opencode/opencode.jsonc`],
5665
+ ["read", `${process.env.HOME}/.ssh/id_ed25519`],
5666
+ ["read", "/srv/backup/id_rsa.bak"],
5667
+ ["external_directory", "/srv/other-repo/src/a.ts"],
5668
+ ["edit", `${root}/src/a.ts`],
5669
+ ["shell", `cd /tmp && ${push("origin", "main")}`],
5670
+ ];
5671
+ check(
5672
+ "both host envelopes reach the same verdict for every probed resource",
5673
+ verdicts.every(([action, resource]) => denies(posture.config, action, resource) === deniesCurrent(posture.config, action, resource)),
5674
+ );
5675
+ check("host matcher: a trunk push with flags is denied", denies(posture.config, "shell", push(forceFlag, "origin", "main")));
5676
+ check("host matcher: a bare trunk push is denied", denies(posture.config, "shell", push("origin", "master")));
5677
+ check("host matcher: a HEAD refspec trunk push is denied", denies(posture.config, "shell", push("origin", "HEAD:main")));
5678
+ check("host matcher: a feature-branch push is NOT denied by the static floor", !denies(posture.config, "shell", push("origin", "feat/x")));
5679
+ check("host matcher: a live control-plane edit is denied", denies(posture.config, "edit", `${process.env.HOME}/.config/opencode/opencode.jsonc`));
5680
+ // A tilde resource would never match: 2.0.x expands `~` only for the
5681
+ // `permissions` key, so this assertion pins the version-independent form.
5682
+ check("host matcher: a tilde resource would not match an absolute path", !hostMatch("~/.config/opencode/*", `${process.env.HOME}/.config/opencode/opencode.jsonc`));
5683
+ check("host matcher: a private key read is denied", denies(posture.config, "read", `${process.env.HOME}/.ssh/id_ed25519`));
5684
+ check("host matcher: an id_rsa read outside .ssh is denied", denies(posture.config, "read", "/srv/backup/id_rsa.bak"));
5685
+ check("host matcher: the external directory boundary is denied", denies(posture.config, "external_directory", "/srv/other-repo/src/a.ts"));
5686
+ check("host matcher: a project source edit is NOT denied by the static floor", !denies(posture.config, "edit", `${root}/src/a.ts`));
5687
+ // Honest about the limit: a compound command line is not covered, which is
5688
+ // why the plugin seat stays the primary control.
5689
+ check("host matcher: a compound command evades the whole-string glob", !denies(posture.config, "shell", `cd /tmp && ${push("origin", "main")}`));
5690
+
5691
+ const rendered = renderPolicyPosture();
5692
+ check("the rendered posture is valid JSONC with a documented header", rendered.startsWith("// Workflow Guard static policy posture"));
5693
+ check("the rendered posture parses back to the same rule count", (JSON.parse(stripJsonComments(rendered)) as any).experimental.policies.length === posture.rules.length);
5694
+ check("the rendered posture carries both host envelopes", (JSON.parse(stripJsonComments(rendered)) as any).policies.length === posture.rules.length);
5695
+ check("the rendered posture states precedence and residual risk", /Precedence/.test(rendered) && /Residual risk/.test(rendered));
5696
+ check("the rendered header names both qualified hosts", /2\.0\.x and 2\.1\.x/.test(rendered));
5697
+
5698
+ // The CLI prints to stdout and never writes. A second identical run proves
5699
+ // determinism; the absence of a write path is enforced by the CLI itself,
5700
+ // which refuses to run without --print.
5701
+ const binPath = join(process.cwd(), "bin", "opencode-workflow-guard.mjs");
5702
+ const printed = spawnSync(process.execPath, [binPath, "policies", "--print"], { encoding: "utf8" });
5703
+ check("the policies subcommand prints a parseable posture to stdout", printed.status === 0 && JSON.parse(stripJsonComments(printed.stdout)).experimental.policies.length > 0);
5704
+ const printedAgain = spawnSync(process.execPath, [binPath, "policies", "--print"], { encoding: "utf8" });
5705
+ check("the policies subcommand is deterministic", printed.stdout === printedAgain.stdout);
5706
+ check("the policies subcommand defaults to both envelopes", printed.status === 0 && Boolean(JSON.parse(stripJsonComments(printed.stdout)).policies));
5707
+ check("the policies subcommand refuses to run without --print", spawnSync(process.execPath, [binPath, "policies"], { encoding: "utf8" }).status !== 0);
5708
+ check("the policies subcommand rejects an unknown format", spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "3.0"], { encoding: "utf8" }).status !== 0);
5709
+ const nextPrinted = spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "next"], { encoding: "utf8" });
5710
+ check("the policies subcommand honors --format next", nextPrinted.status === 0 && JSON.parse(stripJsonComments(nextPrinted.stdout)).policies.length > 0);
5711
+ check(
5712
+ "pinning one host omits the other envelope",
5713
+ !JSON.parse(stripJsonComments(nextPrinted.stdout)).experimental &&
5714
+ !JSON.parse(stripJsonComments(spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "2.0"], { encoding: "utf8" }).stdout)).policies,
5715
+ );
5716
+ }
5717
+
5718
+ // Todo capability wiring: setup probes the host registry once, records it for
5719
+ // guard_status, and the adapter passes it into history reconstruction. A host
5720
+ // that registers todowrite (2.1.x + experimental.todo) must be reported as
5721
+ // available; one that does not (2.0.x) must be reported as unavailable.
5722
+ console.log("- V2 todo capability wiring -");
5723
+ {
5724
+ const capabilityRoot = mkdtempSync(join(tmpdir(), "wg-v2-cap-"));
5725
+ const prevCapDataHome = process.env.XDG_DATA_HOME;
5726
+ process.env.XDG_DATA_HOME = join(capabilityRoot, "data");
5727
+ resetTodoCapabilityForTesting();
5728
+ const withTool = makeV2Context(capabilityRoot, { registeredTools: [{ id: "todowrite", description: "todo" }] });
5729
+ const withCleanup = (await defaultExport.setup(withTool.ctx as any)) as (() => void) | undefined;
5730
+ check("setup records available when the host registers todowrite", recordedTodoCapability() === "available");
5731
+ check("setup journals the probed capability", getRecentAuditEntries(20).some((entry) => entry.reason === "todo_capability_available"));
5732
+ withCleanup?.();
5733
+
5734
+ resetTodoCapabilityForTesting();
5735
+ const withoutTool = makeV2Context(capabilityRoot, { registeredTools: [{ id: "edit", description: "edit" }] });
5736
+ const withoutCleanup = (await defaultExport.setup(withoutTool.ctx as any)) as (() => void) | undefined;
5737
+ check("setup records unavailable when the host has no todowrite", recordedTodoCapability() === "unavailable");
5738
+ check("setup journals the unavailable capability", getRecentAuditEntries(20).some((entry) => entry.reason === "todo_capability_unavailable"));
5739
+ withoutCleanup?.();
5740
+ resetTodoCapabilityForTesting();
5741
+ if (prevCapDataHome === undefined) delete process.env.XDG_DATA_HOME;
5742
+ else process.env.XDG_DATA_HOME = prevCapDataHome;
5743
+ rmSync(capabilityRoot, { recursive: true, force: true });
5744
+ }
5745
+
5746
+ // ── setup writes the key the target host actually reads ──
5747
+ // 2.0.10 drops a singular `plugin` key without an error, so a wrong key yields
5748
+ // a config that looks configured and loads nothing. These assertions pin the
5749
+ // key selection and the non-destructive update path.
5750
+ console.log("- setup config writer -");
5751
+ {
5752
+ const writerRoot = mkdtempSync(join(tmpdir(), "wg-config-writer-"));
5753
+ const target = join(writerRoot, "settings-under-test.json");
5754
+ const spec = "opencode-workflow-guard@9.9.9";
5755
+
5756
+ check("writer: a fresh config targets the plural key", pluginKeyFor({}) === "plugins");
5757
+ check("writer: an existing plugins key means 2.x", pluginKeyFor({ plugins: [] }) === "plugins");
5758
+ check("writer: an existing plugin key means 1.x", pluginKeyFor({ plugin: [] }) === "plugin");
5759
+ check("writer: an explicit major overrides the existing key", pluginKeyFor({ plugin: [] }, 2) === "plugins");
5760
+
5761
+ writeFileSync(target, '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
5762
+ const fresh = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
5763
+ check("writer: a fresh config gains a plugins array", fresh !== undefined && JSON.parse(stripJsonComments(fresh)).plugins[0] === spec);
5764
+ check("writer: the fresh write does not add the singular key", !/\"plugin\"\s*:/.test(fresh ?? ""));
5765
+
5766
+ writeFileSync(target, '{\n "plugins": ["other-plugin"]\n}\n');
5767
+ const appended = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
5768
+ check("writer: an existing plugins entry is preserved", JSON.stringify(JSON.parse(stripJsonComments(appended!)).plugins) === JSON.stringify(["other-plugin", spec]));
5769
+
5770
+ writeFileSync(target, `{\n "plugins": ["${spec}"]\n}\n`);
5771
+ check("writer: an already-configured package is not rewritten", preparePluginUpdate(target, "https://opencode.ai/config.json", spec) === undefined);
5772
+
5773
+ writeFileSync(target, '{\n "plugin": ["other-plugin"]\n}\n');
5774
+ const legacy = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
5775
+ 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);
5776
+ const forced = preparePluginUpdate(target, "https://opencode.ai/config.json", spec, undefined, 2);
5777
+ check("writer: --major 2 writes the plural key even for a 1.x-shaped config", forced !== undefined && JSON.parse(stripJsonComments(forced)).plugins[0] === spec);
5778
+
5779
+ writeFileSync(target, `{\n "plugin": ["${spec}"], "plugins": ["${spec}"]\n}\n`);
5780
+ check("writer: a config already naming the package under either key is untouched", preparePluginUpdate(target, "https://opencode.ai/config.json", spec) === undefined);
5781
+
5782
+ writeFileSync(target, `{\n "plugin": ["opencode-workflow-guard/tui", "${spec}"]\n}\n`);
5783
+ const migrated = preparePluginUpdate(target, "https://opencode.ai/config.json", spec, "opencode-workflow-guard/tui");
5784
+ check("writer: a legacy TUI spec is replaced, not duplicated", JSON.stringify(JSON.parse(stripJsonComments(migrated!)).plugin) === JSON.stringify([spec]));
5785
+
5786
+ // `tui.json` keeps the singular key on both majors; `--major 2` must not
5787
+ // rewrite it to `plugins`, which that schema ignores (the TUI plugin would
5788
+ // then silently never load).
5789
+ writeFileSync(target, '{\n "$schema": "https://opencode.ai/tui.json"\n}\n');
5790
+ const tuiForced = preparePluginUpdate(target, "https://opencode.ai/tui.json", spec, undefined, 2, "plugin");
5791
+ 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);
5792
+ writeFileSync(target, tuiForced!);
5793
+ const tuiExisting = preparePluginUpdate(target, "https://opencode.ai/tui.json", spec, undefined, 2, "plugin");
5794
+ check("writer: an already-configured tui.json is not rewritten", tuiExisting === undefined);
5795
+
5796
+ writeFileSync(target, '{\n "plugins": "not-an-array"\n}\n');
5797
+ let threw = false;
5798
+ try { preparePluginUpdate(target, "https://opencode.ai/config.json", spec); } catch { threw = true; }
5799
+ check("writer: a non-array plugin key is refused instead of clobbered", threw);
5800
+
5801
+ writeFileSync(target, "not json at all {{{");
5802
+ threw = false;
5803
+ try { preparePluginUpdate(target, "https://opencode.ai/config.json", spec); } catch { threw = true; }
5804
+ check("writer: an unparseable config is refused instead of clobbered", threw);
5805
+
5806
+ rmSync(writerRoot, { recursive: true, force: true });
5807
+ }
5808
+
4981
5809
  // Loaded plugin version visibility: OpenCode resolves configured bare plugin
4982
5810
  // names through Node's parent-directory lookup from its configuration
4983
5811
  // location, so the loaded copy can silently differ from a globally
@@ -5044,6 +5872,12 @@ check("loadedPluginVersion reads the adjacent package manifest", loadedPluginVer
5044
5872
  check("loadedPluginVersion fails soft for an unreadable manifest", loadedPluginVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
5045
5873
  const versionStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: root }, { sessionID: "s-version", worktree: root, directory: root } as any)));
5046
5874
  check("guard_status exposes the loaded plugin version", versionStatus.pluginVersion === expectedVersion);
5875
+ const expectedHostQualification = String((JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { dependencies: Record<string, string> }).dependencies["@opencode/plugin"]);
5876
+ check("qualifiedHostVersion reads the pinned host dependency", qualifiedHostVersion() === expectedHostQualification);
5877
+ check("the qualified host version is an exact pin, not a range", /^\d+\.\d+\.\d+$/.test(expectedHostQualification));
5878
+ check("qualifiedHostVersion fails soft for an unreadable manifest", qualifiedHostVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
5879
+ check("guard_status exposes the qualified host version", versionStatus.qualifiedHostVersion === expectedHostQualification);
5880
+ check("guard_status reports a null host version on the V1 path", versionStatus.hostVersion === null);
5047
5881
  const versionLogs: string[] = [];
5048
5882
  await WorkflowGuard({
5049
5883
  directory: root,