opencode-workflow-guard 1.12.1 → 1.13.2

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
@@ -7,8 +7,9 @@ import { parse as parseJsonc } from "jsonc-parser";
7
7
  let pass = 0;
8
8
  let fail = 0;
9
9
  let unavailable = 0;
10
- const check = (name: string, cond: unknown): void => {
10
+ const check = (name: string, cond: unknown): boolean => {
11
11
  cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
12
+ return Boolean(cond);
12
13
  };
13
14
  const providerUnavailable = (output: string): boolean =>
14
15
  /available credits|insufficient credits|rate limit|too many requests|capacity|overloaded/i.test(output);
@@ -137,7 +138,7 @@ check("server and TUI package specs resolve to different modules", installedPack
137
138
  const opencodeCheck = spawnSync("opencode", ["--version"], { encoding: "utf8" });
138
139
  if (opencodeCheck.status !== 0) {
139
140
  console.log("SKIP: opencode CLI is not available in PATH. Package checks passed; skipping live runtime load tests.");
140
- rmSync(testDir, { recursive: true, force: true });
141
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
141
142
  console.log(`\n${pass} passed, ${fail} failed`);
142
143
  process.exit(fail > 0 ? 1 : 0);
143
144
  }
@@ -149,20 +150,41 @@ mkdirSync(pluginsDir, { recursive: true });
149
150
 
150
151
  // Pre-seed .opencode with dependencies already installed into testDir
151
152
  // so opencode does not execute a cold network install at runtime startup.
152
- const opencodeVersion = opencodeCheck.stdout.trim();
153
+ const opencodeVersionRaw = opencodeCheck.stdout.trim();
154
+ const opencodeSemver = opencodeVersionRaw.match(/(\d+\.\d+\.\d+)/)?.[1] ?? opencodeVersionRaw;
155
+ const opencodeMajor = Number(opencodeSemver.split(".")[0] ?? 1);
156
+ const isOpenCodeV2 = opencodeMajor >= 2;
153
157
  const lockPath = join(testDir, "package-lock.json");
154
158
  if (existsSync(lockPath) && existsSync(join(testDir, "node_modules"))) {
155
159
  try {
156
160
  const lock = JSON.parse(readFileSync(lockPath, "utf8"));
157
161
  lock.name = ".opencode";
158
- if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
159
- lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeVersion;
162
+ const installedVersions: Record<string, string> = {};
163
+ for (const key of ["node_modules/@opencode-ai/plugin", "node_modules/@opencode/plugin"]) {
164
+ const version = lock.packages?.[key]?.version;
165
+ if (typeof version === "string" && version) installedVersions[key.replace("node_modules/", "")] = version;
160
166
  }
161
- if (lock.packages?.[""]) {
162
- lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeVersion };
167
+ if (isOpenCodeV2) {
168
+ // V2 loads .opencode deps with the real installed versions; rewriting
169
+ // them to the CLI version string would produce invalid semver.
170
+ const dependencies: Record<string, string> = {};
171
+ for (const [name, version] of Object.entries(installedVersions)) dependencies[name] = version;
172
+ if (!Object.keys(dependencies).length) dependencies["@opencode/plugin"] = "latest";
173
+ if (lock.packages?.[""]) {
174
+ lock.packages[""].dependencies = dependencies;
175
+ }
176
+ writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
177
+ writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies }, null, 2) + "\n");
178
+ } else {
179
+ if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
180
+ lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeSemver;
181
+ }
182
+ if (lock.packages?.[""]) {
183
+ lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeSemver };
184
+ }
185
+ writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
186
+ writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies: { "@opencode-ai/plugin": opencodeSemver } }, null, 2) + "\n");
163
187
  }
164
- writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
165
- writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies: { "@opencode-ai/plugin": opencodeVersion } }, null, 2) + "\n");
166
188
  cpSync(join(testDir, "node_modules"), join(opencodeDir, "node_modules"), { recursive: true });
