opencode-workflow-guard 1.10.0 → 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.10.0",
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
 
@@ -75,11 +81,14 @@ export class ToolOutcomeTracker {
75
81
  }
76
82
 
77
83
  getFailureCount(sessionID: string): number {
78
- return this.failures.get(sessionID)?.count ?? 0;
84
+ const consecutive = this.consecutiveFailures.get(sessionID) ?? 0;
85
+ const equivalent = this.failures.get(sessionID)?.count ?? 0;
86
+ return Math.max(consecutive, equivalent);
79
87
  }
80
88
 
81
89
  clearSession(sessionID: string): void {
82
90
  this.failures.delete(sessionID);
91
+ this.consecutiveFailures.delete(sessionID);
83
92
  this.terminalCalls.delete(sessionID);
84
93
  this.fallbackCalls.delete(sessionID);
85
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));
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" })));
@@ -1112,6 +1115,12 @@ outcomeTracker.record({ type: "tool", sessionID: "s-tracker", callID: "success",
1112
1115
  check("successful tool outcomes reset equivalent failure tracking", outcomeTracker.record(failedPart("after-success", "same failure"))?.repeatedFailureCount === 1);
1113
1116
  outcomeTracker.record(failedPart("different", "different failure"));
1114
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);
1115
1124
  const boundedTracker = new ToolOutcomeTracker();
1116
1125
  for (let i = 0; i < 4096; i++) boundedTracker.record({ type: "tool", sessionID: "s-bounded", callID: `call-${i}`, tool: "bash", state: { status: "completed" } });
1117
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);