opencode-workflow-guard 1.12.1 → 1.13.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 +5 -2
- package/docs/installation.md +4 -3
- package/docs/policies.md +6 -2
- package/package.json +4 -2
- package/src/lib/guard-dispatcher.ts +6 -2
- package/src/lib/types.ts +1 -0
- package/src/lib/v2-plugin.ts +625 -0
- package/src/lib/verify.ts +11 -0
- package/src/policies/boundary.ts +16 -3
- package/src/policies/continuation.ts +20 -1
- package/src/policies/git.ts +42 -1
- package/src/policies/tamper.ts +17 -0
- package/src/workflow-guard-ui.ts +95 -14
- package/src/workflow-guard.ts +26 -5
- package/test/test-e2e.mts +129 -27
- package/test/test.mts +67 -1
package/test/test.mts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
WorkflowGuard,
|
|
15
15
|
buildCompactionContext,
|
|
16
16
|
detectVerifyCommand,
|
|
17
|
+
resolveVerifyTimeoutMs,
|
|
17
18
|
runVerify,
|
|
18
19
|
getCleanEnv,
|
|
19
20
|
resetVerifyState,
|
|
@@ -442,6 +443,11 @@ check("block auth with global flags (--dir)", blocked(await shell(`${ocCli} --di
|
|
|
442
443
|
check("block config with global flags (-d)", blocked(await shell(`${ocCli} -d . ${"con" + "fig"} edit`)));
|
|
443
444
|
check("block permission with global flags (--workspace)", blocked(await shell(`${ocCli} --workspace . ${"perm" + "ission"} grant`)));
|
|
444
445
|
check("allow normal command", !(await shell("ls -la && git status")));
|
|
446
|
+
const ocDir = ".open" + "code/plans/";
|
|
447
|
+
check("allow gh issue create mentioning .opencode paths (not tamper)", !(await shell(`gh issue create --repo example/proj --title t --body 'see <project>/${ocDir}'`)));
|
|
448
|
+
check("compound collaboration + tamper still blocked", blocked(await shell(`gh issue create --repo example/proj --title t && echo x > open${"code.json"}`)));
|
|
449
|
+
check("collaboration command with real redirect to global config still blocked", blocked(await shell(`gh issue list > /var/home/x/.config/open${"code"}/opencode.json`)));
|
|
450
|
+
check("collaboration command with redirect outside workspace still blocked", blocked(await shell(`gh pr create --title t > /tmp/wg-escape-probe`)));
|
|
445
451
|
|
|
446
452
|
console.log("- Policy 6: tamper via edit tools (path protection) -");
|
|
447
453
|
check("block edit of project opencode.json", blocked(await call("edit", { filePath: join(root, "opencode.json"), oldString: "a", newString: "b" }, { sessionID: "s-active" })));
|
|
@@ -449,6 +455,22 @@ check("block write of .opencode/project config", blocked(await call("write", { f
|
|
|
449
455
|
check("block write of global opencode config path", blocked(await call("write", { filePath: "/var/home/x/.config/opencode/opencode.json", content: "{}" }, { sessionID: "s-active" })));
|
|
450
456
|
check("block apply_patch to opencode.json", blocked(await call("apply_patch", { patchText: "*** Update File: opencode.json\n" }, { sessionID: "s-active" })));
|
|
451
457
|
check("allow edit of normal source file", !(await call("edit", { filePath: join(root, "src", "index.ts"), oldString: "a", newString: "b" }, { sessionID: "s-active" })));
|
|
458
|
+
check("allow write of docs mentioning .opencode paths (not tamper)", !(await call("write", { filePath: join(root, "docs", "guard.md"), content: "see `<project>/" + ocDir + "` and -> isProtectedPath" }, { sessionID: "s-active" })));
|
|
459
|
+
check("allow write of project plan file under .opencode/plans", !(await call("write", { filePath: join(root, ".opencode", "plans", "1789589538371-plan.md"), content: "# plan" }, { sessionID: "s-active" })));
|
|
460
|
+
check("block write escaping plans dir via .. (still tamper)", blocked(await call("write", { filePath: join(root, ".opencode", "plans", "..", "opencode.json"), content: "{}" }, { sessionID: "s-active" })));
|
|
461
|
+
check("block write of the plans directory itself (no trailing-slash exemption)", blocked(await call("write", { filePath: join(root, ".opencode", "plans"), content: "{}" }, { sessionID: "s-active" })));
|
|
462
|
+
check("block write under .opencode/plansx prefix (still tamper)", blocked(await call("write", { filePath: join(root, ".opencode", "plansx", "x.md"), content: "{}" }, { sessionID: "s-active" })));
|
|
463
|
+
check("block write to user-level config plans path (exemption is project-only)", blocked(await call("write", { filePath: "/var/home/x/.config/opencode/plans/x.md", content: "{}" }, { sessionID: "s-active" })));
|
|
464
|
+
// Plans dir symlinked at a config-shaped real location: lexical target is
|
|
465
|
+
// exempt but the realpath fallback must still block via the user-config check.
|
|
466
|
+
const plansAliasDir = mkdtempSync(join(tmpdir(), "wg-plans-alias-"));
|
|
467
|
+
mkdirSync(join(plansAliasDir, ".config", "opencode"), { recursive: true });
|
|
468
|
+
mkdirSync(join(plansAliasDir, ".opencode"), { recursive: true });
|
|
469
|
+
symlinkSync(join(plansAliasDir, ".config", "opencode"), join(plansAliasDir, ".opencode", "plans"), "dir");
|
|
470
|
+
setWorkspaceRoot(plansAliasDir);
|
|
471
|
+
check("write through plans symlink into config real path blocked (realpath fallback)", blocked(await call("write", { filePath: join(plansAliasDir, ".opencode", "plans", "x.md"), content: "{}" }, { sessionID: "s-active" })));
|
|
472
|
+
setWorkspaceRoot(root);
|
|
473
|
+
rmSync(plansAliasDir, { recursive: true, force: true });
|
|
452
474
|
console.log("- Policy 7: branch guard -");
|
|
453
475
|
// Non-git workspace (current `root` is a plain temp dir): git writes allowed.
|
|
454
476
|
check("non-git workspace: git commit allowed", !(await shell("git commit -m test")));
|
|
@@ -464,7 +486,8 @@ check("on main: git merge blocked", blocked(await shell("git merge feature/x")))
|
|
|
464
486
|
check("on main: git checkout path mutation without separator blocked", blocked(await shell("git checkout tracked.txt")));
|
|
465
487
|
check("on main: git checkout -B reset blocked", blocked(await shell("git checkout -B main HEAD~1")));
|
|
466
488
|
check("on main: git add blocked", blocked(await shell("git add tracked.txt")));
|
|
467
|
-
check("on main: git tag
|
|
489
|
+
check("on main: git tag creation allowed", !blocked(await shell("git tag release-test")));
|
|
490
|
+
check("on main: git tag -d blocked", blocked(await shell("git tag -d release-test")));
|
|
468
491
|
check("on main: git tag --list allowed", !blocked(await shell("git tag --list")));
|
|
469
492
|
check("on main: git switch -c allowed (branch creation)", !(await shell("git switch -c feat/x")));
|
|
470
493
|
check("on main: git status allowed", !(await shell("git status")));
|
|
@@ -1357,6 +1380,24 @@ rmSync(pipelineTimeoutMarker, { force: true });
|
|
|
1357
1380
|
await runVerify(`node -e "setTimeout(() => require('fs').writeFileSync('${pipelineTimeoutMarker}', 'orphan'), 300)" | cat`, root, 50);
|
|
1358
1381
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
|
1359
1382
|
check("runVerify timeout terminates descendants behind shell pipelines", !existsSync(pipelineTimeoutMarker));
|
|
1383
|
+
|
|
1384
|
+
console.log("- Policy 10: verification timeout resolution -");
|
|
1385
|
+
const previousVerifyTimeoutEnv = process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
|
|
1386
|
+
if (previousVerifyTimeoutEnv !== undefined) delete process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
|
|
1387
|
+
check("verification timeout resolution defaults to 30s", resolveVerifyTimeoutMs(root) === 30_000);
|
|
1388
|
+
const verifyTimeoutConfigRoot = mkdtempSync(join(tmpdir(), "wg-verify-timeout-"));
|
|
1389
|
+
mkdirSync(join(verifyTimeoutConfigRoot, ".opencode"));
|
|
1390
|
+
writeFileSync(join(verifyTimeoutConfigRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ verifyTimeoutMs: 60000 }));
|
|
1391
|
+
reloadProjectConfig(verifyTimeoutConfigRoot);
|
|
1392
|
+
check("verification timeout honors project config", resolveVerifyTimeoutMs(verifyTimeoutConfigRoot) === 60_000);
|
|
1393
|
+
writeFileSync(join(verifyTimeoutConfigRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ verifyTimeoutMs: -5 }));
|
|
1394
|
+
reloadProjectConfig(verifyTimeoutConfigRoot);
|
|
1395
|
+
check("verification timeout ignores invalid project config values", resolveVerifyTimeoutMs(verifyTimeoutConfigRoot) === 30_000);
|
|
1396
|
+
process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS = "120000";
|
|
1397
|
+
check("verification timeout env overrides a valid project config", resolveVerifyTimeoutMs(verifyTimeoutConfigRoot) === 120_000);
|
|
1398
|
+
delete process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
|
|
1399
|
+
if (previousVerifyTimeoutEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS = previousVerifyTimeoutEnv;
|
|
1400
|
+
rmSync(verifyTimeoutConfigRoot, { recursive: true, force: true });
|
|
1360
1401
|
let windowsCleanupFinished = false;
|
|
1361
1402
|
const windowsCleanup = terminateProcessTree(1234, () => {}, "win32", async (pid) => {
|
|
1362
1403
|
check("Windows timeout cleanup receives the child pid", pid === 1234);
|
|
@@ -2271,6 +2312,12 @@ const mergedCheck = isBranchAlreadyMergedOrClosed(conflictRepo, "feat/already-me
|
|
|
2271
2312
|
check("isBranchAlreadyMergedOrClosed identifies branch with 0 diff from main", mergedCheck.merged);
|
|
2272
2313
|
check("git push on already merged branch is blocked", blocked(await shell("git push origin feat/already-merged")));
|
|
2273
2314
|
|
|
2315
|
+
// Tag pushes are release operations, not branch mutations (#134).
|
|
2316
|
+
spawnSync("git", ["tag", "v0.3.0"], { cwd: conflictRepo });
|
|
2317
|
+
check("git push origin v0.3.0 allowed from merged branch", !blocked(await shell("git push origin v0.3.0")));
|
|
2318
|
+
check("git push origin refs/tags/v0.3.0 allowed", !blocked(await shell("git push origin refs/tags/v0.3.0")));
|
|
2319
|
+
check("git push origin :refs/tags/v0.3.0 still blocked", blocked(await shell("git push origin :refs/tags/v0.3.0")));
|
|
2320
|
+
|
|
2274
2321
|
// Create a branch with a genuine merge conflict against main
|
|
2275
2322
|
spawnSync("git", ["switch", "main"], { cwd: conflictRepo });
|
|
2276
2323
|
spawnSync("git", ["switch", "-c", "feat/conflict-branch"], { cwd: conflictRepo });
|
|
@@ -3080,6 +3127,20 @@ await continuationPlugin.event?.({ event: { type: "message.updated", properties:
|
|
|
3080
3127
|
await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-cap" } } } as any);
|
|
3081
3128
|
check("genuine user message resets continuation budget", continuationPrompts.filter((id) => id === "s-resume-cap").length === 4);
|
|
3082
3129
|
|
|
3130
|
+
console.log("- Policy 1: user interrupt stops automatic continuation -");
|
|
3131
|
+
todo("s-resume-abort", item("work the user stopped", "pending"));
|
|
3132
|
+
await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-abort" } } } as any);
|
|
3133
|
+
await continuationPlugin.event?.({ event: { type: "session.error", properties: { sessionID: "s-resume-abort", error: { name: "MessageAbortedError", data: { message: "aborted by user" } } } } } as any);
|
|
3134
|
+
await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-abort" } } } as any);
|
|
3135
|
+
check("user interrupt (Esc / MessageAbortedError) stops automatic continuation", continuationPrompts.filter((id) => id === "s-resume-abort").length === 1);
|
|
3136
|
+
await continuationPlugin.event?.({ event: { type: "message.updated", properties: { info: { id: "genuine-restart", role: "user", sessionID: "s-resume-abort" } } } } as any);
|
|
3137
|
+
await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-abort" } } } as any);
|
|
3138
|
+
check("genuine user input after an interrupt re-enables automatic continuation", continuationPrompts.filter((id) => id === "s-resume-abort").length === 2);
|
|
3139
|
+
todo("s-resume-api-error", item("work behind a flaky provider", "pending"));
|
|
3140
|
+
await continuationPlugin.event?.({ event: { type: "session.error", properties: { sessionID: "s-resume-api-error", error: { name: "APIError", data: { message: "provider overloaded" } } } } } as any);
|
|
3141
|
+
await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-api-error" } } } as any);
|
|
3142
|
+
check("non-abort session errors do not disable automatic continuation", continuationPrompts.filter((id) => id === "s-resume-api-error").length === 1);
|
|
3143
|
+
|
|
3083
3144
|
const ralphRoot = mkdtempSync(join(tmpdir(), "wg-ralph-"));
|
|
3084
3145
|
mkdirSync(join(ralphRoot, ".opencode"));
|
|
3085
3146
|
writeFileSync(join(ralphRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ ralphMode: true, ralphMaxIterations: 2 }));
|
|
@@ -3118,6 +3179,11 @@ check("duplicate genuine user events leave an active ralph run stopped", ralphPr
|
|
|
3118
3179
|
await ralphPlugin.event?.({ event: { type: "message.updated", properties: { info: { id: "human-restart", role: "user", sessionID: "s-ralph-stop" } } } } as any);
|
|
3119
3180
|
await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-stop" } } } as any);
|
|
3120
3181
|
check("distinct later user input can establish a fresh ralph run", ralphPrompts.filter((id) => id === "s-ralph-stop").length === 2 && runWithRuntimeState(ralphRoot, ralphClient as any, () => getRalphOutcome("s-ralph-stop")) === "running");
|
|
3182
|
+
todo("s-ralph-abort", item("ralph work the user stopped", "pending"));
|
|
3183
|
+
await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-abort" } } } as any);
|
|
3184
|
+
await ralphPlugin.event?.({ event: { type: "session.error", properties: { sessionID: "s-ralph-abort", error: { name: "MessageAbortedError", data: { message: "aborted by user" } } } } } as any);
|
|
3185
|
+
await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-abort" } } } as any);
|
|
3186
|
+
check("user interrupt stops an active ralph run as user_stopped", ralphPrompts.filter((id) => id === "s-ralph-abort").length === 1 && runWithRuntimeState(ralphRoot, ralphClient as any, () => getRalphOutcome("s-ralph-abort")) === "user_stopped");
|
|
3121
3187
|
todo("s-ralph-complete", item("completable work", "pending"));
|
|
3122
3188
|
await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-complete" } } } as any);
|
|
3123
3189
|
todo("s-ralph-complete", item("completable work", "completed"));
|