opencode-workflow-guard 1.12.0 → 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 +7 -3
- package/package.json +4 -2
- package/src/lib/audit.ts +33 -1
- package/src/lib/custom-tools.ts +39 -15
- package/src/lib/evidence.ts +6 -1
- package/src/lib/guard-dispatcher.ts +15 -5
- package/src/lib/project-config.ts +52 -2
- package/src/lib/state.ts +94 -6
- 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 +21 -2
- 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 +60 -8
- package/test/test-e2e.mts +129 -27
- package/test/test.mts +166 -1
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):
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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 (
|
|
162
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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,
|
|
@@ -36,6 +37,14 @@ import {
|
|
|
36
37
|
getRecentAuditEntries,
|
|
37
38
|
getRecentVerifyHistory,
|
|
38
39
|
getVerifyHistoryFilePath,
|
|
40
|
+
getReviewCacheFilePath,
|
|
41
|
+
loadReviewCache,
|
|
42
|
+
persistReviewCache,
|
|
43
|
+
setSessionWorkspace,
|
|
44
|
+
getSessionWorkspace,
|
|
45
|
+
resolveEffectiveWorkspace,
|
|
46
|
+
getLastReviewResultForWorkspace,
|
|
47
|
+
isSameGitRepo,
|
|
39
48
|
managedConfigDiagnostic,
|
|
40
49
|
summarizeInput,
|
|
41
50
|
extractReviewFollowups,
|
|
@@ -434,6 +443,11 @@ check("block auth with global flags (--dir)", blocked(await shell(`${ocCli} --di
|
|
|
434
443
|
check("block config with global flags (-d)", blocked(await shell(`${ocCli} -d . ${"con" + "fig"} edit`)));
|
|
435
444
|
check("block permission with global flags (--workspace)", blocked(await shell(`${ocCli} --workspace . ${"perm" + "ission"} grant`)));
|
|
436
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`)));
|
|
437
451
|
|
|
438
452
|
console.log("- Policy 6: tamper via edit tools (path protection) -");
|
|
439
453
|
check("block edit of project opencode.json", blocked(await call("edit", { filePath: join(root, "opencode.json"), oldString: "a", newString: "b" }, { sessionID: "s-active" })));
|
|
@@ -441,6 +455,22 @@ check("block write of .opencode/project config", blocked(await call("write", { f
|
|
|
441
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" })));
|
|
442
456
|
check("block apply_patch to opencode.json", blocked(await call("apply_patch", { patchText: "*** Update File: opencode.json\n" }, { sessionID: "s-active" })));
|
|
443
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 });
|
|
444
474
|
console.log("- Policy 7: branch guard -");
|
|
445
475
|
// Non-git workspace (current `root` is a plain temp dir): git writes allowed.
|
|
446
476
|
check("non-git workspace: git commit allowed", !(await shell("git commit -m test")));
|
|
@@ -456,7 +486,8 @@ check("on main: git merge blocked", blocked(await shell("git merge feature/x")))
|
|
|
456
486
|
check("on main: git checkout path mutation without separator blocked", blocked(await shell("git checkout tracked.txt")));
|
|
457
487
|
check("on main: git checkout -B reset blocked", blocked(await shell("git checkout -B main HEAD~1")));
|
|
458
488
|
check("on main: git add blocked", blocked(await shell("git add tracked.txt")));
|
|
459
|
-
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")));
|
|
460
491
|
check("on main: git tag --list allowed", !blocked(await shell("git tag --list")));
|
|
461
492
|
check("on main: git switch -c allowed (branch creation)", !(await shell("git switch -c feat/x")));
|
|
462
493
|
check("on main: git status allowed", !(await shell("git status")));
|
|
@@ -1349,6 +1380,24 @@ rmSync(pipelineTimeoutMarker, { force: true });
|
|
|
1349
1380
|
await runVerify(`node -e "setTimeout(() => require('fs').writeFileSync('${pipelineTimeoutMarker}', 'orphan'), 300)" | cat`, root, 50);
|
|
1350
1381
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
|
1351
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 });
|
|
1352
1401
|
let windowsCleanupFinished = false;
|
|
1353
1402
|
const windowsCleanup = terminateProcessTree(1234, () => {}, "win32", async (pid) => {
|
|
1354
1403
|
check("Windows timeout cleanup receives the child pid", pid === 1234);
|
|
@@ -2100,6 +2149,96 @@ const mainReviewToolResult = await customPlugin.tool?.record_review?.execute(
|
|
|
2100
2149
|
);
|
|
2101
2150
|
check("record_review rejects self-approval from main session", typeof mainReviewToolResult === "string" && mainReviewToolResult.includes("rejected"));
|
|
2102
2151
|
|
|
2152
|
+
// Secondary Review Approval Handoff & Durable Cache Tests
|
|
2153
|
+
console.log("- Secondary Review Approval Handoff & Durability -");
|
|
2154
|
+
|
|
2155
|
+
// 1. Negative review statements do not create bogus technical debt items
|
|
2156
|
+
const negativeFollowups = extractReviewFollowups(
|
|
2157
|
+
"Test integrity: ok. Task completeness: ok. Cleanliness: ok. Security: ok. Platform: ok.\n" +
|
|
2158
|
+
"P2: None\n" +
|
|
2159
|
+
"P3: 0\n" +
|
|
2160
|
+
"P2: N/A\n" +
|
|
2161
|
+
"P3: No issues identified\n" +
|
|
2162
|
+
"P2: 0 defects\n" +
|
|
2163
|
+
"P3: ok\n" +
|
|
2164
|
+
"P2: clean\n" +
|
|
2165
|
+
"P2: memory leak in worker pool"
|
|
2166
|
+
);
|
|
2167
|
+
check("extractReviewFollowups filters out negative statements and retains real issues", negativeFollowups.length === 1 && negativeFollowups[0]?.summary.includes("memory leak in worker pool"));
|
|
2168
|
+
|
|
2169
|
+
// 2. Secondary review approval handoff from subagent to parent session
|
|
2170
|
+
const handoffRepo = join(root, "wg-handoff-repo");
|
|
2171
|
+
mkdirSync(handoffRepo, { recursive: true });
|
|
2172
|
+
spawnSync("git", ["init", "-b", "feature/handoff"], { cwd: handoffRepo });
|
|
2173
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: handoffRepo });
|
|
2174
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: handoffRepo });
|
|
2175
|
+
writeFileSync(join(handoffRepo, "code.txt"), "initial\n");
|
|
2176
|
+
spawnSync("git", ["add", "code.txt"], { cwd: handoffRepo });
|
|
2177
|
+
spawnSync("git", ["commit", "-m", "initial"], { cwd: handoffRepo });
|
|
2178
|
+
mkdirSync(join(handoffRepo, ".opencode"), { recursive: true });
|
|
2179
|
+
writeFileSync(join(handoffRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
|
|
2180
|
+
|
|
2181
|
+
// Simulate host root being the filesystem root ("/") while session operates in handoffRepo
|
|
2182
|
+
const handoffPlugin = await WorkflowGuard({
|
|
2183
|
+
directory: root,
|
|
2184
|
+
worktree: "/",
|
|
2185
|
+
client: fakeClient as any,
|
|
2186
|
+
project: {} as any,
|
|
2187
|
+
experimental_workspace: {} as any,
|
|
2188
|
+
serverUrl: new URL("http://localhost:4096"),
|
|
2189
|
+
$: undefined as any,
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
// Parent session sets active workspace in handoffRepo
|
|
2193
|
+
setSessionWorkspace("s-handoff-parent", handoffRepo);
|
|
2194
|
+
fakeParents.set("s-handoff-reviewer", "s-handoff-parent");
|
|
2195
|
+
|
|
2196
|
+
// Before review: guard_status on parent reports review is unsatisfied
|
|
2197
|
+
const statusBeforeReview = JSON.parse(String(await handoffPlugin.tool?.guard_status?.execute({}, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
|
|
2198
|
+
check("before review: guard_status reports review in outstandingRequirements", statusBeforeReview.outstandingRequirements.includes("review") && (statusBeforeReview.lastReview === null || statusBeforeReview.lastReview.fresh === false));
|
|
2199
|
+
|
|
2200
|
+
// Before review: PR creation is blocked by missing review
|
|
2201
|
+
const prBeforeReview = JSON.parse(String(await handoffPlugin.tool?.guard_why?.execute({ tool: "bash", input: { command: "gh pr create --title test --body 'Summary\n\nRelease notes'", workdir: handoffRepo } }, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
|
|
2202
|
+
check("before review: PR creation blocked by review requirement", prBeforeReview.status === "blocked" && prBeforeReview.message.includes("Passing secondary review approval is required"));
|
|
2203
|
+
|
|
2204
|
+
// Subagent calls record_review with passed: true (without explicit directory argument)
|
|
2205
|
+
const subagentReviewResult = await handoffPlugin.tool?.record_review?.execute(
|
|
2206
|
+
{
|
|
2207
|
+
reviewer: "secondary-reviewer",
|
|
2208
|
+
summary: "Test integrity: all tests pass. Task completeness: complete. Cleanliness: clean diff. Security: zero secrets. Platform: standard.",
|
|
2209
|
+
passed: true,
|
|
2210
|
+
},
|
|
2211
|
+
{ sessionID: "s-handoff-reviewer", agent: "reviewer", worktree: "/", directory: root } as any,
|
|
2212
|
+
);
|
|
2213
|
+
check("subagent records review approval successfully", typeof subagentReviewResult === "string" && subagentReviewResult.includes("APPROVED"));
|
|
2214
|
+
|
|
2215
|
+
// Parent checks guard_status: handoff succeeds!
|
|
2216
|
+
const statusAfterReview = JSON.parse(String(await handoffPlugin.tool?.guard_status?.execute({}, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
|
|
2217
|
+
check("secondary review handoff: lastReview.fresh is true in parent session", statusAfterReview.lastReview?.fresh === true && statusAfterReview.lastReview?.passed === true);
|
|
2218
|
+
check("secondary review handoff: review removed from outstandingRequirements", !statusAfterReview.outstandingRequirements.includes("review"));
|
|
2219
|
+
|
|
2220
|
+
// Parent checks PR creation via guard_why: handoff allows PR creation!
|
|
2221
|
+
const prAfterReview = JSON.parse(String(await handoffPlugin.tool?.guard_why?.execute({ tool: "bash", input: { command: "gh pr create --title test --body 'Summary\n\nRelease notes'", workdir: handoffRepo } }, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
|
|
2222
|
+
check("secondary review handoff: PR creation allowed after review approval", prAfterReview.status !== "blocked" && (prAfterReview.status === "allowed" || prAfterReview.code === "remote_state_unchecked"));
|
|
2223
|
+
|
|
2224
|
+
// 3. Durable disk cache persistence & recovery
|
|
2225
|
+
check("durable review cache file created on disk", existsSync(getReviewCacheFilePath()));
|
|
2226
|
+
const loadedDiskReview = loadReviewCache();
|
|
2227
|
+
check("loadReviewCache returns valid passing review", loadedDiskReview?.passed === true && loadedDiskReview?.reviewer === "secondary-reviewer");
|
|
2228
|
+
|
|
2229
|
+
// Simulate new session / restart: clear in-memory sessionReviews and lastReview
|
|
2230
|
+
const memoryClearedStatus = JSON.parse(String(await handoffPlugin.tool?.guard_status?.execute({ directory: handoffRepo }, { sessionID: "s-fresh-session", worktree: "/", directory: root } as any)));
|
|
2231
|
+
check("fresh session recovers durable review cache from disk", memoryClearedStatus.lastReview?.fresh === true && !memoryClearedStatus.outstandingRequirements.includes("review"));
|
|
2232
|
+
|
|
2233
|
+
// 4. Worktree compatibility: review recorded in linked worktree is recognized by main repo
|
|
2234
|
+
todo("s-handoff-parent", item("worktree test", "in_progress"));
|
|
2235
|
+
const handoffWorktreeRes = (await handoffPlugin.tool?.guard_worktree_create?.execute({ branch: "feat/handoff-worktree" }, { sessionID: "s-handoff-parent", worktree: handoffRepo, directory: handoffRepo } as any)) as string;
|
|
2236
|
+
check("worktree created for handoff test", typeof handoffWorktreeRes === "string" && handoffWorktreeRes.includes("Worktree created"));
|
|
2237
|
+
check("worktree and main repo recognized as same git repository", isSameGitRepo(join(getWorktreeStorageDir(handoffRepo), "feat-handoff-worktree"), handoffRepo));
|
|
2238
|
+
|
|
2239
|
+
rmSync(handoffRepo, { recursive: true, force: true });
|
|
2240
|
+
resetReviewState();
|
|
2241
|
+
|
|
2103
2242
|
// Event hook handles permission events
|
|
2104
2243
|
if (typeof customPlugin.event === "function") {
|
|
2105
2244
|
await customPlugin.event({
|
|
@@ -2173,6 +2312,12 @@ const mergedCheck = isBranchAlreadyMergedOrClosed(conflictRepo, "feat/already-me
|
|
|
2173
2312
|
check("isBranchAlreadyMergedOrClosed identifies branch with 0 diff from main", mergedCheck.merged);
|
|
2174
2313
|
check("git push on already merged branch is blocked", blocked(await shell("git push origin feat/already-merged")));
|
|
2175
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
|
+
|
|
2176
2321
|
// Create a branch with a genuine merge conflict against main
|
|
2177
2322
|
spawnSync("git", ["switch", "main"], { cwd: conflictRepo });
|
|
2178
2323
|
spawnSync("git", ["switch", "-c", "feat/conflict-branch"], { cwd: conflictRepo });
|
|
@@ -2783,6 +2928,7 @@ writeFileSync(join(checkpointHookDir, "tracked.txt"), "agent result\n");
|
|
|
2783
2928
|
await checkpointHooks.event?.({ event: { type: "session.idle", properties: { sessionID: "checkpoint-root" } } } as any);
|
|
2784
2929
|
const generatedCheckpointMessage = checkpointHookPrompts.find((entry) => entry.sessionID === "checkpoint-root")?.messageID;
|
|
2785
2930
|
check("idle finalizes recovery checkpoint before continuation", Boolean(listRecoveryCheckpoints(checkpointHookDir, "checkpoint-root")[0]?.endFingerprint && generatedCheckpointMessage));
|
|
2931
|
+
check("synthetic continuation messageID conforms to OpenCode msg_ prefix schema", typeof generatedCheckpointMessage === "string" && generatedCheckpointMessage.startsWith("msg_"));
|
|
2786
2932
|
await checkpointHooks["chat.message"]?.({ sessionID: "checkpoint-root", messageID: generatedCheckpointMessage } as any, {} as any);
|
|
2787
2933
|
check("synthetic continuation does not replace recovery checkpoint", listRecoveryCheckpoints(checkpointHookDir, "checkpoint-root").length === 1);
|
|
2788
2934
|
checkpointHookParents.set("checkpoint-child", "checkpoint-root");
|
|
@@ -2981,6 +3127,20 @@ await continuationPlugin.event?.({ event: { type: "message.updated", properties:
|
|
|
2981
3127
|
await continuationPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-resume-cap" } } } as any);
|
|
2982
3128
|
check("genuine user message resets continuation budget", continuationPrompts.filter((id) => id === "s-resume-cap").length === 4);
|
|
2983
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
|
+
|
|
2984
3144
|
const ralphRoot = mkdtempSync(join(tmpdir(), "wg-ralph-"));
|
|
2985
3145
|
mkdirSync(join(ralphRoot, ".opencode"));
|
|
2986
3146
|
writeFileSync(join(ralphRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ ralphMode: true, ralphMaxIterations: 2 }));
|
|
@@ -3019,6 +3179,11 @@ check("duplicate genuine user events leave an active ralph run stopped", ralphPr
|
|
|
3019
3179
|
await ralphPlugin.event?.({ event: { type: "message.updated", properties: { info: { id: "human-restart", role: "user", sessionID: "s-ralph-stop" } } } } as any);
|
|
3020
3180
|
await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-stop" } } } as any);
|
|
3021
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");
|
|
3022
3187
|
todo("s-ralph-complete", item("completable work", "pending"));
|
|
3023
3188
|
await ralphPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: "s-ralph-complete" } } } as any);
|
|
3024
3189
|
todo("s-ralph-complete", item("completable work", "completed"));
|