opencode-workflow-guard 1.9.3 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.9.3",
3
+ "version": "1.10.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,7 @@ export class ToolOutcomeTracker {
27
27
  private readonly terminalCalls = new Map<string, Set<string>>();
28
28
  private readonly fallbackCalls = new Map<string, Set<string>>();
29
29
  private readonly failures = new Map<string, { signature: string; count: number }>();
30
+ private readonly consecutiveFailures = new Map<string, number>();
30
31
 
31
32
  record(part: ToolOutcomePart): ToolOutcome | undefined {
32
33
  if (part.type !== "tool" || typeof part.sessionID !== "string" || typeof part.callID !== "string" || typeof part.tool !== "string") return undefined;
@@ -51,6 +52,7 @@ export class ToolOutcomeTracker {
51
52
  const durationMs = typeof start === "number" && Number.isFinite(start) && typeof end === "number" && Number.isFinite(end) ? Math.max(0, end - start) : undefined;
52
53
  if (status === "completed") {
53
54
  this.failures.delete(part.sessionID);
55
+ this.consecutiveFailures.delete(part.sessionID);
54
56
  return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs };
55
57
  }
56
58
 
@@ -59,6 +61,10 @@ export class ToolOutcomeTracker {
59
61
  const previous = this.failures.get(part.sessionID);
60
62
  const count = previous?.signature === signature ? previous.count + 1 : 1;
61
63
  this.failures.set(part.sessionID, { signature, count });
64
+
65
+ const consecutive = (this.consecutiveFailures.get(part.sessionID) ?? 0) + 1;
66
+ this.consecutiveFailures.set(part.sessionID, consecutive);
67
+
62
68
  return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs, repeatedFailureCount: count };
63
69
  }
64
70
 
@@ -74,8 +80,15 @@ export class ToolOutcomeTracker {
74
80
  return { sessionID, callID, tool, status: "completed", durationMs };
75
81
  }
76
82
 
83
+ getFailureCount(sessionID: string): number {
84
+ const consecutive = this.consecutiveFailures.get(sessionID) ?? 0;
85
+ const equivalent = this.failures.get(sessionID)?.count ?? 0;
86
+ return Math.max(consecutive, equivalent);
87
+ }
88
+
77
89
  clearSession(sessionID: string): void {
78
90
  this.failures.delete(sessionID);
91
+ this.consecutiveFailures.delete(sessionID);
79
92
  this.terminalCalls.delete(sessionID);
80
93
  this.fallbackCalls.delete(sessionID);
81
94
  }
@@ -158,13 +158,9 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
158
158
  what: `tee to '${teeTargets[0]!}'`,
159
159
  };
160
160
  }
161
- const sedWords = shellWords(unwrapShellCommand(segment));
162
- if (sedWords[0] === "sed" && sedWords.slice(1).some((word) => /^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word))) {
163
- const operands = sedWords.slice(1).filter((word) => !word.startsWith("-"));
164
- const target = operands.at(-1);
165
- if (!target) return undefined;
166
- return { kind: "command", target, what: `sed -i on '${target}'` };
167
- }
161
+ const simple = simpleFilesystemMutations(segment);
162
+ if (simple.length > 0) return simple[0];
163
+
168
164
  const transfer = filesystemTransferInfo(segment);
169
165
  if (transfer?.destination) {
170
166
  return {
@@ -173,34 +169,45 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
173
169
  what: `copy/move/link to '${transfer.destination}'`,
174
170
  };
175
171
  }
