opencode-workflow-guard 1.8.7 → 1.9.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.
@@ -0,0 +1,288 @@
1
+ import { mkdtempSync, rmSync, existsSync, readFileSync, copyFileSync, cpSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { parse as parseJsonc } from "jsonc-parser";
6
+
7
+ let pass = 0;
8
+ let fail = 0;
9
+ let unavailable = 0;
10
+ const check = (name: string, cond: unknown): void => {
11
+ cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
12
+ };
13
+ const providerUnavailable = (output: string): boolean =>
14
+ /available credits|insufficient credits|rate limit|too many requests|capacity|overloaded/i.test(output);
15
+ const checkLive = (name: string, cond: unknown, output: string): boolean => {
16
+ if (!cond && providerUnavailable(output)) {
17
+ unavailable++;
18
+ console.log("SKIP: " + name + " (model provider unavailable before guard behavior could be exercised)");
19
+ return true;
20
+ }
21
+ check(name, cond);
22
+ return Boolean(cond);
23
+ };
24
+
25
+ console.log("- OpenCode Plugin Installation & Runtime Load Test -");
26
+
27
+ // 1. Set up a fresh isolated project with plugin installed. These package
28
+ // checks run even when the optional live OpenCode binary is unavailable.
29
+ const testDir = mkdtempSync(join(tmpdir(), "wg-install-test-"));
30
+
31
+ // Verify the publish artifact resolves its modular entrypoint, not only the
32
+ // checkout/local-copy layout used by the live plugin test below.
33
+ const packResult = spawnSync("npm", ["pack", "--pack-destination", testDir], {
34
+ cwd: join(import.meta.dirname, ".."),
35
+ encoding: "utf8",
36
+ });
37
+ const tarballName = packResult.stdout.trim().split("\n").at(-1) ?? "";
38
+ const tarballPath = join(testDir, tarballName);
39
+ const installResult = spawnSync("npm", ["install", "--ignore-scripts", tarballPath], {
40
+ cwd: testDir,
41
+ encoding: "utf8",
42
+ });
43
+ const packageEntry = join(testDir, "node_modules", "opencode-workflow-guard", "src", "workflow-guard.ts");
44
+ const installedPackageDir = join(testDir, "node_modules", "opencode-workflow-guard");
45
+ const installedPackageJson = JSON.parse(readFileSync(join(testDir, "node_modules", "opencode-workflow-guard", "package.json"), "utf8"));
46
+ check(
47
+ "npm package keeps direct dependencies on latest",
48
+ Object.keys(installedPackageJson.dependencies ?? {}).length > 0 &&
49
+ Object.values(installedPackageJson.dependencies).every((spec) => spec === "latest") &&
50
+ Object.keys(installedPackageJson.devDependencies ?? {}).length > 0 &&
51
+ Object.values(installedPackageJson.devDependencies).every((spec) => spec === "latest"),
52
+ );
53
+ check(
54
+ "npm tarball installs modular plugin entrypoint",
55
+ packResult.status === 0 && installResult.status === 0 && existsSync(packageEntry),
56
+ );
57
+ check("npm package exposes OpenCode server entrypoint", installedPackageJson.exports?.["./server"] === "./src/workflow-guard.ts");
58
+ check("npm package exposes OpenCode TUI entrypoint", installedPackageJson.exports?.["./tui"] === "./src/workflow-guard-ui.ts");
59
+ check("npm package does not expose ambiguous /ui entrypoint", installedPackageJson.exports?.["./ui"] === undefined);
60
+ check("npm package exposes setup CLI", installedPackageJson.bin?.["opencode-workflow-guard"] === "./bin/opencode-workflow-guard.mjs");
61
+ check(
62
+ "npm package includes documented development and test files",
63
+ existsSync(join(installedPackageDir, "test", "run-test.mjs")) &&
64
+ existsSync(join(installedPackageDir, "test", "test-e2e.mts")) &&
65
+ existsSync(join(installedPackageDir, "tsconfig.json")) &&
66
+ existsSync(join(installedPackageDir, "docs", "testing.md")),
67
+ );
68
+
69
+ const setupHome = join(testDir, "setup-home");
70
+ const setupConfigDir = join(setupHome, ".config", "opencode");
71
+ mkdirSync(setupConfigDir, { recursive: true });
72
+ writeFileSync(join(setupConfigDir, "opencode.jsonc"), `{
73
+ // Keep existing user settings intact.
74
+ "model": "test/provider",
75
+ "plugin": [
76
+ "existing-plugin", // Keep this plugin comment.
77
+ ],
78
+ }\n`);
79
+ writeFileSync(join(setupConfigDir, "tui.json"), `{ "plugin": ["opencode-workflow-guard/tui", ["opencode-workflow-guard/tui@1.5.0", { "legacy": true }]] }\n`);
80
+ const setupEnv = { ...process.env, HOME: setupHome, XDG_CONFIG_HOME: join(setupHome, ".config") };
81
+ const setupCli = join(testDir, "node_modules", ".bin", process.platform === "win32" ? "opencode-workflow-guard.cmd" : "opencode-workflow-guard");
82
+ const firstSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: setupEnv });
83
+ const secondSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: setupEnv });
84
+ const setupServerSource = readFileSync(join(setupConfigDir, "opencode.jsonc"), "utf8");
85
+ const setupServer = parseJsonc(setupServerSource);
86
+ const setupTui = JSON.parse(readFileSync(join(setupConfigDir, "tui.json"), "utf8"));
87
+ check("setup CLI succeeds and is idempotent", firstSetup.status === 0 && secondSetup.status === 0);
88
+ check("setup CLI registers cache-safe versioned server plugin once", setupServer.plugin?.filter((entry: unknown) => entry === `opencode-workflow-guard@${installedPackageJson.version}`).length === 1);
89
+ check("setup CLI registers cache-safe versioned TUI plugin once", setupTui.plugin?.filter((entry: unknown) => entry === `opencode-workflow-guard@${installedPackageJson.version}`).length === 1);
90
+ check("setup CLI replaces legacy TUI subpath specs", !setupTui.plugin?.some((entry: unknown) => (typeof entry === "string" && entry.startsWith("opencode-workflow-guard/tui")) || (Array.isArray(entry) && typeof entry[0] === "string" && entry[0].startsWith("opencode-workflow-guard/tui"))));
91
+ check("setup CLI preserves existing JSONC settings and comments", setupServer.model === "test/provider" && setupServer.plugin?.includes("existing-plugin") && setupServerSource.includes("Keep existing user settings intact.") && setupServerSource.includes("Keep this plugin comment."));
92
+
93
+ const pinnedHome = join(testDir, "pinned-setup-home");
94
+ const pinnedConfigDir = join(pinnedHome, ".config", "opencode");
95
+ mkdirSync(pinnedConfigDir, { recursive: true });
96
+ writeFileSync(join(pinnedConfigDir, "opencode.json"), `{ "plugin": ["opencode-workflow-guard@1.7.2"] }\n`);
97
+ writeFileSync(join(pinnedConfigDir, "tui.json"), `{ "plugin": [["opencode-workflow-guard@1.7.2", { "option": true }]] }\n`);
98
+ const pinnedSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: { ...process.env, HOME: pinnedHome, XDG_CONFIG_HOME: join(pinnedHome, ".config") } });
99
+ const pinnedServer = JSON.parse(readFileSync(join(pinnedConfigDir, "opencode.json"), "utf8"));
100
+ const pinnedTui = JSON.parse(readFileSync(join(pinnedConfigDir, "tui.json"), "utf8"));
101
+ check("setup CLI recognizes documented version-pinned plugin specs", pinnedSetup.status === 0 && pinnedServer.plugin?.length === 1 && pinnedServer.plugin[0] === "opencode-workflow-guard@1.7.2" && pinnedTui.plugin?.length === 1 && pinnedTui.plugin[0]?.[0] === "opencode-workflow-guard@1.7.2");
102
+
103
+ const invalidHome = join(testDir, "invalid-setup-home");
104
+ const invalidConfigDir = join(invalidHome, ".config", "opencode");
105
+ mkdirSync(invalidConfigDir, { recursive: true });
106
+ const invalidServerPath = join(invalidConfigDir, "opencode.json");
107
+ writeFileSync(invalidServerPath, `{ "plugin": ["existing-plugin"] }\n`);
108
+ writeFileSync(join(invalidConfigDir, "tui.jsonc"), `{ "plugin": [\n`);
109
+ const invalidServerBefore = readFileSync(invalidServerPath, "utf8");
110
+ const invalidSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: { ...process.env, HOME: invalidHome, XDG_CONFIG_HOME: join(invalidHome, ".config") } });
111
+ check("setup CLI validates both configs before writing either", invalidSetup.status !== 0 && readFileSync(invalidServerPath, "utf8") === invalidServerBefore);
112
+
113
+ const ambiguousHome = join(testDir, "ambiguous-setup-home");
114
+ const ambiguousConfigDir = join(ambiguousHome, ".config", "opencode");
115
+ mkdirSync(ambiguousConfigDir, { recursive: true });
116
+ writeFileSync(join(ambiguousConfigDir, "opencode.json"), `{ "plugin": [] }\n`);
117
+ writeFileSync(join(ambiguousConfigDir, "opencode.jsonc"), `{ "plugin": [] }\n`);
118
+ const ambiguousSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: { ...process.env, HOME: ambiguousHome, XDG_CONFIG_HOME: join(ambiguousHome, ".config") } });
119
+ check("setup CLI refuses ambiguous json/jsonc configs", ambiguousSetup.status !== 0 && ambiguousSetup.stderr.includes("Cannot choose between"));
120
+
121
+ // Verify the export map resolves both entrypoints. OpenCode loads the package's
122
+ // raw TypeScript entrypoints with its own module loader at runtime, while plain
123
+ // Node will not type-strip .ts files under node_modules; resolution (not direct
124
+ // import) is therefore the correct invariant to assert here.
125
+ const serverImport = spawnSync("node", ["--input-type=module", "-e", `
126
+ const serverUrl = import.meta.resolve('opencode-workflow-guard/server', 'file://' + process.cwd() + '/dummy.js');
127
+ const tuiUrl = import.meta.resolve('opencode-workflow-guard/tui', 'file://' + process.cwd() + '/dummy.js');
128
+ if (!serverUrl.endsWith('/src/workflow-guard.ts') || !tuiUrl.endsWith('/src/workflow-guard-ui.ts')) process.exit(2);
129
+ `], {
130
+ cwd: testDir,
131
+ encoding: "utf8",
132
+ });
133
+ check("packed server and TUI entrypoints resolve correctly", serverImport.status === 0);
134
+ check("server and TUI package specs resolve to different modules", installedPackageJson.exports?.["."] !== installedPackageJson.exports?.["./tui"]);
135
+
136
+ // 2. Verify opencode binary is available for live runtime tests.
137
+ const opencodeCheck = spawnSync("opencode", ["--version"], { encoding: "utf8" });
138
+ if (opencodeCheck.status !== 0) {
139
+ 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
+ console.log(`\n${pass} passed, ${fail} failed`);
142
+ process.exit(fail > 0 ? 1 : 0);
143
+ }
144
+ console.log(` Found OpenCode version: ${opencodeCheck.stdout.trim()}`);
145
+
146
+ const pluginsDir = join(testDir, ".opencode", "plugins");
147
+ mkdirSync(pluginsDir, { recursive: true });
148
+
149
+ const sourcePlugin = join(import.meta.dirname, "..", "src", "workflow-guard.ts");
150
+ const sourceDir = join(testDir, ".opencode", "workflow-guard-source");
151
+ mkdirSync(sourceDir, { recursive: true });
152
+ const targetPlugin = join(sourceDir, "workflow-guard.ts");
153
+ copyFileSync(sourcePlugin, targetPlugin);
154
+ cpSync(join(import.meta.dirname, "..", "src", "lib"), join(sourceDir, "lib"), { recursive: true });
155
+ cpSync(join(import.meta.dirname, "..", "src", "policies"), join(sourceDir, "policies"), { recursive: true });
156
+ const localAdapter = join(pluginsDir, "workflow-guard.ts");
157
+ const importedMarker = join(testDir, ".workflow-guard-imported");
158
+ const initializedMarker = join(testDir, ".workflow-guard-initialized");
159
+ const accountabilityMarker = join(testDir, ".workflow-guard-accountability");
160
+ writeFileSync(localAdapter, `import { writeFileSync } from "node:fs";
161
+
162
+ writeFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
163
+
164
+ export const WorkflowGuardE2E = async (ctx: any) => {
165
+ \tconst { WorkflowGuard } = await import("../workflow-guard-source/workflow-guard.ts");
166
+ \tconst hooks = await WorkflowGuard(ctx);
167
+ \tconst status = await hooks.tool?.guard_status?.execute({}, { sessionID: "headless-e2e", directory: ctx.directory, worktree: ctx.worktree });
168
+ \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 });
169
+ \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: JSON.parse(String(why)) }) + "\\n");
170
+ \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
171
+ \treturn hooks;
172
+ };
173
+
174
+ export default { id: "workflow-guard-e2e", server: WorkflowGuardE2E };
175
+ `);
176
+ writeFileSync(join(testDir, ".opencode", "opencode.json"), `${JSON.stringify({ plugin: ["./plugins/workflow-guard.ts"] }, null, 2)}\n`);
177
+ check("local plugin adapter and source copied successfully", existsSync(localAdapter) && existsSync(targetPlugin));
178
+
179
+ // Initialize git repository on a feature branch
180
+ spawnSync("git", ["init", "-b", "feat/install-verification"], { cwd: testDir });
181
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: testDir });
182
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: testDir });
183
+
184
+ const runtimeEnv: NodeJS.ProcessEnv = {
185
+ ...process.env,
186
+ XDG_CONFIG_HOME: join(testDir, "runtime-config"),
187
+ XDG_STATE_HOME: join(testDir, "runtime-state"),
188
+ XDG_DATA_HOME: join(testDir, "runtime-data"),
189
+ };
190
+ delete runtimeEnv.OPENCODE_PID;
191
+ delete runtimeEnv.OPENCODE_PURE;
192
+ const runOpenCode = (args: string[], timeout: number) =>
193
+ spawnSync("opencode", ["run", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
194
+ const configProbe = spawnSync("opencode", ["debug", "config"], { cwd: testDir, encoding: "utf8", timeout: 30_000, env: runtimeEnv });
195
+ let configLoadsLocalPlugin = false;
196
+ try {
197
+ const config = JSON.parse(configProbe.stdout);
198
+ configLoadsLocalPlugin = config.plugin_origins?.some(
199
+ (origin: { spec?: unknown; source?: unknown; scope?: unknown }) =>
200
+ typeof origin.spec === "string" &&
201
+ origin.spec.endsWith("/.opencode/plugins/workflow-guard.ts") &&
202
+ origin.scope === "local",
203
+ );
204
+ } catch {}
205
+ check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin);
206
+ let headlessAccountability: any;
207
+ try {
208
+ headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
209
+ } catch {}
210
+ 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");
211
+
212
+ if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
213
+ console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
214
+ rmSync(testDir, { recursive: true, force: true });
215
+ console.log(`\n${pass} passed, ${fail} failed`);
216
+ process.exit(fail > 0 ? 1 : 0);
217
+ }
218
+
219
+ const recentModelPath = join(
220
+ process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
221
+ "opencode",
222
+ "model.json",
223
+ );
224
+ let recentModel: string | undefined;
225
+ try {
226
+ const recent = JSON.parse(readFileSync(recentModelPath, "utf8")).recent?.[0];
227
+ if (typeof recent?.providerID === "string" && typeof recent?.modelID === "string") {
228
+ recentModel = `${recent.providerID}/${recent.modelID}`;
229
+ }
230
+ } catch {}
231
+ const liveModel = process.env.WORKFLOW_GUARD_LIVE_MODEL?.trim() || recentModel;
232
+ if (!liveModel) {
233
+ console.error(`FAIL: no recent OpenCode model found in ${recentModelPath}; select a model in OpenCode or set WORKFLOW_GUARD_LIVE_MODEL=provider/model.`);
234
+ rmSync(testDir, { recursive: true, force: true });
235
+ process.exit(1);
236
+ }
237
+ console.log(` Using live model: ${liveModel}`);
238
+
239
+ // 3. Test: Direct edit without task list is blocked by the loaded plugin
240
+ console.log(" Running live OpenCode prompt to verify plugin intercept...");
241
+ 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);
242
+
243
+ const output1 = run1.stdout + run1.stderr;
244
+ const adapterInitialized = existsSync(initializedMarker);
245
+ check("local plugin adapter imported in live run", existsSync(importedMarker));
246
+ check("local plugin adapter initialized in live run", adapterInitialized);
247
+ if (!adapterInitialized) console.log(output1.split("\n").filter((line) => /plugin|workflow-guard/i.test(line)).join("\n").slice(-8_000));
248
+ const blockedByGuard =
249
+ output1.includes("[workflow-guard] Blocked: no active todo item") ||
250
+ output1.includes("blocked write: no active todo item") ||
251
+ output1.includes("shell file mutation with no active todo item");
252
+ const run1AccountedFor = checkLive("plugin loaded and intercepted mutation without active todo", blockedByGuard, output1);
253
+ if (!run1AccountedFor) {
254
+ console.log(" task-gate output tail:");
255
+ console.log(output1.slice(-2_000));
256
+ }
257
+
258
+ // 4. Test: Workspace boundary escape is blocked
259
+ const run2 = runOpenCode(["--model", liveModel, "1) Use todowrite to create a pending task 'test'. 2) Invoke bash with exactly this command: printf hi > ../outside_escaped.txt. Make the tool call even if you expect it to be rejected."], 120_000);
260
+
261
+ const output2 = run2.stdout + run2.stderr;
262
+ const boundaryBlocked =
263
+ output2.includes("escapes workspace root") ||
264
+ output2.includes("path escapes workspace") ||
265
+ output2.includes("outside the workspace root");
266
+ const run2AccountedFor = checkLive("plugin loaded and enforced workspace boundary escape guard", boundaryBlocked, output2);
267
+ if (!run2AccountedFor) {
268
+ console.log(" boundary-test output tail:");
269
+ console.log(output2.slice(-2_000));
270
+ }
271
+
272
+ // 5. Test: Compliant workflow (todowrite -> write -> complete) succeeds
273
+ const run3 = runOpenCode(["--model", liveModel, "1) Use todowrite with task 'create verified.txt' (pending). 2) Use write tool to create verified.txt containing 'installed-ok'. 3) Mark task completed."], 120_000);
274
+
275
+ const targetFile = join(testDir, "verified.txt");
276
+ const fileCreated = existsSync(targetFile) && readFileSync(targetFile, "utf8").includes("installed-ok");
277
+ const output3 = run3.stdout + run3.stderr;
278
+ const run3AccountedFor = checkLive("compliant workflow with todowrite succeeded through plugin", fileCreated, output3);
279
+ if (!run3AccountedFor) {
280
+ console.log(" compliant-workflow output tail:");
281
+ console.log(output3.slice(-2_000));
282
+ }
283
+
284
+ // Clean up
285
+ rmSync(testDir, { recursive: true, force: true });
286
+
287
+ console.log(`\n${pass} passed, ${fail} failed, ${unavailable} live unavailable`);
288
+ process.exit(fail ? 1 : 0);