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/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/i.test(output);
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 keeps direct dependencies on latest",
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 === "latest") &&
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 === "latest"),
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", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
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", "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);
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);