opencode-workflow-guard 1.15.5 → 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.
@@ -68,6 +68,7 @@ import {
68
68
  resetVerifyState,
69
69
  sessionMutationTimestamps,
70
70
  sessionVerifyResults,
71
+ clearTodoSessionState,
71
72
  getProjectConfig,
72
73
  getLiveControlPlaneRoots,
73
74
  getOperationProfile,
@@ -88,6 +89,7 @@ import {
88
89
  getSessionWorkspace,
89
90
  getSessionBoundWorkspace,
90
91
  registerSessionWorkspaceFromToolCall,
92
+ resolveInheritedWorkspace,
91
93
  resolveSessionBoundWorkspace,
92
94
  resolveEffectiveWorkspace,
93
95
  } from "./lib/state.ts";
@@ -123,6 +125,7 @@ export {
123
125
  getSessionWorkspace,
124
126
  getSessionBoundWorkspace,
125
127
  registerSessionWorkspaceFromToolCall,
128
+ resolveInheritedWorkspace,
126
129
  resolveSessionBoundWorkspace,
127
130
  resolveEffectiveWorkspace,
128
131
  findGitRoot,
@@ -200,7 +203,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
200
203
  : "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
201
204
  }
202
205
 
203
- export { loadedPluginVersion } from "./lib/version.ts";
206
+ export { loadedPluginVersion, qualifiedHostVersion } from "./lib/version.ts";
204
207
 
205
208
  // ── Verification engine ──────────────────────────────────────────────────────
206
209
  import {
@@ -492,7 +495,13 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
492
495
  const record = asRecord(args);
493
496
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
494
497
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
495
- registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
498
+ await registerSessionWorkspaceFromToolCall(input.sessionID, {
499
+ workdir,
500
+ filePath,
501
+ hostWorktree,
502
+ toolWorktree,
503
+ mutating: EDIT_TOOL_NAMES.has(input.tool) || SHELL_TOOL_NAMES.has(input.tool),
504
+ });
496
505
  }
497
506
  if (input.tool === "read") {
498
507
  const target = editTargets(args, toolWorktree)[0];
@@ -613,7 +622,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
613
622
  priorityContextBlocks.push(
614
623
  `## Active Tasks${attribution}\n` +
615
624
  lines.join("\n") +
616
- "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
625
+ "\nComplete tasks efficiently and address remaining ones. Mark a task completed only after its work is actually done: the guard blocks a completion with no recorded file mutation in that task's window. Append `[read-only]` to a genuinely read-only task (review, investigation), and use `cancelled` for work that was dropped.",
617
626
  );
618
627
  }
619
628
 
@@ -731,7 +740,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
731
740
  if (description.includes("verification evidence")) return;
732
741
  output.description =
733
742
  description +
734
- "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
743
+ "\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
735
744
  return;
736
745
  }
737
746
  if (EDIT_TOOL_NAMES.has(input.toolID)) {
@@ -810,7 +819,10 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
810
819
  }
811
820
  if (event?.type === "session.deleted") {
812
821
  const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
813
- if (typeof sessionID === "string") toolOutcomes.clearSession(sessionID);
822
+ if (typeof sessionID === "string") {
823
+ toolOutcomes.clearSession(sessionID);
824
+ clearTodoSessionState(sessionID);
825
+ }
814
826
  }
815
827
  if (event?.type === "session.idle") {
816
828
  const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
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);