opencode-workflow-guard 1.15.3 → 1.16.0
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 +22 -5
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/installation.md +80 -5
- package/docs/managed-deployment.md +1 -1
- package/docs/policies.md +20 -5
- package/docs/policy-posture.md +171 -0
- package/docs/testing.md +4 -1
- package/package.json +16 -9
- package/src/lib/call-claims.ts +71 -0
- package/src/lib/config-writer.d.mts +13 -0
- package/src/lib/config-writer.mjs +89 -0
- package/src/lib/custom-tools.ts +69 -8
- package/src/lib/guard-command.ts +97 -0
- package/src/lib/guard-dispatcher.ts +56 -0
- package/src/lib/host-worktree.ts +109 -0
- package/src/lib/mcp-inventory.ts +86 -0
- package/src/lib/permission-seat.ts +119 -0
- package/src/lib/plugin-instance.ts +124 -0
- package/src/lib/policy-posture.ts +227 -0
- package/src/lib/project-memory.ts +16 -0
- package/src/lib/read-only-tools.ts +61 -0
- package/src/lib/state.ts +167 -36
- package/src/lib/v2-plugin.ts +208 -22
- package/src/lib/v2-todo.ts +137 -7
- package/src/lib/version.ts +22 -0
- package/src/lib/worktree.ts +61 -18
- package/src/policies/mcp.ts +47 -2
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +221 -6
- package/src/workflow-guard.ts +17 -5
- package/test/test-e2e.mts +91 -6
- package/test/test.mts +861 -4
package/test/test-e2e.mts
CHANGED
|
@@ -11,8 +11,13 @@ const check = (name: string, cond: unknown): boolean => {
|
|
|
11
11
|
cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
|
|
12
12
|
return Boolean(cond);
|
|
13
13
|
};
|
|
14
|
+
// A live probe can only produce a guard verdict if the model actually ran.
|
|
15
|
+
// These are host/provider errors that mean it did not: the prompt never
|
|
16
|
+
// reached a model, so scoring the probe as a guard failure would be a false
|
|
17
|
+
// accusation. Match the host's literal error shapes, not the bare phrase, so
|
|
18
|
+
// model prose cannot mask a real guard regression.
|
|
14
19
|
const providerUnavailable = (output: string): boolean =>
|
|
15
|
-
/available credits|insufficient credits|rate limit|too many requests|capacity|overloaded
|
|
20
|
+
/available credits|insufficient credits|rate limit|too many requests|capacity|overloaded|Model unavailable:/i.test(output);
|
|
16
21
|
const checkLive = (name: string, cond: unknown, output: string): boolean => {
|
|
17
22
|
if (!cond && providerUnavailable(output)) {
|
|
18
23
|
unavailable++;
|
|
@@ -45,11 +50,11 @@ const packageEntry = join(testDir, "node_modules", "opencode-workflow-guard", "s
|
|
|
45
50
|
const installedPackageDir = join(testDir, "node_modules", "opencode-workflow-guard");
|
|
46
51
|
const installedPackageJson = JSON.parse(readFileSync(join(testDir, "node_modules", "opencode-workflow-guard", "package.json"), "utf8"));
|
|
47
52
|
check(
|
|
48
|
-
"npm package
|
|
53
|
+
"npm package pins every direct dependency to an exact version",
|
|
49
54
|
Object.keys(installedPackageJson.dependencies ?? {}).length > 0 &&
|
|
50
|
-
Object.values(installedPackageJson.dependencies).every((spec) => spec
|
|
55
|
+
Object.values(installedPackageJson.dependencies).every((spec) => /^\d+\.\d+\.\d+$/.test(String(spec))) &&
|
|
51
56
|
Object.keys(installedPackageJson.devDependencies ?? {}).length > 0 &&
|
|
52
|
-
Object.values(installedPackageJson.devDependencies).every((spec) => spec
|
|
57
|
+
Object.values(installedPackageJson.devDependencies).every((spec) => /^\d+\.\d+\.\d+$/.test(String(spec))),
|
|
53
58
|
);
|
|
54
59
|
check(
|
|
55
60
|
"npm tarball installs modular plugin entrypoint",
|
|
@@ -226,6 +231,7 @@ const runV2Probes = async (ctx: any) => {
|
|
|
226
231
|
\t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
|
|
227
232
|
\t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
|
|
228
233
|
\t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
|
|
234
|
+
\t\t"todo-completion": { tool: "todowrite", input: { todos: [{ content: "unstarted e2e work", status: "completed", priority: "medium" }] } },
|
|
229
235
|
\t};
|
|
230
236
|
\tconst whyResults: Record<string, any> = {};
|
|
231
237
|
\tfor (const [name, probe] of Object.entries(whyProbes)) {
|
|
@@ -253,6 +259,7 @@ const runV1Probes = async (ctx: any) => {
|
|
|
253
259
|
\t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
|
|
254
260
|
\t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
|
|
255
261
|
\t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
|
|
262
|
+
\t\t"todo-completion": { tool: "todowrite", input: { todos: [{ content: "unstarted e2e work", status: "completed", priority: "medium" }] } },
|
|
256
263
|
\t};
|
|
257
264
|
\tconst whyResults: Record<string, any> = {};
|
|
258
265
|
\tfor (const [name, probe] of Object.entries(whyProbes)) {
|
|
@@ -322,8 +329,11 @@ if (isOpenCodeV2) {
|
|
|
322
329
|
const servicePort = 49400 + (process.pid % 1000);
|
|
323
330
|
spawnSync("opencode", ["service", "set", "port", String(servicePort)], { cwd: testDir, encoding: "utf8", env: runtimeEnv, timeout: 30_000 });
|
|
324
331
|
}
|
|
332
|
+
// `run` takes its directory from the process cwd; 2.0.15 rejects `--dir` as an
|
|
333
|
+
// unknown flag, which made every live probe report a flag error instead of a
|
|
334
|
+
// guard verdict.
|
|
325
335
|
const runOpenCode = (args: string[], timeout: number) =>
|
|
326
|
-
spawnSync("opencode", ["run",
|
|
336
|
+
spawnSync("opencode", ["run", ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
|
|
327
337
|
const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
|
|
328
338
|
const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: runtimeEnv });
|
|
329
339
|
let stdout = "";
|
|
@@ -401,6 +411,9 @@ check("headless guard_why simulates secret read policy via read tool", whyProbe(
|
|
|
401
411
|
check("headless guard_why simulates secret read policy via shell", whyProbe("secrets-shell")?.policy === "secrets" && whyProbe("secrets-shell")?.code === "secret_read" && whyProbe("secrets-shell")?.status === "blocked");
|
|
402
412
|
check("headless guard_why simulates workspace boundary policy via write tool", whyProbe("boundary-write")?.policy === "boundary" && whyProbe("boundary-write")?.code === "workspace_escape" && whyProbe("boundary-write")?.status === "blocked");
|
|
403
413
|
check("headless guard_why simulates interactive tty shell-safety policy", whyProbe("shell-tty")?.policy === "shell-safety" && whyProbe("shell-tty")?.code === "interactive_tty" && whyProbe("shell-tty")?.status === "blocked");
|
|
414
|
+
// The completion gate is host-version independent: claiming work the session
|
|
415
|
+
// never did is blocked through the real dispatcher, on both adapter paths.
|
|
416
|
+
check("headless guard_why simulates the todo completion evidence gate", whyProbe("todo-completion")?.policy === "todo" && whyProbe("todo-completion")?.code === "todo_completion_unverified" && whyProbe("todo-completion")?.status === "blocked");
|
|
404
417
|
check("headless guard_status reports project config loaded from .opencode/workflow-guard.json", headlessAccountability?.status?.projectConfig?.protectedBranches?.[0] === "e2e-config-probe-branch" && headlessAccountability?.status?.projectConfig?.requireReview === false);
|
|
405
418
|
if (isOpenCodeV2) {
|
|
406
419
|
// The V2 adapter runs the shipped setup(ctx) for real; verify the
|
|
@@ -421,6 +434,78 @@ if (isOpenCodeV2) {
|
|
|
421
434
|
console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
|
|
422
435
|
}
|
|
423
436
|
|
|
437
|
+
// The todo capability probe is only meaningful against a host that can gate
|
|
438
|
+
// the tool. Probe the flag rather than the version: a host that does not know
|
|
439
|
+
// `experimental.todo` keeps the capability unavailable, which is the branch
|
|
440
|
+
// the main pass already covers.
|
|
441
|
+
const hostMinor = Number(opencodeSemver.split(".")[1] ?? 0);
|
|
442
|
+
if (isOpenCodeV2 && (opencodeMajor > 2 || (opencodeMajor === 2 && hostMinor >= 1))) {
|
|
443
|
+
const todoConfig = join(testDir, ".opencode", "opencode.json");
|
|
444
|
+
const previousConfig = readFileSync(todoConfig, "utf8");
|
|
445
|
+
const previousAccountability = readFileSync(accountabilityMarker, "utf8");
|
|
446
|
+
rmSync(initializedMarker, { force: true });
|
|
447
|
+
// A second pass needs its own XDG roots and service port. Reusing the first
|
|
448
|
+
// pass's state dirs reconnects to its already-running service, which serves
|
|
449
|
+
// the config it started with and never re-runs plugin setup.
|
|
450
|
+
const todoEnv: NodeJS.ProcessEnv = {
|
|
451
|
+
...runtimeEnv,
|
|
452
|
+
XDG_CONFIG_HOME: join(testDir, "runtime-config-todo"),
|
|
453
|
+
XDG_STATE_HOME: join(testDir, "runtime-state-todo"),
|
|
454
|
+
XDG_DATA_HOME: join(testDir, "runtime-data-todo"),
|
|
455
|
+
};
|
|
456
|
+
const todoPort = 49400 + ((process.pid + 500) % 1000);
|
|
457
|
+
spawnSync("opencode", ["service", "set", "port", String(todoPort)], { cwd: testDir, encoding: "utf8", env: todoEnv, timeout: 30_000 });
|
|
458
|
+
try {
|
|
459
|
+
const parsed = JSON.parse(previousConfig);
|
|
460
|
+
writeFileSync(todoConfig, `${JSON.stringify({ ...parsed, experimental: { ...(parsed.experimental ?? {}), todo: true } }, null, 2)}\n`);
|
|
461
|
+
const todoProbe = await new Promise<{ status: number | null }>((resolve) => {
|
|
462
|
+
const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: todoEnv });
|
|
463
|
+
let timer: NodeJS.Timeout | undefined;
|
|
464
|
+
const poll = setInterval(() => {
|
|
465
|
+
if (existsSync(initializedMarker)) {
|
|
466
|
+
clearInterval(poll);
|
|
467
|
+
if (timer) clearTimeout(timer);
|
|
468
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
469
|
+
resolve({ status: 0 });
|
|
470
|
+
}
|
|
471
|
+
}, 50);
|
|
472
|
+
timer = setTimeout(() => {
|
|
473
|
+
clearInterval(poll);
|
|
474
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
475
|
+
resolve({ status: null });
|
|
476
|
+
}, 30_000);
|
|
477
|
+
child.on("error", () => {
|
|
478
|
+
clearInterval(poll);
|
|
479
|
+
if (timer) clearTimeout(timer);
|
|
480
|
+
resolve({ status: null });
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
let flagged: any;
|
|
484
|
+
try {
|
|
485
|
+
flagged = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
|
|
486
|
+
} catch {}
|
|
487
|
+
// Flag on: the host registry exposes todowrite, so the guard reports the
|
|
488
|
+
// capability available and the gate stops failing open.
|
|
489
|
+
check(
|
|
490
|
+
"V2 todo capability reports available when experimental.todo is enabled",
|
|
491
|
+
todoProbe.status === 0 &&
|
|
492
|
+
(flagged?.tools ?? []).includes("todowrite") &&
|
|
493
|
+
flagged?.status?.todoCapability === "available",
|
|
494
|
+
);
|
|
495
|
+
// The main pass ran with the flag off, so the capability there must be
|
|
496
|
+
// unavailable. Together the two passes prove the probe reads the
|
|
497
|
+
// registry instead of inferring from history.
|
|
498
|
+
check(
|
|
499
|
+
"V2 todo capability reports unavailable when experimental.todo is disabled",
|
|
500
|
+
(headlessAccountability?.tools ?? []).includes("todowrite") === false &&
|
|
501
|
+
headlessAccountability?.status?.todoCapability === "unavailable",
|
|
502
|
+
);
|
|
503
|
+
} finally {
|
|
504
|
+
writeFileSync(todoConfig, previousConfig);
|
|
505
|
+
writeFileSync(accountabilityMarker, previousAccountability);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
424
509
|
if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
|
|
425
510
|
console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
|
|
426
511
|
if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
|
|
@@ -450,7 +535,7 @@ console.log(` Using live model: ${liveModel}`);
|
|
|
450
535
|
|
|
451
536
|
// 3. Test: Direct edit without task list is blocked by the loaded plugin
|
|
452
537
|
console.log(" Running live OpenCode prompt to verify plugin intercept...");
|
|
453
|
-
const run1 = runOpenCode(["--model", liveModel, "--print-logs", "--log-level", "
|
|
538
|
+
const run1 = runOpenCode(["--model", liveModel, "--print-logs", "--log-level", "debug", "Do not call todowrite. Invoke bash immediately with exactly this command: printf blocked > blocked.txt. This is a guard test; make the tool call even if you expect rejection."], 120_000);
|
|
454
539
|
|
|
455
540
|
const output1 = run1.stdout + run1.stderr;
|
|
456
541
|
const adapterInitialized = existsSync(initializedMarker);
|