opencode-workflow-guard 1.14.1 → 1.15.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
@@ -2,6 +2,7 @@ import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSy
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
5
6
  import { DatabaseSync } from "node:sqlite";
6
7
  import type { PluginModule } from "@opencode-ai/plugin";
7
8
  import { getRalphMaxIterations, runWithRuntimeState } from "../src/lib/state.ts";
@@ -74,6 +75,7 @@ import {
74
75
  checkLockfileSync,
75
76
  branchHasDocumentationChange,
76
77
  isDocumentationRequired,
78
+ loadedPluginVersion,
77
79
  isReviewRequired,
78
80
  getOperationProfile,
79
81
  isRecoveryCheckpointsEnabled,
@@ -678,6 +680,35 @@ check("block write to /etc/passwd", blocked(await call("write", { filePath: "/et
678
680
  check("block write to ~ path", blocked(await call("write", { filePath: "~/.bashrc", content: "x" }, { sessionID: "s-active" })));
679
681
  check("block write to $HOME path", blocked(await call("write", { filePath: "$HOME/.profile", content: "x" }, { sessionID: "s-active" })));
680
682
  check("block write to unresolvable $VAR path", blocked(await call("write", { filePath: "$UNKNOWN_DIR/file.txt", content: "x" }, { sessionID: "s-active" })));
683
+ // Cause attribution: indeterminate $VARIABLE destinations get a message that
684
+ // names the unresolvable reference (the on-device over-block class), while
685
+ // deterministically-outside paths keep the generic escape message.
686
+ const unresolvableWrite = await call("write", { filePath: "$UNKNOWN_DIR/file.txt", content: "x" }, { sessionID: "s-active" });
687
+ check("unresolvable $VAR write names the variable cause", blocked(unresolvableWrite) && String(unresolvableWrite).includes("unresolvable variable reference") && String(unresolvableWrite).includes("$UNKNOWN_DIR"));
688
+ const literalEscapeWrite = await call("write", { filePath: "/etc/passwd", content: "x" }, { sessionID: "s-active" });
689
+ check("literal outside path keeps the generic escape message", blocked(literalEscapeWrite) && String(literalEscapeWrite).includes("escapes workspace root") && !String(literalEscapeWrite).includes("unresolvable variable reference"));
690
+ const homeWrite = await call("write", { filePath: "$HOME/.profile", content: "x" }, { sessionID: "s-active" });
691
+ check("deterministically-expanded $HOME path keeps the generic escape message", blocked(homeWrite) && String(homeWrite).includes("escapes workspace root") && !String(homeWrite).includes("unresolvable variable reference"));
692
+ const unresolvableShell = await call("bash", { command: "mkdir -p $D/sub" }, { sessionID: "s-active" });
693
+ check("shell mutation with unresolvable $VAR names the variable cause", blocked(unresolvableShell) && String(unresolvableShell).includes("unresolvable variable reference") && String(unresolvableShell).includes("$D"));
694
+ const unresolvablePatch = await call("apply_patch", { patchText: "*** Update File: $UNKNOWN_DIR/file.ts\n" }, { sessionID: "s-active" });
695
+ check("apply_patch with unresolvable $VAR names the variable cause", blocked(unresolvablePatch) && String(unresolvablePatch).includes("unresolvable variable reference"));
696
+ const unresolvableMv = await call("bash", { command: "mv $D/foo.txt ./bar.txt" }, { sessionID: "s-active" });
697
+ check("mv source with unresolvable $VAR names the variable cause", blocked(unresolvableMv) && String(unresolvableMv).includes("unresolvable variable reference"));
698
+ const unresolvableGit = await call("bash", { command: "git -C $OTHER_REPO commit -m x" }, { sessionID: "s-active" });
699
+ check("git repoDir with unresolvable $VAR names the variable cause", blocked(unresolvableGit) && String(unresolvableGit).includes("unresolvable variable reference"));
700
+ // Scratch-directory cause attribution: /tmp/opencode is the environment's
701
+ // designated scratch space, but the workspace boundary is workspace-absolute
702
+ // and does not cover it; the message explains that instead of reading as a
703
+ // guard bug (6 of the device audit's boundary blocks were this class).
704
+ const scratchWrite = await call("write", { filePath: "/tmp/opencode/scratch.txt", content: "x" }, { sessionID: "s-active" });
705
+ check("scratch-dir write attributes the environment scratch cause", blocked(scratchWrite) && String(scratchWrite).includes("environment scratch directory") && String(scratchWrite).includes("/tmp/opencode") && !String(scratchWrite).includes("unresolvable variable reference"));
706
+ const scratchRedirect = await call("bash", { command: "echo x > /tmp/opencode/out.md" }, { sessionID: "s-active" });
707
+ check("scratch-dir redirect attributes the environment scratch cause", blocked(scratchRedirect) && String(scratchRedirect).includes("environment scratch directory"));
708
+ const scratchPatch = await call("apply_patch", { patchText: "*** Add File: /tmp/opencode/scratch.ts\n+scratch\n" }, { sessionID: "s-active" });
709
+ check("scratch-dir apply_patch attributes the environment scratch cause", blocked(scratchPatch) && String(scratchPatch).includes("environment scratch directory"));
710
+ const otherOutsideWrite = await call("write", { filePath: "/var/tmp/elsewhere.txt", content: "x" }, { sessionID: "s-active" });
711
+ check("non-scratch outside path keeps the generic escape message", blocked(otherOutsideWrite) && String(otherOutsideWrite).includes("escapes workspace root") && !String(otherOutsideWrite).includes("scratch directory"));
681
712
  check("allow apply_patch within workspace", !(await call("apply_patch", { patchText: "*** Update File: src/app.ts\n" }, { sessionID: "s-active" })));
682
713
  check("block apply_patch escaping workspace", blocked(await call("apply_patch", { patchText: "*** Update File: ../../secret.env\n" }, { sessionID: "s-active" })));
683
714
 
@@ -897,6 +928,169 @@ try {
897
928
  }
898
929
  check("blocked multi-target patch leaves no partial claims", !partialPatchClaim);
899
930
 
931
+ // Stale claim takeover (missed idle release): a claim created BEFORE its
932
+ // owner's last recorded idle timestamp cannot belong to an in-flight tool
933
+ // call, so a leaked claim must not block another session forever. Owners that
934
+ // are still mid-session (no idle timestamp), a client without a session
935
+ // lookup, and a failing lookup all keep the block (fail-closed).
936
+ console.log("- Stale file-claim takeover -");
937
+ const takeoverDir = mkdtempSync(join(tmpdir(), "wg-claim-takeover-"));
938
+ writeFileSync(join(takeoverDir, "stale.ts"), "before");
939
+ writeFileSync(join(takeoverDir, "live.ts"), "before");
940
+ writeFileSync(join(takeoverDir, "refresh.ts"), "before");
941
+ const takeoverIdle = new Map<string, number>();
942
+ const takeoverClient = {
943
+ session: {
944
+ todo: fakeClient.session.todo,
945
+ get: async ({ path }: { path: { id: string } }) => {
946
+ const idle = takeoverIdle.get(path.id);
947
+ return { data: { id: path.id, parentID: fakeParents.get(path.id), ...(typeof idle === "number" ? { time: { idle } } : {}) } };
948
+ },
949
+ },
950
+ };
951
+ const takeoverHooks = await pluginFn({
952
+ directory: takeoverDir,
953
+ client: takeoverClient as any,
954
+ project: {} as any,
955
+ worktree: takeoverDir,
956
+ experimental_workspace: {} as any,
957
+ serverUrl: new URL("http://localhost:4096"),
958
+ $: undefined as any,
959
+ });
960
+ todo("s-stale-owner", item("edit stale file", "in_progress"));
961
+ todo("s-live-owner", item("edit live file", "in_progress"));
962
+ todo("s-claimant", item("take over stale claim", "in_progress"));
963
+ const takeoverBefore = takeoverHooks["tool.execute.before"];
964
+ const takeoverAfter = takeoverHooks["tool.execute.after"];
965
+
966
+ // The owner claims the file, its after-hook is skipped, and it idles LATER.
967
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "stale.ts"), "takeover-read-stale");
968
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "stale-claim" }, { args: { filePath: join(takeoverDir, "stale.ts"), content: "a" } });
969
+ await new Promise((resolve) => setTimeout(resolve, 10));
970
+ takeoverIdle.set("s-stale-owner", Date.now());
971
+ // A live owner, still mid-session, claims the other file.
972
+ await recordRead(takeoverBefore, takeoverAfter, "s-live-owner", join(takeoverDir, "live.ts"), "takeover-read-live");
973
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-live-owner", callID: "live-claim" }, { args: { filePath: join(takeoverDir, "live.ts"), content: "b" } });
974
+
975
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "stale.ts"), "takeover-read-claimant");
976
+ let staleTakeoverAllowed = true;
977
+ try {
978
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "takeover-claim" }, { args: { filePath: join(takeoverDir, "stale.ts"), content: "c" } });
979
+ } catch {
980
+ staleTakeoverAllowed = false;
981
+ }
982
+ check("a claim older than its owner's recorded idle is released for the next claimant", staleTakeoverAllowed);
983
+ const takeoverAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "file-claims" && entry.reason === "stale_claim_takeover");
984
+ check("stale claim takeover is audited with the released path and owner", takeoverAudit?.decision === "allow" && typeof takeoverAudit?.evidence?.targetPath === "string" && takeoverAudit.evidence.targetPath.endsWith("stale.ts") && takeoverAudit.evidence.claimOwnerSessionID === "s-stale-owner");
985
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "takeover-claim", args: {} }, { title: "edit", output: "edited", metadata: {} });
986
+
987
+ // A live owner, still mid-turn (no idle timestamp recorded), keeps its claim.
988
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "live.ts"), "takeover-read-live-claimant");
989
+ let liveClaimBlocked = false;
990
+ try {
991
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "live-blocked" }, { args: { filePath: join(takeoverDir, "live.ts"), content: "c" } });
992
+ } catch (error) {
993
+ liveClaimBlocked = String(error).includes("claimed by another active session");
994
+ }
995
+ check("a live owner without an idle timestamp keeps its claim", liveClaimBlocked);
996
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-live-owner", callID: "live-claim", args: {} }, { title: "edit", output: "edited", metadata: {} });
997
+
998
+ // A same-session re-claim refreshes the stored claim (new callID/timestamp):
999
+ // an owner that idled with a leaked claim and comes back to the same file
1000
+ // mid-turn must not have its fresh claim taken over, and the refreshed
1001
+ // callID must be the one the after-hook releases.
1002
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "refresh.ts"), "takeover-read-refresh");
1003
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-1" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "d" } });
1004
+ await new Promise((resolve) => setTimeout(resolve, 10));
1005
+ takeoverIdle.set("s-stale-owner", Date.now());
1006
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-2" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "e" } });
1007
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "refresh.ts"), "takeover-read-refresh-claimant");
1008
+ let refreshClaimBlocked = false;
1009
+ try {
1010
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-conflict" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "f" } });
1011
+ } catch (error) {
1012
+ refreshClaimBlocked = String(error).includes("claimed by another active session");
1013
+ }
1014
+ check("a same-session re-claim refreshes past the owner's idle so it is not taken over mid-turn", refreshClaimBlocked);
1015
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-2", args: {} }, { title: "edit", output: "edited", metadata: {} });
1016
+ let refreshReleasedAllowed = true;
1017
+ try {
1018
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-after-release" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "g" } });
1019
+ } catch {
1020
+ refreshReleasedAllowed = false;
1021
+ }
1022
+ check("the refreshed claim releases through its updated callID", refreshReleasedAllowed);
1023
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-after-release", args: {} }, { title: "edit", output: "edited", metadata: {} });
1024
+
1025
+ // The takeover releases every provably stale claim of the owner, each with
1026
+ // its own audit entry: an owner holding two stale paths loses both in one
1027
+ // pass, and no fresh claim of that owner is touched.
1028
+ writeFileSync(join(takeoverDir, "multi-a.ts"), "before");
1029
+ writeFileSync(join(takeoverDir, "multi-b.ts"), "before");
1030
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "multi-a.ts"), "takeover-read-multi-a");
1031
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "multi-claim-a" }, { args: { filePath: join(takeoverDir, "multi-a.ts"), content: "h" } });
1032
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "multi-b.ts"), "takeover-read-multi-b");
1033
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "multi-claim-b" }, { args: { filePath: join(takeoverDir, "multi-b.ts"), content: "h" } });
1034
+ await new Promise((resolve) => setTimeout(resolve, 10));
1035
+ takeoverIdle.set("s-stale-owner", Date.now());
1036
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "multi-a.ts"), "takeover-read-multi-a-claimant");
1037
+ let multiTakeoverAllowed = true;
1038
+ try {
1039
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-a" }, { args: { filePath: join(takeoverDir, "multi-a.ts"), content: "i" } });
1040
+ } catch {
1041
+ multiTakeoverAllowed = false;
1042
+ }
1043
+ check("one conflict releases every provably stale claim of the owner", multiTakeoverAllowed);
1044
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-a", args: {} }, { title: "edit", output: "edited", metadata: {} });
1045
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "multi-b.ts"), "takeover-read-multi-b-claimant");
1046
+ let multiSecondAllowed = true;
1047
+ try {
1048
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-b" }, { args: { filePath: join(takeoverDir, "multi-b.ts"), content: "j" } });
1049
+ } catch {
1050
+ multiSecondAllowed = false;
1051
+ }
1052
+ check("the owner's other stale path is released in the same pass", multiSecondAllowed);
1053
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-b", args: {} }, { title: "edit", output: "edited", metadata: {} });
1054
+ const takeoverAudits = getRecentAuditEntries(30).filter((entry) => entry.reason === "stale_claim_takeover" && typeof entry.evidence?.targetPath === "string" && entry.evidence.targetPath.includes("multi-"));
1055
+ check("each released stale path has its own audited takeover entry", takeoverAudits.length === 2 && takeoverAudits.every((entry) => entry.evidence?.claimOwnerSessionID === "s-stale-owner"));
1056
+
1057
+ // Fail-closed: no session lookup on the client, and a lookup that throws.
1058
+ const noLookupDir = mkdtempSync(join(tmpdir(), "wg-claim-nolookup-"));
1059
+ writeFileSync(join(noLookupDir, "file.ts"), "before");
1060
+ const noLookupHooks = await pluginFn({ directory: noLookupDir, client: {} as any, project: {} as any, worktree: noLookupDir, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
1061
+ const noLookupBefore = noLookupHooks["tool.execute.before"];
1062
+ const noLookupAfter = noLookupHooks["tool.execute.after"];
1063
+ todo("s-nolookup-owner", item("edit file", "in_progress"));
1064
+ await recordRead(noLookupBefore, noLookupAfter, "s-nolookup-owner", join(noLookupDir, "file.ts"), "nolookup-read-owner");
1065
+ await noLookupBefore?.({ tool: "edit", sessionID: "s-nolookup-owner", callID: "nolookup-claim" }, { args: { filePath: join(noLookupDir, "file.ts"), content: "a" } });
1066
+ todo("s-nolookup-claimant", item("edit file", "in_progress"));
1067
+ await recordRead(noLookupBefore, noLookupAfter, "s-nolookup-claimant", join(noLookupDir, "file.ts"), "nolookup-read-claimant");
1068
+ let noLookupBlocked = false;
1069
+ try {
1070
+ await noLookupBefore?.({ tool: "edit", sessionID: "s-nolookup-claimant", callID: "nolookup-claimant" }, { args: { filePath: join(noLookupDir, "file.ts"), content: "b" } });
1071
+ } catch (error) {
1072
+ noLookupBlocked = String(error).includes("claimed by another active session");
1073
+ }
1074
+ check("stale takeover fails closed when the client has no session lookup", noLookupBlocked);
1075
+
1076
+ const throwLookupDir = mkdtempSync(join(tmpdir(), "wg-claim-throwlookup-"));
1077
+ writeFileSync(join(throwLookupDir, "file.ts"), "before");
1078
+ const throwLookupHooks = await pluginFn({ directory: throwLookupDir, client: { session: { get: async () => { throw new Error("lookup failed"); } } } as any, project: {} as any, worktree: throwLookupDir, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
1079
+ const throwLookupBefore = throwLookupHooks["tool.execute.before"];
1080
+ const throwLookupAfter = throwLookupHooks["tool.execute.after"];
1081
+ todo("s-throwlookup-owner", item("edit file", "in_progress"));
1082
+ await recordRead(throwLookupBefore, throwLookupAfter, "s-throwlookup-owner", join(throwLookupDir, "file.ts"), "throwlookup-read-owner");
1083
+ await throwLookupBefore?.({ tool: "edit", sessionID: "s-throwlookup-owner", callID: "throwlookup-claim" }, { args: { filePath: join(throwLookupDir, "file.ts"), content: "a" } });
1084
+ todo("s-throwlookup-claimant", item("edit file", "in_progress"));
1085
+ await recordRead(throwLookupBefore, throwLookupAfter, "s-throwlookup-claimant", join(throwLookupDir, "file.ts"), "throwlookup-read-claimant");
1086
+ let throwLookupBlocked = false;
1087
+ try {
1088
+ await throwLookupBefore?.({ tool: "edit", sessionID: "s-throwlookup-claimant", callID: "throwlookup-claimant" }, { args: { filePath: join(throwLookupDir, "file.ts"), content: "b" } });
1089
+ } catch (error) {
1090
+ throwLookupBlocked = String(error).includes("claimed by another active session");
1091
+ }
1092
+ check("stale takeover fails closed when the session lookup throws", throwLookupBlocked);
1093
+
900
1094
  // Existing files must be read by the same session before edit/write, and the
901
1095
  // bytes plus filesystem identity must still match when the mutation starts.
902
1096
  const staleDir = mkdtempSync(join(tmpdir(), "wg-stale-write-"));
@@ -1406,22 +1600,19 @@ const fakeV2TuiCtx = {
1406
1600
  ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
1407
1601
  };
1408
1602
  await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
1409
- check("v2 tui companion registers home and prompt status slots", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 2);
1603
+ check("v2 tui companion registers only the prompt footer status slot", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 1 && v2SlotClaims.some((claim) => claim.append === "prompt.footer.status"));
1410
1604
  const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
1411
1605
  const diagLogText = () => readFileSync(diagLogPath, "utf8");
1412
1606
  check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
1413
- const v2HomeRender = v2SlotClaims.find((claim) => claim.append === "home.footer.status")!.render;
1607
+ const v2StatusRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
1414
1608
  // In a plain Node process there is no OpenTUI renderer, so badge() throws the
1415
1609
  // same "No renderer found" the long-running-session bug produced in the TUI.
1416
- check("v2 slot render returns null instead of throwing when the render context is unavailable", v2HomeRender() === null);
1417
- check("v2 slot failure log records probes and stack", diagLogText().includes("render home.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
1418
- for (let i = 0; i < 6; i++) v2HomeRender();
1419
- const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render home.footer.status FAILED (")).length;
1610
+ check("v2 slot render returns null instead of throwing when the render context is unavailable", v2StatusRender() === null);
1611
+ check("v2 slot failure log records probes and stack", diagLogText().includes("render prompt.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
1612
+ for (let i = 0; i < 6; i++) v2StatusRender();
1613
+ const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render prompt.footer.status FAILED (")).length;
1420
1614
  check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
1421
1615
  check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
1422
- const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
1423
- check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
1424
-
1425
1616
 
1426
1617
  // ── Adversarial tests & hardened invariants ──
1427
1618
  console.log("- Adversarial tests & hardened invariants -");
@@ -2427,6 +2618,36 @@ check(
2427
2618
  );
2428
2619
  rmSync(rubricDiffRepo, { recursive: true, force: true });
2429
2620
 
2621
+ // The default-bases loop is directly asserted: with no origin remote, the
2622
+ // loop falls through origin/main and origin/master to the local main branch
2623
+ // and embeds the real branch diff. The spawn also keeps a trailing `--` after
2624
+ // the revision so the base ref can never parse as a pathspec (LL-001).
2625
+ const rubricNoOrigin = join(root, "wg-rubric-no-origin");
2626
+ mkdirSync(rubricNoOrigin, { recursive: true });
2627
+ spawnSync("git", ["init", "-b", "main"], { cwd: rubricNoOrigin });
2628
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: rubricNoOrigin });
2629
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: rubricNoOrigin });
2630
+ writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "base\n");
2631
+ spawnSync("git", ["add", "fallthrough.txt"], { cwd: rubricNoOrigin });
2632
+ spawnSync("git", ["commit", "-m", "base"], { cwd: rubricNoOrigin });
2633
+ spawnSync("git", ["switch", "-c", "feature/rubric-fallthrough"], { cwd: rubricNoOrigin });
2634
+ writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "branch marker\n");
2635
+ spawnSync("git", ["add", "fallthrough.txt"], { cwd: rubricNoOrigin });
2636
+ spawnSync("git", ["commit", "-qm", "branch change"], { cwd: rubricNoOrigin });
2637
+ // An uncommitted tracked edit discriminates the fallbacks: main...HEAD is
2638
+ // committed-only and must NOT include it, while the HEAD~1 fallback (commit
2639
+ // vs worktree) would — so the assertion proves the base loop won.
2640
+ writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "branch marker\nuncommitted worktree edit\n");
2641
+ const rubricFallthrough = await customPlugin.tool?.guard_review_rubric?.execute(
2642
+ { directory: rubricNoOrigin },
2643
+ { sessionID: "s-rubric-fallthrough", worktree: rubricNoOrigin, directory: rubricNoOrigin } as any,
2644
+ );
2645
+ check(
2646
+ "rubric default bases fall through to the local main when no origin exists",
2647
+ typeof rubricFallthrough === "string" && rubricFallthrough.includes("diff --git") && rubricFallthrough.includes("fallthrough.txt") && rubricFallthrough.includes("branch marker") && !rubricFallthrough.includes("uncommitted worktree edit"),
2648
+ );
2649
+ rmSync(rubricNoOrigin, { recursive: true, force: true });
2650
+
2430
2651
  // Reviewer-type agents often lack record_review in their toolset entirely, so
2431
2652
  // the tool accepts recorders of any session type: the verdict binds to the
2432
2653
  // recording session's directory and the audit trail captures the recorder
@@ -2677,6 +2898,119 @@ check("PR blocked when only the reviewed commit drifted, with a commit-specific
2677
2898
  rmSync(driftRepo, { recursive: true, force: true });
2678
2899
  resetReviewState();
2679
2900
 
2901
+ // Strict recorder mode (requireSubagentReview): an opt-in policy that closes
2902
+ // the root-session self-approval path which default content binding keeps
2903
+ // open for read-only reviewer relays. Approvals must be recorded from a
2904
+ // subagent session (lineage, not agent role); changes-requested verdicts
2905
+ // stay relayable.
2906
+ console.log("- Strict Recorder Mode (requireSubagentReview) -");
2907
+ const strictSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
2908
+ const strictRepo = join(root, "wg-review-strict");
2909
+ mkdirSync(strictRepo, { recursive: true });
2910
+ spawnSync("git", ["init", "-b", "feature/strict-recorder"], { cwd: strictRepo });
2911
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: strictRepo });
2912
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: strictRepo });
2913
+ writeFileSync(join(strictRepo, "code.txt"), "base\n");
2914
+ spawnSync("git", ["add", "code.txt"], { cwd: strictRepo });
2915
+ spawnSync("git", ["commit", "-m", "base"], { cwd: strictRepo });
2916
+ mkdirSync(join(strictRepo, ".opencode"), { recursive: true });
2917
+ writeFileSync(join(strictRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true, requireSubagentReview: true }));
2918
+ const strictPlugin = await WorkflowGuard({
2919
+ directory: root,
2920
+ worktree: root,
2921
+ client: fakeClient as any,
2922
+ project: {} as any,
2923
+ experimental_workspace: {} as any,
2924
+ serverUrl: new URL("http://localhost:4096"),
2925
+ $: undefined as any,
2926
+ });
2927
+ const strictPrProbe = { tool: "bash", input: { command: "gh pr create --title t --body 'Changelog: strict'", workdir: strictRepo } };
2928
+
2929
+ // Root-session self-approval is rejected before any verdict is recorded
2930
+ const strictRootAttempt = await strictPlugin.tool?.record_review?.execute(
2931
+ { reviewer: "self-approved", summary: strictSummary, passed: true },
2932
+ { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
2933
+ );
2934
+ const strictRootAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-strict-root");
2935
+ check("strict mode: root-session approval is rejected with an actionable cause", typeof strictRootAttempt === "string" && strictRootAttempt.includes("rejected") && strictRootAttempt.includes("requireSubagentReview") && !strictRootAttempt.includes("APPROVED"));
2936
+ check("strict mode: rejected root approval is audited as subagent_recorder_required", strictRootAudit?.reason === "subagent_recorder_required");
2937
+ const strictStatusAfterRoot = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: strictRepo }, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2938
+ check("strict mode: review gate stays unsatisfied after rejected self-approval", strictStatusAfterRoot.outstandingRequirements.includes("review") && strictStatusAfterRoot.lastReview === null && strictStatusAfterRoot.projectConfig?.requireSubagentReview === true);
2939
+ const strictPrAfterRoot = JSON.parse(String(await strictPlugin.tool?.guard_why?.execute(strictPrProbe, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2940
+ check("strict mode: PR preflight still blocked after rejected self-approval", strictPrAfterRoot.status === "blocked" && strictPrAfterRoot.message.includes("Passing secondary review approval is required"));
2941
+
2942
+ // Changes-requested findings remain relayable from the root session
2943
+ const strictRootNegative = await strictPlugin.tool?.record_review?.execute(
2944
+ { reviewer: "self-flagged", summary: strictSummary, passed: false },
2945
+ { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
2946
+ );
2947
+ check("strict mode: root session may still relay changes-requested verdicts", typeof strictRootNegative === "string" && strictRootNegative.includes("CHANGES REQUESTED"));
2948
+
2949
+ // A subagent session (has a parent) records the approval
2950
+ fakeParents.set("s-strict-reviewer", "s-strict-root");
2951
+ const strictSubagentResult = await strictPlugin.tool?.record_review?.execute(
2952
+ { reviewer: "strict-reviewer", summary: strictSummary, passed: true },
2953
+ { sessionID: "s-strict-reviewer", agent: "general", worktree: strictRepo, directory: strictRepo } as any,
2954
+ );
2955
+ check("strict mode: subagent recorder records the approval", typeof strictSubagentResult === "string" && strictSubagentResult.includes("APPROVED"));
2956
+ const strictStatusAfterSubagent = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: strictRepo }, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2957
+ check("strict mode: subagent approval satisfies the review gate", !strictStatusAfterSubagent.outstandingRequirements.includes("review") && strictStatusAfterSubagent.lastReview?.fresh === true);
2958
+ const strictPrAfterSubagent = JSON.parse(String(await strictPlugin.tool?.guard_why?.execute(strictPrProbe, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2959
+ check("strict mode: PR preflight passes after subagent-recorded approval", strictPrAfterSubagent.status !== "blocked" && (strictPrAfterSubagent.status === "allowed" || strictPrAfterSubagent.code === "remote_state_unchecked"));
2960
+ if (strictPrAfterSubagent.status === "blocked") console.log(" blocked:", strictPrAfterSubagent.message);
2961
+
2962
+ // The knob follows the REVIEWED workspace's config, not the caller's: a root
2963
+ // session based in a non-strict context cannot approve strictRepo's work by
2964
+ // naming it as the reviewed directory from outside strict mode. Note the
2965
+ // reviewed workspace comes from the tool ARG (`directory`), not the caller's
2966
+ // context; the context worktree below is deliberately the non-strict temp root.
2967
+ const strictCrossAttempt = await strictPlugin.tool?.record_review?.execute(
2968
+ { reviewer: "cross-root", summary: strictSummary, passed: true, directory: strictRepo },
2969
+ { sessionID: "s-strict-cross", worktree: root, directory: root } as any,
2970
+ );
2971
+ const strictCrossAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-strict-cross");
2972
+ check("strict mode: the knob follows the reviewed workspace, not the caller's repo", typeof strictCrossAttempt === "string" && strictCrossAttempt.includes("rejected") && strictCrossAttempt.includes("requireSubagentReview") && !strictCrossAttempt.includes("APPROVED"));
2973
+ check("strict mode: cross-repo root approval audited as subagent_recorder_required", strictCrossAudit?.reason === "subagent_recorder_required");
2974
+
2975
+ // Env override: =0 disables the knob even when project config enables it
2976
+ process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW = "0";
2977
+ const strictEnvOff = await strictPlugin.tool?.record_review?.execute(
2978
+ { reviewer: "env-off-root", summary: strictSummary, passed: true },
2979
+ { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
2980
+ );
2981
+ check("strict mode env off: WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=0 overrides the enabled project config", typeof strictEnvOff === "string" && strictEnvOff.includes("APPROVED"));
2982
+ delete process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW;
2983
+
2984
+ rmSync(strictRepo, { recursive: true, force: true });
2985
+ resetReviewState();
2986
+
2987
+ // Environment overrides follow the requireReview pattern; the default keeps
2988
+ // root-session relay recording allowed without the knob.
2989
+ const relaxedRepo = join(root, "wg-review-relaxed");
2990
+ mkdirSync(relaxedRepo, { recursive: true });
2991
+ spawnSync("git", ["init", "-b", "feature/relaxed-recorder"], { cwd: relaxedRepo });
2992
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: relaxedRepo });
2993
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: relaxedRepo });
2994
+ writeFileSync(join(relaxedRepo, "code.txt"), "base\n");
2995
+ spawnSync("git", ["add", "code.txt"], { cwd: relaxedRepo });
2996
+ spawnSync("git", ["commit", "-m", "base"], { cwd: relaxedRepo });
2997
+ process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW = "1";
2998
+ const relaxedEnvAttempt = await strictPlugin.tool?.record_review?.execute(
2999
+ { reviewer: "self-approved", summary: strictSummary, passed: true },
3000
+ { sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any,
3001
+ );
3002
+ check("strict recorder env override: WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1 applies without project config", typeof relaxedEnvAttempt === "string" && relaxedEnvAttempt.includes("rejected") && relaxedEnvAttempt.includes("subagent"));
3003
+ const relaxedEnvStatus = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: relaxedRepo }, { sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any)));
3004
+ check("strict recorder env override: status echoes the forced requirement", relaxedEnvStatus.projectConfig?.requireSubagentReview === true);
3005
+ delete process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW;
3006
+ const relaxedDefault = await strictPlugin.tool?.record_review?.execute(
3007
+ { reviewer: "relayed-reviewer", summary: strictSummary, passed: true },
3008
+ { sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any,
3009
+ );
3010
+ check("strict recorder default: root-session relay recording stays allowed without the knob", typeof relaxedDefault === "string" && relaxedDefault.includes("APPROVED"));
3011
+ rmSync(relaxedRepo, { recursive: true, force: true });
3012
+ resetReviewState();
3013
+
2680
3014
  // Event hook handles permission events
2681
3015
  if (typeof customPlugin.event === "function") {
2682
3016
  await customPlugin.event({
@@ -2786,6 +3120,39 @@ spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conf
2786
3120
  const baseUpToDateCheck = checkBranchBaseIsUpToDate(conflictRepo);
2787
3121
  check("checkBranchBaseIsUpToDate passes when equal to remote", !baseUpToDateCheck.isBehind);
2788
3122
 
3123
+ // Policy 20: branch creation start-point awareness. Advance the simulated
3124
+ // remote past the current branch so HEAD is stale, then verify that an
3125
+ // explicit fresh start point clears the gate while startless and
3126
+ // indeterminate creations still fail (closed).
3127
+ spawnSync("git", ["switch", "-c", "wg-origin-advance"], { cwd: conflictRepo });
3128
+ writeFileSync(join(conflictRepo, "file.txt"), "remote advance content\n");
3129
+ spawnSync("git", ["commit", "-am", "remote advance"], { cwd: conflictRepo });
3130
+ spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conflictRepo });
3131
+ spawnSync("git", ["switch", "feat/conflict-branch"], { cwd: conflictRepo });
3132
+ const staleHeadCheck = checkBranchBaseIsUpToDate(conflictRepo);
3133
+ check(
3134
+ "checkBranchBaseIsUpToDate attributes the stale current branch and working remedies",
3135
+ staleHeadCheck.isBehind &&
3136
+ staleHeadCheck.baseRef === "origin/main" &&
3137
+ (staleHeadCheck.reason ?? "").includes("'feat/conflict-branch'") &&
3138
+ (staleHeadCheck.reason ?? "").includes("git rebase origin/main"),
3139
+ );
3140
+ const freshStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main"]);
3141
+ check("checkBranchBaseIsUpToDate accepts an explicit start point containing the remote default", !freshStartCheck.isBehind);
3142
+ const staleStartSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: conflictRepo, encoding: "utf8" }).stdout!.trim();
3143
+ const staleStartCheck = checkBranchBaseIsUpToDate(conflictRepo, [staleStartSha]);
3144
+ check("checkBranchBaseIsUpToDate blocks an explicit stale start point", staleStartCheck.isBehind && (staleStartCheck.reason ?? "").includes("start point"));
3145
+ const unresolvableCheck = checkBranchBaseIsUpToDate(conflictRepo, ["$WG_START_REF"]);
3146
+ check("checkBranchBaseIsUpToDate fails closed on an unresolvable start point", unresolvableCheck.isBehind && (unresolvableCheck.reason ?? "").includes("could not be resolved"));
3147
+ const mixedStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main", undefined]);
3148
+ check("checkBranchBaseIsUpToDate keeps the head check when a creation lacks a start point", mixedStartCheck.isBehind && (mixedStartCheck.reason ?? "").includes("'feat/conflict-branch'"));
3149
+ check("git switch -c without a start point is blocked on a stale branch", blocked(await shell("git switch -c wg-start-stale")));
3150
+ check("git switch -c origin/main with a stale branch is allowed", !blocked(await shell("git switch -c wg-start-fresh origin/main")));
3151
+ check("git checkout -b origin/main with a stale branch is allowed", !blocked(await shell("git checkout -b wg-checkout-fresh origin/main")));
3152
+ check("wrapper git switch -c origin/main keeps the fresh start point exemption", !blocked(await shell("env git switch -c wg-wrapper-fresh origin/main")));
3153
+ check("variable start point fails closed on a stale branch", blocked(await shell("git switch -c wg-start-var $WG_REF")));
3154
+ check("plain git branch is not treated as guarded branch creation", !blocked(await shell("git branch wg-plain-branch origin/main")));
3155
+
2789
3156
  // Lockfile synchronization tests
2790
3157
  const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
2791
3158
  spawnSync("git", ["init", "-b", "main"], { cwd: lockRepo });
@@ -4254,6 +4621,80 @@ check("project-memory initialization failure leaves core guard hooks active", ty
4254
4621
  if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
4255
4622
  else process.env.XDG_DATA_HOME = prevDataHome;
4256
4623
 
4624
+ // Loaded plugin version visibility: OpenCode resolves configured bare plugin
4625
+ // names through Node's parent-directory lookup from its configuration
4626
+ // location, so the loaded copy can silently differ from a globally
4627
+ // npm-installed one (observed on-device: npm i -g updates ignored while a
4628
+ // home-level pnpm node_modules copy kept loading). The version is therefore
4629
+ // surfaced in guard_status and in the startup app log.
4630
+ console.log("- Git hygiene snapshot -");
4631
+ const hygieneRepo = join(root, "wg-hygiene");
4632
+ mkdirSync(hygieneRepo, { recursive: true });
4633
+ const hgit = (args: string[]): string => {
4634
+ const res = spawnSync("git", args, { cwd: hygieneRepo, encoding: "utf8" });
4635
+ if (res.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${res.stderr}`);
4636
+ return res.stdout;
4637
+ };
4638
+ hgit(["init", "-b", "main"]);
4639
+ hgit(["config", "user.email", "test@test.local"]);
4640
+ hgit(["config", "user.name", "Test Runner"]);
4641
+ writeFileSync(join(hygieneRepo, "base.txt"), "base\n");
4642
+ hgit(["add", "base.txt"]);
4643
+ hgit(["commit", "-m", "base"]);
4644
+ hgit(["switch", "-c", "feature/done"]);
4645
+ writeFileSync(join(hygieneRepo, "done.txt"), "done\n");
4646
+ hgit(["add", "done.txt"]);
4647
+ hgit(["commit", "-m", "done work"]);
4648
+ hgit(["switch", "main"]);
4649
+ hgit(["merge", "--ff-only", "feature/done"]);
4650
+ hgit(["switch", "-c", "feature/open"]);
4651
+ writeFileSync(join(hygieneRepo, "open.txt"), "open\n");
4652
+ hgit(["add", "open.txt"]);
4653
+ hgit(["commit", "-m", "open work"]);
4654
+ // Prunable worktree: register one, then delete its directory (crashed-add
4655
+ // simulation) so the admin entry stays behind.
4656
+ hgit(["worktree", "add", join(root, "wg-hygiene-wt"), "feature/done"]);
4657
+ rmSync(join(root, "wg-hygiene-wt"), { recursive: true, force: true });
4658
+ const hygieneStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneRepo }, { sessionID: "s-hygiene", worktree: hygieneRepo, directory: hygieneRepo } as any)));
4659
+ check("guard_status exposes the git hygiene snapshot", typeof hygieneStatus.gitHygiene === "object" && hygieneStatus.gitHygiene !== null);
4660
+ check("hygiene lists merged local branches excluding the current and unmerged", Array.isArray(hygieneStatus.gitHygiene.mergedLocalBranches) && hygieneStatus.gitHygiene.mergedLocalBranches.includes("feature/done") && !hygieneStatus.gitHygiene.mergedLocalBranches.includes("feature/open") && !hygieneStatus.gitHygiene.mergedLocalBranches.includes("main"));
4661
+ check("hygiene counts prunable worktree admin entries", hygieneStatus.gitHygiene.prunableWorktreeCount === 1);
4662
+ check("hygiene omits base-behind without a remote", hygieneStatus.gitHygiene.baseBehind === undefined);
4663
+ // The list is capped while the count reflects reality.
4664
+ for (let i = 0; i < 12; i++) {
4665
+ hgit(["switch", "main"]);
4666
+ hgit(["switch", "-c", `feature/merged-${i}`]);
4667
+ writeFileSync(join(hygieneRepo, `merged-${i}.txt`), `${i}\n`);
4668
+ hgit(["add", `merged-${i}.txt`]);
4669
+ hgit(["commit", "-m", `merged ${i}`]);
4670
+ hgit(["switch", "main"]);
4671
+ hgit(["merge", "--ff-only", `feature/merged-${i}`]);
4672
+ }
4673
+ // End on a merged, non-base current branch: the current-branch filter must
4674
+ // exclude it from the merged list independently of the base filter.
4675
+ hgit(["switch", "feature/merged-11"]);
4676
+ const cappedStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneRepo }, { sessionID: "s-hygiene-capped", worktree: hygieneRepo, directory: hygieneRepo } as any)));
4677
+ check("hygiene branch list is capped, counts reality, and excludes the merged current branch", cappedStatus.gitHygiene.mergedLocalBranchCount === 12 && cappedStatus.gitHygiene.mergedLocalBranches.length === 10 && !cappedStatus.gitHygiene.mergedLocalBranches.includes("feature/merged-11") && cappedStatus.gitHygiene.mergedLocalBranches.includes("feature/merged-10"));
4678
+ rmSync(hygieneRepo, { recursive: true, force: true });
4679
+ const hygieneNonGit = mkdtempSync(join(tmpdir(), "wg-hygiene-nongit-"));
4680
+ const nonGitStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneNonGit }, { sessionID: "s-hygiene-nongit", worktree: hygieneNonGit, directory: hygieneNonGit } as any)));
4681
+ check("hygiene snapshot is null outside a git repository", nonGitStatus.gitHygiene === null);
4682
+ rmSync(hygieneNonGit, { recursive: true, force: true });
4683
+
4684
+ console.log("- Loaded plugin version visibility -");
4685
+ const expectedVersion = String((JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version);
4686
+ check("loadedPluginVersion reads the adjacent package manifest", loadedPluginVersion() === expectedVersion);
4687
+ check("loadedPluginVersion fails soft for an unreadable manifest", loadedPluginVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
4688
+ const versionStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: root }, { sessionID: "s-version", worktree: root, directory: root } as any)));
4689
+ check("guard_status exposes the loaded plugin version", versionStatus.pluginVersion === expectedVersion);
4690
+ const versionLogs: string[] = [];
4691
+ await WorkflowGuard({
4692
+ directory: root,
4693
+ worktree: root,
4694
+ client: { app: { log: async (opts: { body: { message: string } }) => { versionLogs.push(opts.body.message); } } } as any,
4695
+ } as any);
4696
+ check("startup app log states the loaded plugin version", versionLogs.some((message) => message.startsWith(`Workflow Guard v${expectedVersion} plugin initialized for ${root}; `) && message.includes("managed config")));
4697
+
4257
4698
  console.log("- live control-plane paths (tier port: config-path facts) -");
4258
4699
  const liveBase = mkdtempSync(join(tmpdir(), "wg-lcp-base-"));
4259
4700
  const livePlane = join(liveBase, ".config", "open" + "code");