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/docs/agents/lessons.md +3 -0
- package/docs/installation.md +4 -0
- package/docs/policies.md +6 -3
- package/docs/troubleshooting.md +5 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +20 -6
- package/src/lib/guard-dispatcher.ts +61 -8
- package/src/lib/hygiene.ts +66 -0
- package/src/lib/state.ts +7 -0
- package/src/lib/types.ts +18 -1
- package/src/lib/v2-plugin.ts +2 -1
- package/src/lib/version.ts +21 -0
- package/src/policies/boundary.ts +36 -0
- package/src/policies/file-claims.ts +68 -2
- package/src/policies/git.ts +134 -11
- package/src/workflow-guard-ui.ts +1 -2
- package/src/workflow-guard.ts +6 -1
- package/test/test.mts +450 -9
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
|
|
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
|
|
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",
|
|
1417
|
-
check("v2 slot failure log records probes and stack", diagLogText().includes("render
|
|
1418
|
-
for (let i = 0; i < 6; i++)
|
|
1419
|
-
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render
|
|
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");
|