opencode-workflow-guard 1.13.6 → 1.14.1

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
@@ -27,6 +27,8 @@ import {
27
27
  getCurrentGitCommitHash,
28
28
  getGitStatusSummary,
29
29
  getGitWorktreeFingerprint,
30
+ getTrackedWorktreeFingerprint,
31
+ resolveWorktreeTimeoutMs,
30
32
  getVerifyCacheFilePath,
31
33
  persistVerifyCache,
32
34
  loadVerifyCache,
@@ -980,6 +982,56 @@ try {
980
982
  }
981
983
  check("stale-write protection allows creating a new file without a prior read", newFileAllowed);
982
984
  await staleAfter?.({ tool: "write", sessionID: "s-stale", callID: "new-file", args: {} }, { title: "write", output: "written", metadata: {} });
985
+ // A successful mutation seeds the session's observation of the bytes it
986
+ // authored, so iterating on a just-created/modified file needs no re-read;
987
+ // a no-op mutation seeds nothing and external changes stay blocked.
988
+ const mutationSession = "s-stale-mutation";
989
+ todo(mutationSession, item("iterate on authored file", "in_progress"));
990
+ const authoredPath = join(staleDir, "authored.ts");
991
+ await staleBefore?.({ tool: "write", sessionID: mutationSession, callID: "write-authored" }, { args: { filePath: authoredPath, content: "v1" } });
992
+ writeFileSync(authoredPath, "v1");
993
+ await staleAfter?.({ tool: "write", sessionID: mutationSession, callID: "write-authored", args: {} }, { title: "write", output: "written", metadata: {} });
994
+ let authoredEditAllowed = true;
995
+ try {
996
+ await staleBefore?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-1" }, { args: { filePath: authoredPath, oldString: "v1", newString: "v2" } });
997
+ } catch {
998
+ authoredEditAllowed = false;
999
+ }
1000
+ check("a successful write seeds the session observation so the authored file can be edited without a re-read", authoredEditAllowed);
1001
+ writeFileSync(authoredPath, "v2");
1002
+ await staleAfter?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-1", args: {} }, { title: "edit", output: "edited", metadata: {} });
1003
+ let chainedEditAllowed = true;
1004
+ try {
1005
+ await staleBefore?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-2" }, { args: { filePath: authoredPath, oldString: "v2", newString: "v3" } });
1006
+ } catch {
1007
+ chainedEditAllowed = false;
1008
+ }
1009
+ check("a successful edit seeds the observation so chained edits need no re-read", chainedEditAllowed);
1010
+ writeFileSync(authoredPath, "changed externally");
1011
+ let externalAfterMutationBlocked = false;
1012
+ try {
1013
+ await staleBefore?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-3" }, { args: { filePath: authoredPath, oldString: "v3", newString: "v4" } });
1014
+ } catch (error) {
1015
+ externalAfterMutationBlocked = String(error).includes("changed since this session read it");
1016
+ }
1017
+ check("an external change after a successful mutation is still blocked", externalAfterMutationBlocked);
1018
+ // Negative branch: a mutation that leaves content unchanged must not seed.
1019
+ // apply_patch is not stale-write checked, so this unobserved session can run
1020
+ // it; because the file is left unchanged, the seed gate must skip and a
1021
+ // later edit must still demand a read.
1022
+ const noopSession = "s-stale-noop";
1023
+ todo(noopSession, item("no-op patch", "in_progress"));
1024
+ const noopPath = join(staleDir, "noop.ts");
1025
+ writeFileSync(noopPath, "stable");
1026
+ await staleBefore?.({ tool: "apply_patch", sessionID: noopSession, callID: "noop-patch" }, { args: { patchText: `*** Begin Patch\n*** Update File: ${noopPath}\n@@\n stable\n*** End Patch` } });
1027
+ await staleAfter?.({ tool: "apply_patch", sessionID: noopSession, callID: "noop-patch", args: {} }, { title: "patch", output: "done", metadata: {} });
1028
+ let noopEditBlocked = false;
1029
+ try {
1030
+ await staleBefore?.({ tool: "edit", sessionID: noopSession, callID: "noop-edit" }, { args: { filePath: noopPath, oldString: "stable", newString: "changed" } });
1031
+ } catch (error) {
1032
+ noopEditBlocked = String(error).includes("has not been read");
1033
+ }
1034
+ check("a no-op mutation does not seed an observation for a later edit", noopEditBlocked);
983
1035
  const staleAlternateRoot = mkdtempSync(join(tmpdir(), "wg-stale-root-"));
984
1036
  writeFileSync(join(staleAlternateRoot, "relative.ts"), "observed");
985
1037
  todo("s-stale-root", item("edit relative file", "in_progress"));
@@ -1718,10 +1770,10 @@ check("evidence freshness requires exact subject and post-mutation observation",
1718
1770
  const missingProvenanceEvidence = verificationEvidence({ passed: true, command: "npm test", output: "ok", timestamp: 123, workspaceRoot: "/tmp/ws" }, "s-evidence");
1719
1771
  check("evidence freshness fails closed when Git provenance is unavailable", !isEvidenceFresh(missingProvenanceEvidence, { workspace: "/tmp/ws", sessionID: "s-evidence" }, 0) && !isEvidenceFresh(verificationEvidenceResult, { workspace: "/tmp/ws", sessionID: "s-evidence" }, 0));
1720
1772
  await call("edit", { filePath: join(root, "after-review.ts"), content: "changed" }, { sessionID: "s-active" });
1721
- check("new mutation invalidates prior review approval", getLastReviewResult() === undefined);
1773
+ check("mutations no longer erase review approval state (freshness is re-evaluated against content)", getLastReviewResult() !== undefined);
1722
1774
  recordReviewResult("reviewer-subagent", "Child review passed all axes.", true, "s-reviewed-child");
1723
1775
  recordMutation("s-reviewed-parent", "s-reviewed-child");
1724
- check("actor mutation invalidates child-targeted global review", getLastReviewResult() === undefined);
1776
+ check("actor mutation no longer erases child-targeted review evidence", getLastReviewResult()?.targetSessionID === "s-reviewed-child");
1725
1777
 
1726
1778
  const fingerprintRepo = mkdtempSync(join(tmpdir(), "wg-review-fingerprint-"));
1727
1779
  spawnSync("git", ["init", "-b", "feature/review"], { cwd: fingerprintRepo });
@@ -1791,6 +1843,29 @@ symlinkSync(join(specialRepo, "missing-target-2"), join(specialRepo, "broken-lin
1791
1843
  check("adding an untracked symlink updates the fingerprint", getGitWorktreeFingerprint(specialRepo) !== fpWithSpecials);
1792
1844
  rmSync(specialRepo, { recursive: true, force: true });
1793
1845
  rmSync(linkTargetBase, { recursive: true, force: true });
1846
+ // Review evidence binds to tracked content only: untracked scratch files are
1847
+ // not part of the reviewed diff, so creating or deleting them must keep the
1848
+ // approval fresh, while the full fingerprint still covers untracked contents
1849
+ // for verification evidence.
1850
+ const trackedFpRepo = mkdtempSync(join(tmpdir(), "wg-review-tracked-fp-"));
1851
+ spawnSync("git", ["init", "-b", "main"], { cwd: trackedFpRepo });
1852
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: trackedFpRepo });
1853
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: trackedFpRepo });
1854
+ writeFileSync(join(trackedFpRepo, "tracked.txt"), "tracked\n");
1855
+ spawnSync("git", ["add", "tracked.txt"], { cwd: trackedFpRepo });
1856
+ spawnSync("git", ["commit", "-m", "base"], { cwd: trackedFpRepo });
1857
+ const trackedFpBase = getTrackedWorktreeFingerprint(trackedFpRepo);
1858
+ check("tracked-content fingerprint is available for a git worktree", typeof trackedFpBase === "string" && trackedFpBase.length === 64);
1859
+ writeFileSync(join(trackedFpRepo, ".tmp-review-final.diff"), "scratch\n");
1860
+ check("untracked scratch creation does not change the tracked-content fingerprint", getTrackedWorktreeFingerprint(trackedFpRepo) === trackedFpBase);
1861
+ const fullFpWithScratch = getGitWorktreeFingerprint(trackedFpRepo);
1862
+ writeFileSync(join(trackedFpRepo, ".tmp-review-final.diff"), "scratch v2\n");
1863
+ check("full fingerprint still binds untracked contents for verification evidence", getGitWorktreeFingerprint(trackedFpRepo) !== fullFpWithScratch);
1864
+ rmSync(join(trackedFpRepo, ".tmp-review-final.diff"));
1865
+ check("untracked scratch deletion does not change the tracked-content fingerprint", getTrackedWorktreeFingerprint(trackedFpRepo) === trackedFpBase);
1866
+ writeFileSync(join(trackedFpRepo, "tracked.txt"), "tracked edit\n");
1867
+ check("tracked-content fingerprint changes on a tracked edit", getTrackedWorktreeFingerprint(trackedFpRepo) !== trackedFpBase);
1868
+ rmSync(trackedFpRepo, { recursive: true, force: true });
1794
1869
 