167
189
  } catch {}
168
190
  }
@@ -179,20 +201,63 @@ const importedMarker = join(testDir, ".workflow-guard-imported");
179
201
  const initializedMarker = join(testDir, ".workflow-guard-initialized");
180
202
  const accountabilityMarker = join(testDir, ".workflow-guard-accountability");
181
203
  writeFileSync(localAdapter, `import { writeFileSync } from "node:fs";
204
+ import { Plugin } from "@opencode/plugin";
182
205
 
183
206
  writeFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
184
207
 
185
- export const WorkflowGuardE2E = async (ctx: any) => {
186
- \tconst { WorkflowGuard } = await import("../workflow-guard-source/workflow-guard.ts");
187
- \tconst hooks = await WorkflowGuard(ctx);
188
- \tconst status = await hooks.tool?.guard_status?.execute({}, { sessionID: "headless-e2e", directory: ctx.directory, worktree: ctx.worktree });
189
- \tconst why = await hooks.tool?.guard_why?.execute({ tool: "bash", input: { command: "git push origin main" } }, { sessionID: "headless-e2e", directory: ctx.directory, worktree: ctx.worktree });
208
+ // Load the shipped dual plugin: V1 probes call server(), V2 runs the real
209
+ // setup(ctx) against the real plugin context and probes the REGISTERED tools.
210
+ const loaded = await import("../workflow-guard-source/workflow-guard.ts");
211
+
212
+ const headlessToolContext = { sessionID: "headless-e2e", agent: "build", messageID: "msg_headless", id: "call_headless", progress: async () => {} };
213
+
214
+ const runV2Probes = async (ctx: any) => {
215
+ \tawait loaded.default.setup(ctx);
216
+ \tconst tools = await ctx.tool.list();
217
+ \tconst byId = (id: string) => tools.find((t: any) => t.id === id);
218
+ \tconst todowrite = byId("todowrite");
219
+ \tconst guardStatus = byId("guard_status");
220
+ \tconst guardWhy = byId("guard_why");
221
+ \tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
222
+ \tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
223
+ \tconst whyRes = guardWhy ? await guardWhy.execute({ tool: "bash", input: { command: "git push origin main" } }, headlessToolContext) : undefined;
224
+ \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({
225
+ \t\ttools: tools.map((t: any) => t.id),
226
+ \t\ttodowriteEnriched: Boolean(todowrite?.description?.includes("Workflow Guard lifecycle")),
227
+ \t\tstatus: JSON.parse(pick(statusRes)),
228
+ \t\twhy: JSON.parse(pick(whyRes)),
229
+ \t}) + "\\n");
230
+ \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
231
+ };
232
+
233
+ const runV1Probes = async (ctx: any) => {
234
+ \tconst hooks = await loaded.default.server(ctx);
235
+ \tconst directory = ctx?.location?.directory ?? ctx?.directory ?? process.cwd();
236
+ \tconst toolCtx = { sessionID: "headless-e2e", directory, worktree: directory };
237
+ \tconst status = await hooks.tool?.guard_status?.execute({}, toolCtx);
238
+ \tconst why = await hooks.tool?.guard_why?.execute({ tool: "bash", input: { command: "git push origin main" } }, toolCtx);
190
239
  \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: JSON.parse(String(why)) }) + "\\n");
191
240
  \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
192
241
  \treturn hooks;
193
242
  };
194
243
 
195
- export default { id: "workflow-guard-e2e", server: WorkflowGuardE2E };
244
+ export default {
245
+ \t...Plugin.define({
246
+ \t\tid: "workflow-guard-e2e",
247
+ \t\tasync setup(ctx: any) {
248
+ \t\t\twriteFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
249
+ \t\t\ttry {
250
+ \t\t\t\tawait runV2Probes(ctx);
251
+ \t\t\t} catch (error) {
252
+ \t\t\t\twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ error: String((error as Error)?.message ?? error), stack: String((error as Error)?.stack ?? "") }) + "\\n");
253
+ \t\t\t\tthrow error;
254
+ \t\t\t}
255
+ \t\t},
256
+ \t}),
257
+ \tserver: async (ctx: any) => {
258
+ \t\treturn await runV1Probes(ctx);
259
+ \t},
260
+ };
196
261
  `);
