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