1795
1870
  // 9. Secret-File READ Blocks (Policy 17)
1796
1871
  console.log("- Policy 17: Secret-File READ Blocks -");
@@ -2237,6 +2312,17 @@ try {
2237
2312
  rmSync(whyRemoteRoot, { recursive: true, force: true });
2238
2313
  }
2239
2314
 
2315
+ // record_review binds verdicts to tracked repository content, so its tool
2316
+ // tests need a real git worktree rather than the non-git test root.
2317
+ const reviewToolRepo = join(root, "wg-review-tool-repo");
2318
+ mkdirSync(reviewToolRepo, { recursive: true });
2319
+ spawnSync("git", ["init", "-b", "main"], { cwd: reviewToolRepo });
2320
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: reviewToolRepo });
2321
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: reviewToolRepo });
2322
+ writeFileSync(join(reviewToolRepo, "code.txt"), "reviewed\n");
2323
+ spawnSync("git", ["add", "code.txt"], { cwd: reviewToolRepo });
2324
+ spawnSync("git", ["commit", "-m", "base"], { cwd: reviewToolRepo });
2325
+
2240
2326
  fakeParents.set("s-reviewer-tool", "s-active");
2241
2327
  const unrelatedClient = {
2242
2328
  session: {
@@ -2255,15 +2341,15 @@ await (defaultExport?.server ?? WorkflowGuard)({
2255
2341
  });
2256
2342
  const reviewToolResult = await customPlugin.tool?.record_review?.execute(
2257
2343
  { reviewer: "subagent-1", summary: "Test integrity: real assertions. Task completeness: done. Cleanliness: no stubs. Security: clean. Platform fit: ok.", passed: true },
2258
- { sessionID: "s-reviewer-tool", agent: "reviewer", worktree: root, directory: root } as any,
2344
+ { sessionID: "s-reviewer-tool", agent: "reviewer", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2259
2345
  );
2260
- check("record_review resolves subagent lineage through its own plugin client", typeof reviewToolResult === "string" && reviewToolResult.includes("APPROVED"));
2346
+ check("record_review resolves subagent lineage through its own plugin client", typeof reviewToolResult === "string" && reviewToolResult.includes("APPROVED") && reviewToolResult.includes(reviewToolRepo));
2261
2347
  setSdkClient(fakeClient);
2262
2348
 
2263
2349
  fakeParents.set("s-reviewer-changes", "s-active");
2264
2350
  const changesReviewResult = await customPlugin.tool?.record_review?.execute(
2265
2351
  { reviewer: "subagent-changes", summary: "Test integrity: gap found. Task completeness: needs work. Cleanliness: ok. Security: ok. Platform: ok.", passed: false },
2266
- { sessionID: "s-reviewer-changes", agent: "reviewer", worktree: root, directory: root } as any,
2352
+ { sessionID: "s-reviewer-changes", agent: "reviewer", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2267
2353
  );
2268
2354
  const changesReviewAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-reviewer-changes");
2269
2355
  check("record_review audit distinguishes changes-requested verdict without persisting summary", typeof changesReviewResult === "string" && changesReviewResult.includes("CHANGES REQUESTED") && changesReviewAudit?.reason === "changes_requested" && !JSON.stringify(changesReviewAudit).includes("gap found"));
@@ -2275,7 +2361,7 @@ await customPlugin.tool?.record_review?.execute(
2275
2361
  summary: "Test integrity: covered. Task completeness: done. Cleanliness: clean. Security: safe. Platform: compatible.\nP2: first durable issue\nP3: second durable issue",
2276
2362
  passed: true,
2277
2363
  },
2278
- { sessionID: "s-reviewer-followups", agent: "reviewer", worktree: root, directory: root } as any,
2364
+ { sessionID: "s-reviewer-followups", agent: "reviewer", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2279
2365
  );
2280
2366
  const durableReviewFollowups = JSON.parse(String(await customPlugin.tool?.guard_review_followups?.execute({}, {} as any))) as Array<{ severity?: string; summary?: string }>;
2281
2367
  check("record_review persists multiple P2/P3 findings independently", durableReviewFollowups.some((item) => item.severity === "P2" && item.summary?.includes("first durable issue")) && durableReviewFollowups.some((item) => item.severity === "P3" && item.summary?.includes("second durable issue")));
@@ -2316,11 +2402,50 @@ check(
2316
2402
  const rubricOptionFlag = await customPlugin.tool?.guard_review_rubric?.execute({ base: "--output=injected" }, {} as any);
2317
2403
  check("guard_review_rubric sanitizes option flags in base ref", typeof rubricOptionFlag === "string" && !existsSync(join(root, "injected")));
2318
2404
 
2405
+ // Regression: the rubric must embed the real branch diff. The git argv
2406
+ // previously placed `--` before the revision, so the revision parsed as a
2407
+ // pathspec and every rubric carried an empty diff block.
2408
+ const rubricDiffRepo = join(root, "wg-review-rubric-diff");
2409
+ mkdirSync(rubricDiffRepo, { recursive: true });
2410
+ spawnSync("git", ["init", "-b", "main"], { cwd: rubricDiffRepo });
2411
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: rubricDiffRepo });
2412
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: rubricDiffRepo });
2413
+ writeFileSync(join(rubricDiffRepo, "seed.txt"), "base\n");
2414
+ spawnSync("git", ["add", "seed.txt"], { cwd: rubricDiffRepo });
2415
+ spawnSync("git", ["commit", "-m", "base"], { cwd: rubricDiffRepo });
2416
+ spawnSync("git", ["switch", "-c", "feature/rubric-diff"], { cwd: rubricDiffRepo });
2417
+ writeFileSync(join(rubricDiffRepo, "added.txt"), "export const rubricDiffMarker = 42;\n");
2418
+ spawnSync("git", ["add", "added.txt"], { cwd: rubricDiffRepo });
2419
+ spawnSync("git", ["commit", "-m", "add rubric marker"], { cwd: rubricDiffRepo });
2420
+ const rubricWithDiff = await customPlugin.tool?.guard_review_rubric?.execute(
2421
+ { base: "main", directory: rubricDiffRepo },
2422
+ { sessionID: "s-rubric-diff", worktree: rubricDiffRepo, directory: rubricDiffRepo } as any,
2423
+ );
2424
+ check(
2425
+ "guard_review_rubric embeds the real branch diff content",
2426
+ typeof rubricWithDiff === "string" && rubricWithDiff.includes("rubricDiffMarker") && rubricWithDiff.includes("added.txt"),
2427
+ );
2428
+ rmSync(rubricDiffRepo, { recursive: true, force: true });
2429
+
2430
+ // Reviewer-type agents often lack record_review in their toolset entirely, so
2431
+ // the tool accepts recorders of any session type: the verdict binds to the
2432
+ // recording session's directory and the audit trail captures the recorder
2433
+ // lineage (recorderRole). The decision is documented in the changelog.
2319
2434
  const mainReviewToolResult = await customPlugin.tool?.record_review?.execute(
2320
- { reviewer: "self", summary: "self approval", passed: true },
2321
- { sessionID: "s-active", agent: "main", worktree: root, directory: root } as any,
2435
+ { reviewer: "root-relay", summary: "Test integrity: verified. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2436
+ { sessionID: "s-active", agent: "main", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2437
+ );
2438
+ check(
2439
+ "record_review accepts a root-session recorder and binds the verdict to its directory",
2440
+ typeof mainReviewToolResult === "string" && mainReviewToolResult.includes("APPROVED") && mainReviewToolResult.includes(reviewToolRepo),
2322
2441
  );
2323
- check("record_review rejects self-approval from main session", typeof mainReviewToolResult === "string" && mainReviewToolResult.includes("rejected"));
2442
+ const rootRecorderAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-active");
2443
+ check(
2444
+ "record_review audit includes the binding key and recorder lineage",
2445
+ (rootRecorderAudit?.evidence as { binding?: { workspace?: string; recorderRole?: string } })?.binding?.workspace === reviewToolRepo &&
2446
+ (rootRecorderAudit?.evidence as { binding?: { recorderRole?: string } })?.binding?.recorderRole === "root",
2447
+ );
2448
+ resetReviewState();
2324
2449
 
2325
2450
  // Secondary Review Approval Handoff & Durable Cache Tests
2326
2451
  console.log("- Secondary Review Approval Handoff & Durability -");
@@ -2412,6 +2537,146 @@ check("worktree and main repo recognized as same git repository", isSameGitRepo(
2412
2537
  rmSync(handoffRepo, { recursive: true, force: true });
2413
2538
  resetReviewState();
2414
2539
 
2540
+ // 5. Review binding across sessions and linked worktrees (regression): a
2541
+ // review recorded by a subagent rooted in a LINKED WORKTREE must satisfy the
2542
+ // PR preflight even when the PR is created from a DIFFERENT session, and must
2543
+ // stay valid across innocuous untracked-file cleanup.
2544
+ console.log("- Review Binding Across Sessions & Linked Worktrees -");
2545
+ const flowRepo = join(root, "wg-flow-repo");
2546
+ mkdirSync(flowRepo, { recursive: true });
2547
+ spawnSync("git", ["init", "-b", "main"], { cwd: flowRepo });
2548
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: flowRepo });
2549
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: flowRepo });
2550
+ writeFileSync(join(flowRepo, "seed.txt"), "base\n");
2551
+ spawnSync("git", ["add", "seed.txt"], { cwd: flowRepo });
2552
+ spawnSync("git", ["commit", "-m", "base"], { cwd: flowRepo });
2553
+ mkdirSync(join(flowRepo, ".opencode"), { recursive: true });
2554
+ writeFileSync(join(flowRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
2555
+ const prevWorktreeDirFlow = process.env.WORKFLOW_GUARD_WORKTREE_DIR;
2556
+ process.env.WORKFLOW_GUARD_WORKTREE_DIR = join(root, "wg-flow-worktrees");
2557
+ const flowWorktreeRes = createGitWorktree("fix/flow-branch", "HEAD", flowRepo);
2558
+ check("flow: linked worktree created", flowWorktreeRes.success && typeof flowWorktreeRes.worktreePath === "string");
2559
+ const flowWt = flowWorktreeRes.worktreePath!;
2560
+ writeFileSync(join(flowWt, "seed.txt"), "worktree change\n");
2561
+ spawnSync("git", ["add", "seed.txt"], { cwd: flowWt });
2562
+ spawnSync("git", ["commit", "-qm", "worktree change"], { cwd: flowWt });
2563
+ writeFileSync(join(flowWt, ".tmp-review-final.diff"), "scratch\n");
2564
+
2565
+ const flowPlugin = await WorkflowGuard({
2566
+ directory: root,
2567
+ worktree: root,
2568
+ client: fakeClient as any,
2569
+ project: {} as any,
2570
+ experimental_workspace: {} as any,
2571
+ serverUrl: new URL("http://localhost:4096"),
2572
+ $: undefined as any,
2573
+ });
2574
+ // The orchestrator session moved itself into the linked worktree
2575
+ // (tools.opencode.session_move): its file tools are rooted at the worktree.
2576
+ setSessionWorkspace("s-flow-orch", flowWt);
2577
+ fakeParents.set("s-flow-reviewer", "s-flow-orch");
2578
+ fakeParents.set("s-flow-pr", "s-flow-orch");
2579
+ todo("s-flow-orch", item("drive the flow", "in_progress"));
2580
+ const flowCtx = (sessionID: string) => ({ sessionID, worktree: flowWt, directory: flowWt }) as any;
2581
+
2582
+ const flowReview = await flowPlugin.tool?.record_review?.execute(
2583
+ { reviewer: "secondary-reviewer", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2584
+ { ...flowCtx("s-flow-reviewer"), agent: "general" },
2585
+ );
2586
+ check("flow: review recorded from a subagent rooted in the linked worktree binds to it", typeof flowReview === "string" && flowReview.includes("APPROVED") && flowReview.includes(flowWt));
2587
+
2588
+ const flowStatus = JSON.parse(String(await flowPlugin.tool?.guard_status?.execute({ directory: flowWt }, flowCtx("s-flow-orch"))));
2589
+ check("flow: guard_status reports fresh lastReview for the linked worktree", flowStatus.lastReview?.fresh === true && !flowStatus.outstandingRequirements.includes("review"));
2590
+
2591
+ const flowPrevPath = process.env.PATH;
2592
+ const flowBin = join(root, "wg-flow-bin");
2593
+ mkdirSync(flowBin, { recursive: true });
2594
+ writeFileSync(join(flowBin, "gh"), "#!/bin/sh\nexit 1\n");
2595
+ writeFileSync(join(flowBin, "az"), "#!/bin/sh\nexit 1\n");
2596
+ chmodSync(join(flowBin, "gh"), 0o755);
2597
+ chmodSync(join(flowBin, "az"), 0o755);
2598
+ process.env.PATH = `${flowBin}:${flowPrevPath ?? ""}`;
2599
+ const flowPrBody = "gh pr create --title 'fix: flow' --body 'Changelog: flow'";
2600
+ try {
2601
+ const flowPrParent = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-orch"));
2602
+ check("flow: PR from the reviewer's parent session is allowed", !blocked(flowPrParent));
2603
+ const flowPrOther = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-pr"));
2604
+ check("flow: PR from a DIFFERENT session in the same worktree is allowed (content binding, not session identity)", !blocked(flowPrOther));
2605
+ if (blocked(flowPrOther)) console.log(" blocked:", flowPrOther);
2606
+
2607
+ // Untracked scratch cleanup must not invalidate the approval
2608
+ const flowRm = await call("bash", { command: "rm -f .tmp-review-final.diff", workdir: flowWt }, flowCtx("s-flow-orch"));
2609
+ check("flow: untracked scratch deletion is a permitted cleanup", !blocked(flowRm));
2610
+ const flowStatusAfterRm = JSON.parse(String(await flowPlugin.tool?.guard_status?.execute({ directory: flowWt }, flowCtx("s-flow-orch"))));
2611
+ check("flow: review stays fresh after untracked scratch deletion", flowStatusAfterRm.lastReview?.fresh === true && !flowStatusAfterRm.outstandingRequirements.includes("review"));
2612
+ const flowPrAfterRm = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-pr"));
2613
+ check("flow: PR still allowed after innocuous untracked-file mutations", !blocked(flowPrAfterRm));
2614
+
2615
+ // Tracked content changes still invalidate (the gate is not weakened)
2616
+ writeFileSync(join(flowWt, "seed.txt"), "post-review tracked edit\n");
2617
+ const flowStatusAfterEdit = JSON.parse(String(await flowPlugin.tool?.guard_status?.execute({ directory: flowWt }, flowCtx("s-flow-orch"))));
2618
+ check("flow: tracked edit after review makes the approval stale", flowStatusAfterEdit.outstandingRequirements.includes("review"));
2619
+ const flowPrAfterEdit = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-orch"));
2620
+ check("flow: PR blocked after tracked content changed post-review", blocked(flowPrAfterEdit) && String(flowPrAfterEdit).includes("tracked content changed after review"));
2621
+ spawnSync("git", ["checkout", "--", "seed.txt"], { cwd: flowWt });
2622
+ } finally {
2623
+ process.env.PATH = flowPrevPath;
2624
+ }
2625
+
2626
+ // Reviewer-type agents cannot call record_review themselves (their toolset has
2627
+ // no record_review), so the orchestrator relays the verdict from its own
2628
+ // session; the verdict must still bind to the reviewed worktree.
2629
+ const relayedReview = await flowPlugin.tool?.record_review?.execute(
2630
+ { reviewer: "relayed-reviewer", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2631
+ { ...flowCtx("s-flow-orch"), agent: "reviewer" },
2632
+ );
2633
+ check("flow: relayed verdict from a root session binds to the reviewed worktree", typeof relayedReview === "string" && relayedReview.includes("APPROVED") && relayedReview.includes(flowWt));
2634
+
2635
+ // A verdict recorded against a non-git directory can never bind to a PR
2636
+ // preflight; it must fail loudly instead of silently not binding.
2637
+ const flowNonGit = join(root, "wg-flow-nongit");
2638
+ mkdirSync(flowNonGit, { recursive: true });
2639
+ const flowBadReview = await flowPlugin.tool?.record_review?.execute(
2640
+ { reviewer: "unbindingable", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2641
+ { ...flowCtx("s-flow-reviewer"), worktree: flowNonGit, directory: flowNonGit },
2642
+ );
2643
+ check(
2644
+ "flow: verdict against a non-git directory returns an explicit bind error",
2645
+ typeof flowBadReview === "string" && flowBadReview.includes("rejected") && flowBadReview.includes("cannot be bound") && !flowBadReview.includes("APPROVED"),
2646
+ );
2647
+
2648
+ rmSync(flowNonGit, { recursive: true, force: true });
2649
+ rmSync(flowBin, { recursive: true, force: true });
2650
+ rmSync(flowRepo, { recursive: true, force: true });
2651
+ if (prevWorktreeDirFlow === undefined) delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
2652
+ else process.env.WORKFLOW_GUARD_WORKTREE_DIR = prevWorktreeDirFlow;
2653
+ rmSync(join(root, "wg-flow-worktrees"), { recursive: true, force: true });
2654
+ resetReviewState();
2655
+
2656
+ // Commit drift: a review recorded with its change already staged stays
2657
+ // fingerprint-identical once that change is committed, so the commit-hash
2658
+ // binding is the invalidation signal and the preflight must name it.
2659
+ const driftRepo = join(root, "wg-review-commit-drift");
2660
+ mkdirSync(driftRepo, { recursive: true });
2661
+ spawnSync("git", ["init", "-b", "feature/commit-drift"], { cwd: driftRepo });
2662
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: driftRepo });
2663
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: driftRepo });
2664
+ writeFileSync(join(driftRepo, "code.txt"), "base\n");
2665
+ spawnSync("git", ["add", "code.txt"], { cwd: driftRepo });
2666
+ spawnSync("git", ["commit", "-m", "base"], { cwd: driftRepo });
2667
+ mkdirSync(join(driftRepo, ".opencode"), { recursive: true });
2668
+ writeFileSync(join(driftRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
2669
+ writeFileSync(join(driftRepo, "code.txt"), "reviewed staged edit\n");
2670
+ spawnSync("git", ["add", "code.txt"], { cwd: driftRepo });
2671
+ const driftFingerprintBefore = getTrackedWorktreeFingerprint(driftRepo);
2672
+ recordReviewResult("commit-drift-reviewer", "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", true, "s-drift", driftRepo);
2673
+ spawnSync("git", ["commit", "-qm", "commit reviewed change"], { cwd: driftRepo });
2674
+ check("committing staged reviewed content keeps the tracked fingerprint", getTrackedWorktreeFingerprint(driftRepo) === driftFingerprintBefore);
2675
+ const driftPr = await call("bash", { command: "gh pr create --title t --body 'Changelog: drift'", workdir: driftRepo }, { sessionID: "s-drift", worktree: driftRepo, directory: driftRepo });
2676
+ check("PR blocked when only the reviewed commit drifted, with a commit-specific cause", blocked(driftPr) && String(driftPr).includes("bound to commit"));
2677
+ rmSync(driftRepo, { recursive: true, force: true });
2678
+ resetReviewState();
2679
+
2415
2680
  // Event hook handles permission events
2416
2681
  if (typeof customPlugin.event === "function") {
2417
2682
  await customPlugin.event({
@@ -2743,6 +3008,17 @@ check("checkInteractiveTtyCommand permits top long-form batch flag", !checkInter
2743
3008
  check("checkInteractiveTtyCommand permits hyphenated top-like filename", !checkInteractiveTtyCommand("ls top-level-dir").isInteractive);
2744
3009
  check("top batch mode does not hide another interactive monitor", checkInteractiveTtyCommand("top -b -n 1; htop").isInteractive);
2745
3010
  check("top batch mode does not hide a later interactive top", checkInteractiveTtyCommand("top -b -n 1; top").isInteractive);
3011
+ check("checkInteractiveTtyCommand permits top as an argument value", !checkInteractiveTtyCommand("az keyvault secret show --vault-name jcs-pim-vault-nz --name top").isInteractive);
3012
+ check("checkInteractiveTtyCommand permits an unquoted top argument", !checkInteractiveTtyCommand("echo top").isInteractive);
3013
+ check("checkInteractiveTtyCommand still detects a monitor after a pipe", checkInteractiveTtyCommand("cat file | top").isInteractive);
3014
+ check("checkInteractiveTtyCommand still detects a wrapped monitor", checkInteractiveTtyCommand("sudo top").isInteractive);
3015
+ check("checkInteractiveTtyCommand still detects busybox top", checkInteractiveTtyCommand("busybox top").isInteractive);
3016
+ check("checkInteractiveTtyCommand detects eval-wrapped monitor", checkInteractiveTtyCommand("eval top").isInteractive);
3017
+ check("checkInteractiveTtyCommand detects shell -c monitor", checkInteractiveTtyCommand("bash -c top").isInteractive);
3018
+ check("checkInteractiveTtyCommand detects shell -lc quoted monitor", checkInteractiveTtyCommand("sh -lc 'htop'").isInteractive);
3019
+ check("checkInteractiveTtyCommand permits a quoted separator before a monitor name", !checkInteractiveTtyCommand('echo "run; top"').isInteractive);
3020
+ check("checkInteractiveTtyCommand detects timeout-wrapped monitor", checkInteractiveTtyCommand("timeout 5 top").isInteractive);
3021
+ check("checkInteractiveTtyCommand detects a backgrounded-wrapper monitor", checkInteractiveTtyCommand("sudo -b top").isInteractive);
2746
3022
  check("checkInteractiveTtyCommand detects sudo", checkInteractiveTtyCommand("sudo apt-get update").isInteractive);
2747
3023
  check("checkInteractiveTtyCommand detects git rebase -i", checkInteractiveTtyCommand("git rebase -i HEAD~2").isInteractive);
2748
3024
  check("checkInteractiveTtyCommand detects npm init without -y", checkInteractiveTtyCommand("npm init").isInteractive);
@@ -2757,6 +3033,7 @@ check("shell tool blocks less", blocked(await shell("less package.json")));
2757
3033
  check("shell tool blocks top", blocked(await shell("top")));
2758
3034
  check("shell tool allows top batch mode", !(await shell("top -b -n 1")));
2759
3035
  check("shell tool allows top long-form batch flag", !(await shell("top --batch -n 1")));
3036
+ check("shell tool allows top as an argument value", !(await shell("az keyvault secret show --vault-name jcs-pim-vault-nz --name top")));
2760
3037
  check("shell tool blocks npm init without flag", blocked(await shell("npm init")));
2761
3038
  check("shell tool allows npm init -y", !(await shell("npm init -y")));
2762
3039
 
@@ -2974,6 +3251,21 @@ check(
2974
3251
  !rogueCleanup.success && existsSync(join(roguePath, "keep.txt")),
2975
3252
  );
2976
3253
 
3254
+ // Idempotent cleanup: a registered worktree whose directory vanished (the
3255
+ // prunable admin entry `git worktree list` shows) must be deregistered rather
3256
+ // than refused, while unregistered missing paths stay refused.
3257
+ const prunableCreate = createGitWorktree("feat/prunable-cleanup", "HEAD", worktreeBaseRepo);
3258
+ check("prunable-cleanup setup creates a worktree", prunableCreate.success && typeof prunableCreate.worktreePath === "string");
3259
+ const prunablePath = prunableCreate.worktreePath!;
3260
+ rmSync(prunablePath, { recursive: true, force: true });
3261
+ const prunableListedBefore = (spawnSync("git", ["worktree", "list", "--porcelain"], { cwd: worktreeBaseRepo, encoding: "utf8" }).stdout ?? "").includes(prunablePath);
3262
+ check("a removed-directory worktree is still listed as prunable before cleanup", prunableListedBefore);
3263
+ const prunableCleanup = cleanupGitWorktree(prunablePath, worktreeBaseRepo);
3264
+ const prunableStillListed = (spawnSync("git", ["worktree", "list", "--porcelain"], { cwd: worktreeBaseRepo, encoding: "utf8" }).stdout ?? "").includes(prunablePath);
3265
+ check("cleanupGitWorktree deregisters a registered worktree whose directory is missing", prunableCleanup.success && !prunableStillListed);
3266
+ const missingRogue = cleanupGitWorktree(join(storageBase, "missing-rogue"), worktreeBaseRepo);
3267
+ check("cleanupGitWorktree still refuses an unregistered missing path", !missingRogue.success);
3268
+
2977
3269
  // Snapshot integrity: when the snapshot commit cannot be established (e.g. a
2978
3270
  // failing pre-commit hook), cleanup must abort and preserve the worktree.
2979
3271
  const failingHooks = mkdtempSync(join(tmpdir(), "wg-wt-hooks-"));
@@ -3067,6 +3359,53 @@ delete process.env.GIT_INDEX_FILE;
3067
3359
  delete process.env.GIT_DIR;
3068
3360
  check("getCleanGitEnv strips git context variables", typeof getCleanGitEnv().GIT_INDEX_FILE === "undefined");
3069
3361
 
3362
+ // Partial-failure rollback: git creates the branch ref before the worktree
3363
+ // checkout, so a failure after that point must not leave the branch behind,
3364
+ // must not delete a branch that already existed, must leave a pre-existing
3365
+ // target path intact, and must report the git exit status rather than only
3366
+ // stderr progress text.
3367
+ const storageBaseForRollback = getWorktreeStorageDir(worktreeBaseRepo);
3368
+ mkdirSync(storageBaseForRollback, { recursive: true });
3369
+ const rollbackBranch = "feat/rollback-partial";
3370
+ const rollbackCollision = join(storageBaseForRollback, "feat-rollback-partial");
3371
+ writeFileSync(rollbackCollision, "path collision\n");
3372
+ const rollbackRes = createGitWorktree(rollbackBranch, "HEAD", worktreeBaseRepo);
3373
+ check(
3374
+ "partial worktree add failure reports git exit status, not only stderr progress",
3375
+ rollbackRes.success === false && /exit status/.test(String(rollbackRes.error)),
3376
+ );
3377
+ check(
3378
+ "partial worktree add failure rolls back the created branch",
3379
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${rollbackBranch}`], { cwd: worktreeBaseRepo }).status !== 0,
3380
+ );
3381
+ check("partial worktree add failure leaves a pre-existing target path intact", existsSync(rollbackCollision) && readFileSync(rollbackCollision, "utf8") === "path collision\n");
3382
+ const preservedBranch = "feat/preserve-on-fail";
3383
+ spawnSync("git", ["branch", preservedBranch], { cwd: worktreeBaseRepo });
3384
+ writeFileSync(join(storageBaseForRollback, "feat-preserve-on-fail"), "blocker\n");
3385
+ createGitWorktree(preservedBranch, "HEAD", worktreeBaseRepo);
3386
+ check(
3387
+ "a pre-existing branch is not deleted when worktree add fails",
3388
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${preservedBranch}`], { cwd: worktreeBaseRepo }).status === 0,
3389
+ );
3390
+ // A colliding sanitized path must never let rollback destroy a live
3391
+ // registered worktree (duplicate names, concurrent subagents, feat/x vs
3392
+ // feat:x). The failed add must leave it and its uncommitted work intact.
3393
+ const liveCollision = createGitWorktree("feat/live-collision", "HEAD", worktreeBaseRepo);
3394
+ check("live-collision setup creates a registered worktree", liveCollision.success && typeof liveCollision.worktreePath === "string");
3395
+ const liveCollisionPath = liveCollision.worktreePath!;
3396
+ writeFileSync(join(liveCollisionPath, "uncommitted-work.txt"), "do not lose me\n");
3397
+ const duplicateCreate = createGitWorktree("feat/live-collision", "HEAD", worktreeBaseRepo);
3398
+ const liveSurvived = existsSync(liveCollisionPath) && existsSync(join(liveCollisionPath, "uncommitted-work.txt"));
3399
+ const liveCleanup = cleanupGitWorktree(liveCollisionPath, worktreeBaseRepo);
3400
+ check(
3401
+ "failed duplicate create leaves a live registered worktree and its uncommitted work intact",
3402
+ duplicateCreate.success === false && liveSurvived && liveCleanup.success,
3403
+ );
3404
+ process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS = "2500";
3405
+ check("resolveWorktreeTimeoutMs honors WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS", resolveWorktreeTimeoutMs() === 2500);
3406
+ delete process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS;
3407
+ check("resolveWorktreeTimeoutMs falls back to the 15s default", resolveWorktreeTimeoutMs() === 15_000);
3408
+
3070
3409
  rmSync(worktreeBaseRepo, { recursive: true, force: true });
3071
3410
  delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
3072
3411
  rmSync(worktreeStorage, { recursive: true, force: true });
@@ -3915,6 +4254,43 @@ check("project-memory initialization failure leaves core guard hooks active", ty
3915
4254
  if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
3916
4255
  else process.env.XDG_DATA_HOME = prevDataHome;
3917
4256
 
4257
+ console.log("- live control-plane paths (tier port: config-path facts) -");
4258
+ const liveBase = mkdtempSync(join(tmpdir(), "wg-lcp-base-"));
4259
+ const livePlane = join(liveBase, ".config", "open" + "code");
4260
+ mkdirSync(livePlane, { recursive: true });
4261
+ const lcpRoot = mkdtempSync(join(tmpdir(), "wg-lcp-root-"));
4262
+ mkdirSync(join(lcpRoot, ".opencode"), { recursive: true });
4263
+ const ocJson = "open" + "code.json";
4264
+ const ocJsonc = "open" + "code.jsonc";
4265
+ setWorkspaceRoot(root);
4266
+
4267
+ // Legacy (no live roots configured): the copy SOURCE under a config path alone
4268
+ // blocks the command — the F3 false positive, pinned as current behavior.
4269
+ check("legacy: cp FROM a .config/opencode source is blocked without live roots", blocked(await call("bash", { command: `cp ${livePlane}/agent/x.md ./draft.md` }, { sessionID: "s-active" })));
4270
+
4271
+ // Declare the live control plane via project config, then reload.
4272
+ writeFileSync(join(lcpRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ liveControlPlanePaths: [livePlane] }));
4273
+ reloadProjectConfig(lcpRoot);
4274
+ setWorkspaceRoot(lcpRoot);
4275
+
4276
+ // With facts: the same copy is allowed — the SOURCE is no longer matched and
4277
+ // the destination is a sanctioned workspace draft (F3 fixed).
4278
+ check("facts: cp FROM live config into a workspace draft is allowed", !(await call("bash", { command: `cp ${livePlane}/agent/x.md ./draft.md` }, { sessionID: "s-active" })));
4279
+ // The live destination stays protected.
4280
+ check("facts: cp INTO the live config is blocked", blocked(await call("bash", { command: `cp ./draft.md ${livePlane}/agent/x.md` }, { sessionID: "s-active" })));
4281
+ check("facts: redirect into the live config is blocked", blocked(await call("bash", { command: `echo x > ${livePlane}/${ocJsonc}` }, { sessionID: "s-active" })));
4282
+ // A config-shaped draft inside the workspace is allowed.
4283
+ check("facts: write to a project .opencode draft is allowed", !(await call("write", { filePath: join(lcpRoot, ".opencode", ocJson), content: "{}" }, { sessionID: "s-active" })));
4284
+
4285
+ // An unusable declared root rejects the whole fact set -> fail-closed legacy.
4286
+ writeFileSync(join(lcpRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ liveControlPlanePaths: ["relative/dir"] }));
4287
+ reloadProjectConfig(lcpRoot);
4288
+ check("facts: an unusable declared root falls back to legacy segment matching", blocked(await call("write", { filePath: join(lcpRoot, ".opencode", ocJson), content: "{}" }, { sessionID: "s-active" })));
4289
+
4290
+ setWorkspaceRoot(root);
4291
+ rmSync(lcpRoot, { recursive: true, force: true });
4292
+ rmSync(liveBase, { recursive: true, force: true });
4293
+
3918
4294
  rmSync(root, { recursive: true, force: true });
3919
4295
  if (prevLive !== undefined) process.env.WORKFLOW_GUARD_ALLOW_LIVE = prevLive;
3920
4296
  console.log(`\n${pass} passed, ${fail} failed`);