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 +1 -1
- package/src/lib/tool-outcomes.ts +10 -1
- package/src/policies/boundary.ts +35 -28
- package/test/test.mts +9 -0
package/package.json
CHANGED
package/src/lib/tool-outcomes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -158,13 +158,9 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
158
158
|
what: `tee to '${teeTargets[0]!}'`,
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 (
|
|
197
|
-
|
|
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);
|