176
- const fsMutationMatch = segment.match(
177
- /\b(?:touch|mkdir|rm|unlink|rmdir|ln)\b[^|;&]*\s+["']?([^\s;&|"']+)["']?\s*$/,
178
- );
179
- if (fsMutationMatch?.[1] && !fsMutationMatch[1].startsWith("-")) {
180
- return {
181
- kind: "command",
182
- target: fsMutationMatch[1],
183
- what: `filesystem mutation of '${fsMutationMatch[1]}'`,
184
- };
172
+ const words = shellWords(unwrapShellCommand(segment));
173
+ const command = words[0];
174
+ if (!command) return undefined;
175
+
176
+ if (command === "curl") {
177
+ for (let i = 1; i < words.length; i++) {
178
+ const w = words[i]!;
179
+ if ((w === "-o" || w === "--output") && i + 1 < words.length) {
180
+ return { kind: "command", target: words[i + 1]!, what: `curl output to '${words[i + 1]!}'` };
181
+ }
182
+ if (w.startsWith("--output=")) {
183
+ const target = w.slice("--output=".length);
184
+ return { kind: "command", target, what: `curl output to '${target}'` };
185
+ }
186
+ }
185
187
  }
186
- const copyMatch = segment.match(
187
- /\b(?:cp|mv|rsync|install|cpio|scp|wget|curl)\b[^|;&]*?(-o\s+)?(["']?)([^\s;&|"']+)\2?\s*$/,
188
- );
189
- if (copyMatch?.[3] && /\b(?:cp|mv|rsync|install)\b/.test(segment)) {
190
- return {
191
- kind: "command",
192
- target: copyMatch[3],
193
- what: `copy/move to '${copyMatch[3]}'`,
194
- };
188
+ if (command === "wget") {
189
+ for (let i = 1; i < words.length; i++) {
190
+ const w = words[i]!;
191
+ if ((w === "-O" || w === "--output-document") && i + 1 < words.length) {
192
+ return { kind: "command", target: words[i + 1]!, what: `wget output to '${words[i + 1]!}'` };
193
+ }
194
+ if (w.startsWith("--output-document=")) {
195
+ const target = w.slice("--output-document=".length);
196
+ return { kind: "command", target, what: `wget output to '${target}'` };
197
+ }
198
+ }
195
199
  }
196
- if (/\bgit\s+(?:apply|am)\b/.test(segment)) {
197
- return { kind: "command", what: "git apply/am (patch via shell)" };
200
+ if (command === "git") {
201
+ const sub = words.slice(1).find((w) => !w.startsWith("-"));
202
+ if (sub === "apply" || sub === "am") {
203
+ return { kind: "command", what: "git apply/am (patch via shell)" };
204
+ }
198
205
  }
199
206
  return undefined;
200
207
  }
201
208
 
202
209
  const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate", "chmod", "chown", "chgrp"]);
203
- const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
210
+ const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln", "rsync", "install", "cpio", "scp"]);
204
211
 
205
212
  export function simpleFilesystemMutations(segment: string): ShellMutation[] {
206
213
  const words = shellWords(unwrapShellCommand(segment));
@@ -83,6 +83,13 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
83
83
  }
84
84
  generatedIDs.add(messageID);
85
85
  state.generatedMessageIDs.set(sessionID, generatedIDs);
86
+ const activeTasks = (effective.todos ?? [])
87
+ .filter((t) => t.status === "in_progress" || t.status === "pending")
88
+ .slice(0, 10);
89
+ const tasksSummary = activeTasks.length > 0
90
+ ? "\nRemaining active tasks:\n" + activeTasks.map((t) => `- [${t.status === "in_progress" ? "IN_PROGRESS" : "PENDING"}] ${t.content}`).join("\n")
91
+ : "";
92
+
86
93
  try {
87
94
  await session.promptAsync({
88
95
  path: { id: sessionID },
@@ -90,7 +97,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
90
97
  messageID,
91
98
  parts: [{
92
99
  type: "text",
93
- text: "Workflow Guard: unfinished todos remain. Continue working through them. If you need user input, use the question tool instead of ending the run.",
100
+ text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run.`,
94
101
  synthetic: true,
95
102
  }],
96
103
  },
@@ -414,7 +414,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
414
414
  });
415
415
  if (reason !== undefined) {
416
416
  await emitBlockFeedback(reason);
417
- throw new Error(`[workflow-guard] ${reason}`);
417
+ const failureCount = input.sessionID ? toolOutcomes.getFailureCount(input.sessionID) : 0;
418
+ const circuitBreakerSuffix = failureCount >= 2
419
+ ? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly.]"
420
+ : "";
421
+ throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
418
422
  }
419
423
  toolLifecycle.start(input.sessionID, input.callID);
420
424
  if (input.tool === "read") {
@@ -606,13 +610,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
606
610
 
607
611
  // Keep tool descriptions honest: todowrite's description reflects lifecycle
608
612
  // and finalization gates so the model is not surprised by preventable blocks.
613
+ // Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
609
614
  "tool.definition": async (input, output) => {
610
- if (input.toolID !== "todowrite") return;
611
- const description = typeof output.description === "string" ? output.description : "";
612
- if (description.includes("verification evidence")) return;
613
- output.description =
614
- description +
615
- "\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.";
615
+ if (input.toolID === "todowrite") {
616
+ const description = typeof output.description === "string" ? output.description : "";
617
+ if (description.includes("verification evidence")) return;
618
+ output.description =
619
+ description +
620
+ "\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.";
621
+ return;
622
+ }
623
+ if (EDIT_TOOL_NAMES.has(input.toolID)) {
624
+ const description = typeof output.description === "string" ? output.description : "";
625
+ if (description.includes("Workflow Guard requirement")) return;
626
+ output.description =
627
+ description +
628
+ "\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
629
+ return;
630
+ }
616
631
  },
617
632
 
618
633
  "chat.message": async (input) => {
package/test/test-e2e.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdtempSync, rmSync, existsSync, readFileSync, copyFileSync, cpSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { parse as parseJsonc } from "jsonc-parser";
@@ -143,9 +143,30 @@ if (opencodeCheck.status !== 0) {
143
143
  }
144
144
  console.log(` Found OpenCode version: ${opencodeCheck.stdout.trim()}`);
145
145
 
146
- const pluginsDir = join(testDir, ".opencode", "plugins");
146
+ const opencodeDir = join(testDir, ".opencode");
147
+ const pluginsDir = join(opencodeDir, "plugins");
147
148
  mkdirSync(pluginsDir, { recursive: true });
148
149
 
150
+ // Pre-seed .opencode with dependencies already installed into testDir
151
+ // so opencode does not execute a cold network install at runtime startup.
152
+ const opencodeVersion = opencodeCheck.stdout.trim();
153
+ const lockPath = join(testDir, "package-lock.json");
154
+ if (existsSync(lockPath) && existsSync(join(testDir, "node_modules"))) {
155
+ try {
156
+ const lock = JSON.parse(readFileSync(lockPath, "utf8"));
157
+ lock.name = ".opencode";
158
+ if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
159
+ lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeVersion;
160
+ }
161
+ if (lock.packages?.[""]) {
162
+ lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeVersion };
163
+ }
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
+ cpSync(join(testDir, "node_modules"), join(opencodeDir, "node_modules"), { recursive: true });
167
+ } catch {}
168
+ }
169
+
149
170
  const sourcePlugin = join(import.meta.dirname, "..", "src", "workflow-guard.ts");
150
171
  const sourceDir = join(testDir, ".opencode", "workflow-guard-source");
151
172
  mkdirSync(sourceDir, { recursive: true });
@@ -189,9 +210,43 @@ const runtimeEnv: NodeJS.ProcessEnv = {
189
210
  };
190
211
  delete runtimeEnv.OPENCODE_PID;
191
212
  delete runtimeEnv.OPENCODE_PURE;
213
+ delete runtimeEnv.OPENCODE;
192
214
  const runOpenCode = (args: string[], timeout: number) =>
193
215
  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 });
216
+ const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
217
+ const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: runtimeEnv });
218
+ let stdout = "";
219
+ let stderr = "";
220
+ let timer: NodeJS.Timeout | undefined;
221
+ let pollInterval: NodeJS.Timeout | undefined;
222
+
223
+ const done = (status: number | null) => {
224
+ if (timer) clearTimeout(timer);
225
+ if (pollInterval) clearInterval(pollInterval);
226
+ try { child.kill("SIGTERM"); } catch {}
227
+ resolve({ status: status ?? 0, stdout, stderr });
228
+ };
229
+
230
+ const checkReady = () => {
231
+ try {
232
+ if (JSON.parse(stdout)?.plugin_origins && existsSync(initializedMarker)) {
233
+ done(0);
234
+ }
235
+ } catch {}
236
+ };
237
+
238
+ pollInterval = setInterval(checkReady, 50);
239
+
240
+ child.stdout?.on("data", (d) => {
241
+ stdout += d.toString();
242
+ checkReady();
243
+ });
244
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
245
+ child.on("close", (code) => done(code));
246
+ child.on("error", () => done(null));
247
+
248
+ timer = setTimeout(() => done(null), 30_000);
249
+ });
195
250
  let configLoadsLocalPlugin = false;
196
251
  try {
197
252
  const config = JSON.parse(configProbe.stdout);
package/test/test.mts CHANGED
@@ -497,6 +497,9 @@ check("sed -i on opencode.json blocked (tamper)", blocked(await call("bash", { c
497
497
  check("git apply needs todos (patch via shell)", blocked(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-empty" })));
498
498
  check("git apply allowed with todos", !(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-active" })));
499
499
  check("non-mutating shell unaffected (ls, cat)", !(await call("bash", { command: "ls -la && cat file" }, { sessionID: "s-empty" })));
500
+ check("inspection command with mutation keywords in grep argument is not blocked", !(await call("bash", { command: 'strings /bin/opencode | grep -E "install plugin and update config" -C 10' }, { sessionID: "s-empty" })));
501
+ 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" })));
502
+ check("grep searching for mutation command name is not blocked", !(await call("bash", { command: 'grep -rn "mkdir" src/' }, { sessionID: "s-empty" })));
500
503
  check("stderr redirect to /dev/null is not a file mutation", !(await call("bash", { command: "ls missing 2>/dev/null" }, { sessionID: "s-empty" })));
501
504
  check("touch outside workspace is blocked", blocked(await call("bash", { command: "touch /tmp/wg-outside-touch" }, { sessionID: "s-active" })));
502
505
  check("mkdir outside workspace is blocked", blocked(await call("bash", { command: "mkdir /tmp/wg-outside-dir" }, { sessionID: "s-active" })));
@@ -1096,6 +1099,14 @@ if (typeof cmdEvt.event === "function") {
1096
1099
  }
1097
1100
  check("tool.execute.after provides deduplicated fallback outcome telemetry", getRecentAuditEntries(10).filter((entry) => entry.callID === "after-only-outcome" && entry.phase === "outcome").length === 1);
1098
1101
 
1102
+ let circuitBreakerMessage = "";
1103
+ try {
1104
+ await cmdEvt["tool.execute.before"]?.({ tool: "edit", sessionID: "s-outcome", callID: "call-cb" } as any, { args: { filePath: join(root, "file.txt"), content: "hello" } } as any);
1105
+ } catch (err) {
1106
+ circuitBreakerMessage = (err as Error).message;
1107
+ }
1108
+ check("repeated failures trigger circuit breaker guidance in block error", circuitBreakerMessage.includes("Workflow Guard Circuit Breaker") && circuitBreakerMessage.includes("Repeated failures detected"));
1109
+
1099
1110
  const outcomeTracker = new ToolOutcomeTracker();
1100
1111
  const failedPart = (callID: string, error: string) => ({ type: "tool", sessionID: "s-tracker", callID, tool: "bash", state: { status: "error", error } } as const);
1101
1112
  check("duplicate terminal tool updates are ignored", !!outcomeTracker.record(failedPart("duplicate", "same failure")) && outcomeTracker.record(failedPart("duplicate", "same failure")) === undefined);
@@ -1104,6 +1115,12 @@ outcomeTracker.record({ type: "tool", sessionID: "s-tracker", callID: "success",
1104
1115
  check("successful tool outcomes reset equivalent failure tracking", outcomeTracker.record(failedPart("after-success", "same failure"))?.repeatedFailureCount === 1);
1105
1116
  outcomeTracker.record(failedPart("different", "different failure"));
1106
1117
  check("distinct failures reset equivalent failure tracking", outcomeTracker.record(failedPart("after-different", "same failure"))?.repeatedFailureCount === 1);
1118
+ const consecutiveTracker = new ToolOutcomeTracker();
1119
+ consecutiveTracker.record(failedPart("diverse-1", "failure A"));
1120
+ consecutiveTracker.record(failedPart("diverse-2", "failure B"));
1121
+ check("consecutive diverse failures track failure count for circuit breaker", consecutiveTracker.getFailureCount("s-tracker") === 2);
1122
+ consecutiveTracker.record({ type: "tool", sessionID: "s-tracker", callID: "diverse-success", tool: "bash", state: { status: "completed" } });
1123
+ check("successful tool outcome resets consecutive failure count", consecutiveTracker.getFailureCount("s-tracker") === 0);
1107
1124
  const boundedTracker = new ToolOutcomeTracker();
1108
1125
  for (let i = 0; i < 4096; i++) boundedTracker.record({ type: "tool", sessionID: "s-bounded", callID: `call-${i}`, tool: "bash", state: { status: "completed" } });
1109
1126
  check("terminal call deduplication remains stable within its bounded window", boundedTracker.record({ type: "tool", sessionID: "s-bounded", callID: "call-0", tool: "bash", state: { status: "completed" } }) === undefined);
@@ -3141,6 +3158,13 @@ const idemDef = { description: todoDef.description, parameters: {} };
3141
3158
  await defPlugin["tool.definition"]?.({ toolID: "todowrite" } as any, idemDef);
3142
3159
  check("tool.definition enrichment is idempotent", idemDef.description === todoDef.description);
3143
3160
 
3161
+ const editDef = { description: "Edit a file.", parameters: {} };
3162
+ await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editDef);
3163
+ check("tool.definition enriches edit tool with preconditions", editDef.description.includes("Workflow Guard requirement") && editDef.description.includes("todowrite"));
3164
+ const editIdemDef = { description: editDef.description, parameters: {} };
3165
+ await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editIdemDef);
3166
+ check("tool.definition edit enrichment is idempotent", editIdemDef.description === editDef.description);
3167
+
3144
3168
  // Socratic learning engine: evidence is explicit and interventions favor
3145
3169
  // relevant gaps without repeatedly interrupting demonstrated knowledge.
3146
3170
  const learner = createLearnerProfile();