197
262
  writeFileSync(join(testDir, ".opencode", "opencode.json"), `${JSON.stringify({ plugin: ["./plugins/workflow-guard.ts"] }, null, 2)}\n`);
198
263
  check("local plugin adapter and source copied successfully", existsSync(localAdapter) && existsSync(targetPlugin));
@@ -211,6 +276,13 @@ const runtimeEnv: NodeJS.ProcessEnv = {
211
276
  delete runtimeEnv.OPENCODE_PID;
212
277
  delete runtimeEnv.OPENCODE_PURE;
213
278
  delete runtimeEnv.OPENCODE;
279
+ if (isOpenCodeV2) {
280
+ // V2 runs a shared managed service on a fixed default port. Each run uses
281
+ // its own isolated XDG dirs, so pick a run-unique port to keep concurrent
282
+ // and sequential runs from colliding on service startup.
283
+ const servicePort = 49400 + (process.pid % 1000);
284
+ spawnSync("opencode", ["service", "set", "port", String(servicePort)], { cwd: testDir, encoding: "utf8", env: runtimeEnv, timeout: 30_000 });
285
+ }
214
286
  const runOpenCode = (args: string[], timeout: number) =>
215
287
  spawnSync("opencode", ["run", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
216
288
  const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
@@ -229,7 +301,11 @@ const configProbe = await new Promise<{ status: number | null; stdout: string; s
229
301
 
230
302
  const checkReady = () => {
231
303
  try {
232
- if (JSON.parse(stdout)?.plugin_origins && existsSync(initializedMarker)) {
304
+ const parsed = JSON.parse(stdout);
305
+ // V1 prints an object with plugin_origins; V2 prints a config document
306
+ // array. Either way, the adapter's setup/server must have run.
307
+ const hasConfig = Boolean(parsed?.plugin_origins) || Array.isArray(parsed);
308
+ if (hasConfig && existsSync(initializedMarker)) {
233
309
  done(0);
234
310
  }
235
311
  } catch {}
@@ -247,26 +323,52 @@ const configProbe = await new Promise<{ status: number | null; stdout: string; s
247
323
 
248
324
  timer = setTimeout(() => done(null), 30_000);
249
325
  });
326
+ if (isOpenCodeV2) {
327
+ // V2's debug config can exit before plugin setup completes; wait briefly
328
+ // for the adapter's initialized marker before evaluating plugin checks.
329
+ for (let i = 0; i < 200 && !existsSync(initializedMarker); i++) {
330
+ await new Promise((resolve) => setTimeout(resolve, 50));
331
+ }
332
+ }
250
333
  let configLoadsLocalPlugin = false;
251
334
  try {
252
- const config = JSON.parse(configProbe.stdout);
253
- configLoadsLocalPlugin = config.plugin_origins?.some(
254
- (origin: { spec?: unknown; source?: unknown; scope?: unknown }) =>
255
- typeof origin.spec === "string" &&
256
- origin.spec.endsWith("/.opencode/plugins/workflow-guard.ts") &&
257
- origin.scope === "local",
258
- );
335
+ if (isOpenCodeV2) {
336
+ // V2 has no plugin_origins in debug config output; the adapter's setup
337
+ // writing the imported marker is the plugin-load proof.
338
+ configLoadsLocalPlugin = existsSync(importedMarker) && existsSync(initializedMarker);
339
+ } else {
340
+ const config = JSON.parse(configProbe.stdout);
341
+ configLoadsLocalPlugin = config.plugin_origins?.some(
342
+ (origin: { spec?: unknown; source?: unknown; scope?: unknown }) =>
343
+ typeof origin.spec === "string" &&
344
+ origin.spec.endsWith("/.opencode/plugins/workflow-guard.ts") &&
345
+ origin.scope === "local",
346
+ );
347
+ }
259
348
  } catch {}
260
- check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin);
349
+ const configCheckPassed = Boolean(check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin));
350
+ if (!configCheckPassed) {
351
+ console.log(` configProbe stderr: ${configProbe.stderr.slice(0, 2000)}`);
352
+ console.log(` configProbe stdout: ${configProbe.stdout.slice(0, 2000)}`);
353
+ console.log(` importedMarker: ${existsSync(importedMarker)} initializedMarker: ${existsSync(initializedMarker)}`);
354
+ }
261
355
  let headlessAccountability: any;
262
356
  try {
263
357
  headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
264
358
  } catch {}
265
359
  check("headless OpenCode runtime exposes structured guard status and why without TUI", headlessAccountability?.status?.workspaceRoot === testDir && headlessAccountability?.why?.policy === "git" && headlessAccountability?.why?.code === "protected_branch_push");
360
+ if (isOpenCodeV2) {
361
+ // The V2 adapter runs the shipped setup(ctx) for real; verify the
362
+ // registered tool surface. Builtin description enrichment is best-effort:
363
+ // builtins are not visible to ctx.tool.list() during plugin setup, so
364
+ // todowriteEnriched is reported but not asserted.
365
+ check("V2 setup registers custom guard tools", (headlessAccountability?.tools ?? []).includes("guard_status") && (headlessAccountability?.tools ?? []).includes("guard_why"));
366
+ console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
367
+ }
266
368
 
267
369
  if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
268
370
  console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
269
- rmSync(testDir, { recursive: true, force: true });
371
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
270
372
  console.log(`\n${pass} passed, ${fail} failed`);
271
373
  process.exit(fail > 0 ? 1 : 0);
272
374
  }
@@ -286,7 +388,7 @@ try {
286
388
  const liveModel = process.env.WORKFLOW_GUARD_LIVE_MODEL?.trim() || recentModel;
287
389
  if (!liveModel) {
288
390
  console.error(`FAIL: no recent OpenCode model found in ${recentModelPath}; select a model in OpenCode or set WORKFLOW_GUARD_LIVE_MODEL=provider/model.`);
289
- rmSync(testDir, { recursive: true, force: true });
391
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
290
392
  process.exit(1);
291
393
  }
292
394
  console.log(` Using live model: ${liveModel}`);
@@ -337,7 +439,7 @@ if (!run3AccountedFor) {
337
439
  }
338
440
 
339
441
  // Clean up
340
- rmSync(testDir, { recursive: true, force: true });
442
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
341
443
 
342
444
  console.log(`\n${pass} passed, ${fail} failed, ${unavailable} live unavailable`);
343
445
  process.exit(fail ? 1 : 0);
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 blocked", blocked(await shell("git tag release-test")));
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")));
@@ -514,6 +537,10 @@ check("inspection command with mutation keywords in grep argument is not blocked
514
537
  check("git log with mutation keywords in grep argument is not blocked", !(await call("bash", { command: 'git log --grep="rm old files"' }, { sessionID: "s-empty" })));
515
538
  check("grep searching for mutation command name is not blocked", !(await call("bash", { command: 'grep -rn "mkdir" src/' }, { sessionID: "s-empty" })));
516
539
  check("stderr redirect to /dev/null is not a file mutation", !(await call("bash", { command: "ls missing 2>/dev/null" }, { sessionID: "s-empty" })));
540
+ check("SQL > comparison in query is not a file mutation", !(await call("bash", { command: 'sqlite3 app.db "SELECT id FROM events WHERE count > 5"' }, { sessionID: "s-empty" })));
541
+ check("SQL >= comparison in query is not a file mutation", !(await call("bash", { command: 'psql -c "SELECT 1 FROM metrics WHERE n >= 10"' }, { sessionID: "s-empty" })));
542
+ check("numeric comparison in grep pattern is not a file mutation", !(await call("bash", { command: "grep -E 'latency > 200' src/log.ts" }, { sessionID: "s-empty" })));
543
+ check("real redirect alongside numeric comparison still blocked", blocked(await call("bash", { command: 'echo "x > 5" > src/a.ts' }, { sessionID: "s-empty" })));
517
544
  check("touch outside workspace is blocked", blocked(await call("bash", { command: "touch /tmp/wg-outside-touch" }, { sessionID: "s-active" })));
518
545
  check("mkdir outside workspace is blocked", blocked(await call("bash", { command: "mkdir /tmp/wg-outside-dir" }, { sessionID: "s-active" })));
519
546
  check("single-file rm outside workspace is blocked", blocked(await call("bash", { command: "rm /tmp/wg-outside-file" }, { sessionID: "s-active" })));
@@ -1357,6 +1384,24 @@ rmSync(pipelineTimeoutMarker, { force: true });
1357
1384
  await runVerify(`node -e "setTimeout(() => require('fs').writeFileSync('${pipelineTimeoutMarker}', 'orphan'), 300)" | cat`, root, 50);
1358
1385
  await new Promise((resolve) => setTimeout(resolve, 450));
1359
1386
  check("runVerify timeout terminates descendants behind shell pipelines", !existsSync(pipelineTimeoutMarker));
1387
+
1388
+ console.log("- Policy 10: verification timeout resolution -");
1389
+ const previousVerifyTimeoutEnv = process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
1390
+ if (previousVerifyTimeoutEnv !== undefined) delete process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
1391
+ check("verification timeout resolution defaults to 30s", resolveVerifyTimeoutMs(root) === 30_000);
1392
+ const verifyTimeoutConfigRoot = mkdtempSync(join(tmpdir(), "wg-verify-timeout-"));
1393
+ mkdirSync(join(verifyTimeoutConfigRoot, ".opencode"));
1394
+ writeFileSync(join(verifyTimeoutConfigRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ verifyTimeoutMs: 60000 }));
1395
+ reloadProjectConfig(verifyTimeoutConfigRoot);
1396
+ check("verification timeout honors project config", resolveVerifyTimeoutMs(verifyTimeoutConfigRoot) === 60_000);
1397
+ writeFileSync(join(verifyTimeoutConfigRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ verifyTimeoutMs: -5 }));
1398
+ reloadProjectConfig(verifyTimeoutConfigRoot);
1399
+ check("verification timeout ignores invalid project config values", resolveVerifyTimeoutMs(verifyTimeoutConfigRoot) === 30_000);
1400
+ process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS = "120000";
1401
+ check("verification timeout env overrides a valid project config", resolveVerifyTimeoutMs(verifyTimeoutConfigRoot) === 120_000);
1402
+ delete process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
1403
+ if (previousVerifyTimeoutEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS = previousVerifyTimeoutEnv;
1404
+ rmSync(verifyTimeoutConfigRoot, { recursive: true, force: true });
1360
1405
  let windowsCleanupFinished = false;
1361
1406
  const windowsCleanup = terminateProcessTree(1234, () => {}, "win32", async (pid) => {
1362
1407
  check("Windows timeout cleanup receives the child pid", pid === 1234);
@@ -2271,6 +2316,12 @@ const mergedCheck = isBranchAlreadyMergedOrClosed(conflictRepo, "feat/already-me
2271
2316
  check("isBranchAlreadyMergedOrClosed identifies branch with 0 diff from main", mergedCheck.merged);
2272
2317
  check("git push on already merged branch is blocked", blocked(await shell("git push origin feat/already-merged")));
2273
2318
 
2319
+ // Tag pushes are release operations, not branch mutations (#134).
2320
+ spawnSync("git", ["tag", "v0.3.0"], { cwd: conflictRepo });
2321
+ check("git push origin v0.3.0 allowed from merged branch", !blocked(await shell("git push origin v0.3.0")));
2322
+ check("git push origin refs/tags/v0.3.0 allowed", !blocked(await shell("git push origin refs/tags/v0.3.0")));
2323
+ check("git push origin :refs/tags/v0.3.0 still blocked", blocked(await shell("git push origin :refs/tags/v0.3.0")));
2324
+
2274
2325
  // Create a branch with a genuine merge conflict against main
2275
2326
  spawnSync("git", ["switch", "main"], { cwd: conflictRepo });
2276
2327
  spawnSync("git", ["switch", "-c", "feat/conflict-branch"], { cwd: conflictRepo });
@@ -3080,6 +3131,20 @@ await continuationPlugin.event?.({ event: { type: "message.updated", properties:
3080
3131
  await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-cap" } } } as any);
3081
3132
  check("genuine user message resets continuation budget", continuationPrompts.filter((id) => id === "s-resume-cap").length === 4);
3082
3133
 
3134
+ console.log("- Policy 1: user interrupt stops automatic continuation -");
3135
+ todo("s-resume-abort", item("work the user stopped", "pending"));
3136
+ await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-abort" } } } as any);
3137
+ await continuationPlugin.event?.({ event: { type: "session.error", properties: { sessionID: "s-resume-abort", error: { name: "MessageAbortedError", data: { message: "aborted by user" } } } } } as any);
3138
+ await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-abort" } } } as any);
3139
+ check("user interrupt (Esc / MessageAbortedError) stops automatic continuation", continuationPrompts.filter((id) => id === "s-resume-abort").length === 1);
3140
+ await continuationPlugin.event?.({ event: { type: "message.updated", properties: { info: { id: "genuine-restart", role: "user", sessionID: "s-resume-abort" } } } } as any);
3141
+ await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-abort" } } } as any);
3142
+ check("genuine user input after an interrupt re-enables automatic continuation", continuationPrompts.filter((id) => id === "s-resume-abort").length === 2);
3143
+ todo("s-resume-api-error", item("work behind a flaky provider", "pending"));
3144
+ await continuationPlugin.event?.({ event: { type: "session.error", properties: { sessionID: "s-resume-api-error", error: { name: "APIError", data: { message: "provider overloaded" } } } } } as any);
3145
+ await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-api-error" } } } as any);
3146
+ check("non-abort session errors do not disable automatic continuation", continuationPrompts.filter((id) => id === "s-resume-api-error").length === 1);
3147
+
3083
3148
  const ralphRoot = mkdtempSync(join(tmpdir(), "wg-ralph-"));
3084
3149
  mkdirSync(join(ralphRoot, ".opencode"));
3085
3150
  writeFileSync(join(ralphRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ ralphMode: true, ralphMaxIterations: 2 }));
@@ -3118,6 +3183,11 @@ check("duplicate genuine user events leave an active ralph run stopped", ralphPr
3118
3183
  await ralphPlugin.event?.({ event: { type: "message.updated", properties: { info: { id: "human-restart", role: "user", sessionID: "s-ralph-stop" } } } } as any);
3119
3184
  await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-stop" } } } as any);
3120
3185
  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");
3186
+ todo("s-ralph-abort", item("ralph work the user stopped", "pending"));
3187
+ await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-abort" } } } as any);
3188
+ await ralphPlugin.event?.({ event: { type: "session.error", properties: { sessionID: "s-ralph-abort", error: { name: "MessageAbortedError", data: { message: "aborted by user" } } } } } as any);
3189
+ await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-abort" } } } as any);
3190
+ 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
3191
  todo("s-ralph-complete", item("completable work", "pending"));
3122
3192
  await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-complete" } } } as any);
3123
3193
  todo("s-ralph-complete", item("completable work", "completed"));