opencode-workflow-guard 1.5.1 → 1.6.0
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 +7 -4
- package/package.json +3 -2
- package/src/lib/audit.ts +82 -7
- package/src/lib/checkpoint.ts +308 -0
- package/src/lib/custom-tools.ts +198 -0
- package/src/lib/guard-dispatcher.ts +471 -0
- package/src/lib/project-config.ts +68 -0
- package/src/lib/project-memory.ts +44 -1
- package/src/lib/shell.ts +399 -0
- package/src/lib/state.ts +12 -95
- package/src/lib/tool-lifecycle.ts +63 -0
- package/src/lib/tool-outcomes.ts +64 -0
- package/src/lib/types.ts +36 -0
- package/src/lib/utils.ts +13 -380
- package/src/lib/verify.ts +69 -8
- package/src/policies/boundary.ts +2 -2
- package/src/policies/changelog.ts +2 -2
- package/src/policies/continuation.ts +5 -0
- package/src/policies/destructive.ts +2 -1
- package/src/policies/file-claims.ts +56 -0
- package/src/policies/git.ts +1 -1
- package/src/policies/post-edit-validation.ts +76 -0
- package/src/policies/secrets.ts +1 -1
- package/src/policies/stale-write.ts +70 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +25 -15
- package/src/workflow-guard-ui.ts +75 -0
- package/src/workflow-guard.ts +154 -1109
package/src/lib/shell.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
2
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
return value as Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function extractCommands(input: unknown): string[] {
|
|
9
|
+
if (typeof input === "string") {
|
|
10
|
+
return [input];
|
|
11
|
+
}
|
|
12
|
+
const record = asRecord(input);
|
|
13
|
+
if (!record) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const commands: string[] = [];
|
|
17
|
+
if (typeof record.command === "string") commands.push(record.command);
|
|
18
|
+
if (Array.isArray(record.commands)) {
|
|
19
|
+
for (const c of record.commands) {
|
|
20
|
+
if (typeof c === "string") commands.push(c);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return commands;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalize(cmd: string): string {
|
|
27
|
+
return cmd.replace(/\s+/g, " ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function dynamicShellSyntaxIn(command: string): string | undefined {
|
|
31
|
+
let quote: "'" | '"' | undefined;
|
|
32
|
+
let escaped = false;
|
|
33
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
34
|
+
const char = command[i]!;
|
|
35
|
+
if (escaped) {
|
|
36
|
+
escaped = false;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (char === "\\" && quote !== "'") {
|
|
40
|
+
escaped = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char === "'") {
|
|
44
|
+
if (!quote) quote = "'";
|
|
45
|
+
else if (quote === "'") quote = undefined;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === '"') {
|
|
49
|
+
if (!quote) quote = '"';
|
|
50
|
+
else if (quote === '"') quote = undefined;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (quote === "'") continue;
|
|
54
|
+
if (char === "`" || (char === "$" && command[i + 1] === "(") || (!quote && (char === "<" || char === ">") && command[i + 1] === "(")) {
|
|
55
|
+
return "dynamic command/process substitution";
|
|
56
|
+
}
|
|
57
|
+
if (char === "$" && /^(?:IFS\b|\{IFS(?:\}|[:\[]))/.test(command.slice(i + 1))) return "dynamic IFS expansion";
|
|
58
|
+
if (!quote && (char === "\r" || /[\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/u.test(char))) {
|
|
59
|
+
return "ambiguous shell whitespace";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (quote || escaped) return "malformed shell quoting";
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decodeShellEscapes(text: string): string {
|
|
67
|
+
return text
|
|
68
|
+
// ANSI-C quoting wrapper: $'...' -> ...
|
|
69
|
+
.replace(/\$'([^']*)'/g, "$1")
|
|
70
|
+
// Hex escapes: \xHH
|
|
71
|
+
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
72
|
+
// Unicode escapes: \uHHHH
|
|
73
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
74
|
+
// Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
|
|
75
|
+
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function shellWords(command: string): string[] {
|
|
79
|
+
const words: string[] = [];
|
|
80
|
+
let word = "";
|
|
81
|
+
let quote: "'" | '"' | undefined;
|
|
82
|
+
let escaped = false;
|
|
83
|
+
let started = false;
|
|
84
|
+
for (const char of command) {
|
|
85
|
+
if (escaped) {
|
|
86
|
+
word += char;
|
|
87
|
+
escaped = false;
|
|
88
|
+
started = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (char === "\\" && quote !== "'") {
|
|
92
|
+
escaped = true;
|
|
93
|
+
started = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === "'" || char === '"') {
|
|
97
|
+
if (!quote) quote = char;
|
|
98
|
+
else if (quote === char) quote = undefined;
|
|
99
|
+
else word += char;
|
|
100
|
+
started = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!quote && /\s/.test(char)) {
|
|
104
|
+
if (started) words.push(word);
|
|
105
|
+
word = "";
|
|
106
|
+
started = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
word += char;
|
|
110
|
+
started = true;
|
|
111
|
+
}
|
|
112
|
+
if (escaped) word += "\\";
|
|
113
|
+
if (started) words.push(word);
|
|
114
|
+
return words;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function envSplitWords(value: string): string[] {
|
|
118
|
+
const words: string[] = [];
|
|
119
|
+
let word = "";
|
|
120
|
+
let quote: "'" | '"' | undefined;
|
|
121
|
+
let started = false;
|
|
122
|
+
const push = () => {
|
|
123
|
+
if (started) words.push(word);
|
|
124
|
+
word = "";
|
|
125
|
+
started = false;
|
|
126
|
+
};
|
|
127
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
128
|
+
const char = value[i]!;
|
|
129
|
+
if (char === "'" || char === '"') {
|
|
130
|
+
if (!quote) quote = char;
|
|
131
|
+
else if (quote === char) quote = undefined;
|
|
132
|
+
else word += char;
|
|
133
|
+
started = true;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (char === "\\" && i + 1 < value.length) {
|
|
137
|
+
const next = value[++i]!;
|
|
138
|
+
if (quote === "'" && next !== "'" && next !== "\\") {
|
|
139
|
+
word += `\\${next}`;
|
|
140
|
+
started = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (next === "c" && !quote) break;
|
|
144
|
+
if (next === "_" && quote !== "'") {
|
|
145
|
+
if (quote === '"') {
|
|
146
|
+
word += " ";
|
|
147
|
+
started = true;
|
|
148
|
+
} else push();
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const escapes: Record<string, string> = { f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "#": "#", "$": "$", '"': '"', "'": "'", "\\": "\\" };
|
|
152
|
+
word += escapes[next] ?? next;
|
|
153
|
+
started = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!quote && /[\s\f\v]/.test(char)) {
|
|
157
|
+
push();
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!quote && char === "#" && !started) break;
|
|
161
|
+
if (char === "$" && quote !== "'" && value[i + 1] === "{") {
|
|
162
|
+
const end = value.indexOf("}", i + 2);
|
|
163
|
+
if (end >= 0) {
|
|
164
|
+
const name = value.slice(i + 2, end);
|
|
165
|
+
word += process.env[name] ?? "";
|
|
166
|
+
started = true;
|
|
167
|
+
i = end;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
word += char;
|
|
172
|
+
started = true;
|
|
173
|
+
}
|
|
174
|
+
push();
|
|
175
|
+
return words;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function splitShellSegments(command: string): string[] {
|
|
179
|
+
const segments: string[] = [];
|
|
180
|
+
let current = "";
|
|
181
|
+
let quote: "'" | '"' | "ansi" | undefined;
|
|
182
|
+
let escaped = false;
|
|
183
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
184
|
+
const char = command[i]!;
|
|
185
|
+
if (escaped) {
|
|
186
|
+
current += char;
|
|
187
|
+
escaped = false;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
191
|
+
current += "$'";
|
|
192
|
+
quote = "ansi";
|
|
193
|
+
i += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (char === "\\" && quote !== "'") {
|
|
197
|
+
current += char;
|
|
198
|
+
escaped = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (quote === "ansi" && char === "'") {
|
|
202
|
+
quote = undefined;
|
|
203
|
+
current += char;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (char === "'" || char === '"') {
|
|
207
|
+
if (!quote) quote = char;
|
|
208
|
+
else if (quote === char) quote = undefined;
|
|
209
|
+
current += char;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!quote && /[\n|;&]/.test(char)) {
|
|
213
|
+
if (current.trim()) segments.push(current.trim());
|
|
214
|
+
current = "";
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
current += char;
|
|
218
|
+
}
|
|
219
|
+
if (current.trim()) segments.push(current.trim());
|
|
220
|
+
return segments;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean } {
|
|
224
|
+
let cleanCmd = decodeShellEscapes(command.trim());
|
|
225
|
+
// Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
|
|
226
|
+
while (
|
|
227
|
+
(cleanCmd.startsWith("(") && cleanCmd.endsWith(")")) ||
|
|
228
|
+
(cleanCmd.startsWith("{") && cleanCmd.endsWith("}"))
|
|
229
|
+
) {
|
|
230
|
+
cleanCmd = cleanCmd.slice(1, -1).trim();
|
|
231
|
+
if (cleanCmd.endsWith(";")) cleanCmd = cleanCmd.slice(0, -1).trim();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const words = shellWords(cleanCmd);
|
|
235
|
+
let i = 0;
|
|
236
|
+
let changesCwd = false;
|
|
237
|
+
while (i < words.length) {
|
|
238
|
+
const rawWord = words[i]!;
|
|
239
|
+
const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
|
|
240
|
+
if (!word) {
|
|
241
|
+
i++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
|
|
246
|
+
i++;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (word === "nice") {
|
|
250
|
+
i++;
|
|
251
|
+
if (i < words.length && (words[i] === "-n" || words[i]?.startsWith("-n"))) {
|
|
252
|
+
if (words[i] === "-n") i += 2;
|
|
253
|
+
else i++;
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (word === "timeout") {
|
|
258
|
+
i++;
|
|
259
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
260
|
+
const opt = words[i++]!;
|
|
261
|
+
if ((opt === "-k" || opt === "-s" || opt === "--signal" || opt === "--kill-after") && i < words.length) {
|
|
262
|
+
i++;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (i < words.length && /^\d+[smhd]?$/.test(words[i]!)) {
|
|
266
|
+
i++;
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (word === "stdbuf") {
|
|
271
|
+
i++;
|
|
272
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
273
|
+
const opt = words[i++]!;
|
|
274
|
+
if ((opt === "-i" || opt === "-o" || opt === "-e") && i < words.length) {
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (word === "time") {
|
|
281
|
+
i++;
|
|
282
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (word === "sudo" || word === "doas") {
|
|
288
|
+
i++;
|
|
289
|
+
const valueOptions = new Set([
|
|
290
|
+
"-u",
|
|
291
|
+
"--user",
|
|
292
|
+
"-g",
|
|
293
|
+
"--group",
|
|
294
|
+
"-h",
|
|
295
|
+
"--host",
|
|
296
|
+
"-p",
|
|
297
|
+
"--prompt",
|
|
298
|
+
"-C",
|
|
299
|
+
"--close-from",
|
|
300
|
+
"-R",
|
|
301
|
+
"--chroot",
|
|
302
|
+
"-D",
|
|
303
|
+
"--chdir",
|
|
304
|
+
]);
|
|
305
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
306
|
+
const option = words[i++]!;
|
|
307
|
+
if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) changesCwd = true;
|
|
308
|
+
if (valueOptions.has(option) && i < words.length) i++;
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (word === "env") {
|
|
313
|
+
i++;
|
|
314
|
+
const valueOptions = new Set([
|
|
315
|
+
"-u",
|
|
316
|
+
"--unset",
|
|
317
|
+
"--argv0",
|
|
318
|
+
"-C",
|
|
319
|
+
"--chdir",
|
|
320
|
+
"-S",
|
|
321
|
+
"--split-string",
|
|
322
|
+
]);
|
|
323
|
+
while (i < words.length) {
|
|
324
|
+
const w = words[i]!;
|
|
325
|
+
if (w === "--") {
|
|
326
|
+
i++;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
330
|
+
i++;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
let splitValue: string | undefined;
|
|
334
|
+
let consumed = 0;
|
|
335
|
+
const clustered = /^-[iv0]*([SC])(.*)$/.exec(w);
|
|
336
|
+
if (clustered?.[1] === "C") {
|
|
337
|
+
changesCwd = true;
|
|
338
|
+
i++;
|
|
339
|
+
if (!clustered[2] && i < words.length) i++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (clustered?.[1] === "S") {
|
|
343
|
+
if (clustered[2]) splitValue = clustered[2];
|
|
344
|
+
else if (i + 1 < words.length) {
|
|
345
|
+
splitValue = words[i + 1]!;
|
|
346
|
+
consumed = 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else if (w.startsWith("-S") && w !== "-S") splitValue = w.slice(2);
|
|
350
|
+
else if (w.startsWith("--split-string=")) splitValue = w.slice("--split-string=".length);
|
|
351
|
+
else if ((w === "-S" || w === "--split-string") && i + 1 < words.length) {
|
|
352
|
+
splitValue = words[i + 1]!;
|
|
353
|
+
consumed = 1;
|
|
354
|
+
}
|
|
355
|
+
if (splitValue !== undefined) {
|
|
356
|
+
words.splice(i, consumed + 1, ...envSplitWords(splitValue));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const clusteredValue = /^-[iv0]*([ua])(.*)$/.exec(w);
|
|
360
|
+
if (clusteredValue) {
|
|
361
|
+
i++;
|
|
362
|
+
if (!clusteredValue[2] && i < words.length) i++;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (w.startsWith("-C") && w !== "-C") {
|
|
366
|
+
changesCwd = true;
|
|
367
|
+
i++;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!w.startsWith("-")) break;
|
|
371
|
+
i++;
|
|
372
|
+
if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) changesCwd = true;
|
|
373
|
+
if (valueOptions.has(w) && i < words.length) i++;
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
378
|
+
i++;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
return { words: words
|
|
384
|
+
.slice(i)
|
|
385
|
+
.map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
|
|
386
|
+
.filter(Boolean), changesCwd };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function unwrapShellWords(command: string): string[] {
|
|
390
|
+
return unwrapShellWordsImpl(command).words;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function shellWrappersChangeCwd(command: string): boolean {
|
|
394
|
+
return unwrapShellWordsImpl(command).changesCwd;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function unwrapShellCommand(command: string): string {
|
|
398
|
+
return unwrapShellWords(command).join(" ");
|
|
399
|
+
}
|
package/src/lib/state.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { realpathSync
|
|
2
|
-
import {
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
-
import { persistVerifyCache } from "./audit.ts";
|
|
4
|
+
import { persistVerifyCache, persistVerifyHistory } from "./audit.ts";
|
|
5
|
+
import { getCachedProjectConfig, loadProjectConfig } from "./project-config.ts";
|
|
5
6
|
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
|
|
6
7
|
import type {
|
|
7
8
|
TodoSdkClient,
|
|
@@ -124,6 +125,7 @@ export function recordVerifyResult(
|
|
|
124
125
|
workspaceRoot: resolve(root),
|
|
125
126
|
};
|
|
126
127
|
if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
|
|
128
|
+
persistVerifyHistory(lastVerify);
|
|
127
129
|
if (lastVerify.passed) {
|
|
128
130
|
persistVerifyCache(lastVerify);
|
|
129
131
|
}
|
|
@@ -173,99 +175,10 @@ export function resetReviewState(): void {
|
|
|
173
175
|
sessionReviews.clear();
|
|
174
176
|
}
|
|
175
177
|
|
|
176
|
-
export let cachedProjectConfig: ProjectConfig | undefined;
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Strips single-line comments, block comments, and trailing commas from JSONC
|
|
180
|
-
* text without corrupting comment markers inside string literals or URLs.
|
|
181
|
-
*/
|
|
182
|
-
export function stripJsonComments(jsonc: string): string {
|
|
183
|
-
let insideString = false;
|
|
184
|
-
let stringQuote = "";
|
|
185
|
-
let escaped = false;
|
|
186
|
-
let output = "";
|
|
187
|
-
let i = 0;
|
|
188
|
-
|
|
189
|
-
while (i < jsonc.length) {
|
|
190
|
-
const char = jsonc[i]!;
|
|
191
|
-
const next = jsonc[i + 1];
|
|
192
|
-
|
|
193
|
-
if (escaped) {
|
|
194
|
-
output += char;
|
|
195
|
-
escaped = false;
|
|
196
|
-
i++;
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (char === "\\") {
|
|
201
|
-
output += char;
|
|
202
|
-
escaped = true;
|
|
203
|
-
i++;
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (char === '"' || char === "'") {
|
|
208
|
-
if (!insideString) {
|
|
209
|
-
insideString = true;
|
|
210
|
-
stringQuote = char;
|
|
211
|
-
} else if (stringQuote === char) {
|
|
212
|
-
insideString = false;
|
|
213
|
-
}
|
|
214
|
-
output += char;
|
|
215
|
-
i++;
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (!insideString) {
|
|
220
|
-
if (char === "/" && next === "/") {
|
|
221
|
-
i += 2;
|
|
222
|
-
while (i < jsonc.length && jsonc[i] !== "\n" && jsonc[i] !== "\r") {
|
|
223
|
-
i++;
|
|
224
|
-
}
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (char === "/" && next === "*") {
|
|
228
|
-
i += 2;
|
|
229
|
-
while (i < jsonc.length && !(jsonc[i] === "*" && jsonc[i + 1] === "/")) {
|
|
230
|
-
i++;
|
|
231
|
-
}
|
|
232
|
-
i += 2;
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
output += char;
|
|
238
|
-
i++;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return output.replace(/,(\s*[}\]])/g, "$1");
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export function loadProjectConfig(root: string): ProjectConfig {
|
|
245
|
-
const candidates = [
|
|
246
|
-
join(root, ".opencode", "workflow-guard.json"),
|
|
247
|
-
join(root, ".opencode", "workflow-guard.jsonc"),
|
|
248
|
-
join(root, "workflow-guard.json"),
|
|
249
|
-
join(root, "workflow-guard.jsonc"),
|
|
250
|
-
];
|
|
251
|
-
for (const candidate of candidates) {
|
|
252
|
-
try {
|
|
253
|
-
const raw = readFileSync(candidate, "utf8");
|
|
254
|
-
const sanitized = stripJsonComments(raw);
|
|
255
|
-
return JSON.parse(sanitized);
|
|
256
|
-
} catch {}
|
|
257
|
-
}
|
|
258
|
-
return {};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export function reloadProjectConfig(root: string): void {
|
|
262
|
-
cachedProjectConfig = loadProjectConfig(root);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
178
|
export function getProjectConfig(root: string): ProjectConfig {
|
|
266
179
|
const active = runtimeState.getStore();
|
|
267
180
|
if (active?.workspaceRoot === root) return active.projectConfig;
|
|
268
|
-
return
|
|
181
|
+
return getCachedProjectConfig() ?? loadProjectConfig(root);
|
|
269
182
|
}
|
|
270
183
|
|
|
271
184
|
export function isReviewRequired(root: string): boolean {
|
|
@@ -289,11 +202,15 @@ export function getSubagentMutationBudget(root: string): number {
|
|
|
289
202
|
if (typeof cfg.maxSubagentMutations === "number" && cfg.maxSubagentMutations > 0) {
|
|
290
203
|
return cfg.maxSubagentMutations;
|
|
291
204
|
}
|
|
292
|
-
return
|
|
205
|
+
return 100;
|
|
293
206
|
}
|
|
294
207
|
|
|
295
208
|
export function isLearningEnabled(root: string): boolean {
|
|
296
|
-
return process.env.WORKFLOW_GUARD_LEARNING === "1";
|
|
209
|
+
return process.env.WORKFLOW_GUARD_LEARNING === "1" || getProjectConfig(root).learning === true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function isProjectMemoryEnabled(root: string): boolean {
|
|
213
|
+
return getProjectConfig(root).projectMemory !== false;
|
|
297
214
|
}
|
|
298
215
|
|
|
299
216
|
export function getLearningInterventionBudget(root: string): number {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { FileSnapshot } from "../policies/post-edit-validation.ts";
|
|
2
|
+
import type { ReadObservation } from "../policies/stale-write.ts";
|
|
3
|
+
|
|
4
|
+
function invocationKey(sessionID: string, callID: string): string {
|
|
5
|
+
return `${sessionID}\0${callID}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ToolInvocationLifecycle {
|
|
9
|
+
private readonly postEditSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
|
|
10
|
+
private readonly startedAt = new Map<string, number>();
|
|
11
|
+
private readonly readObservations = new Map<string, ReadObservation>();
|
|
12
|
+
private readonly recoveryRuns = new Map<string, number>();
|
|
13
|
+
|
|
14
|
+
start(sessionID: string, callID: string): void {
|
|
15
|
+
this.startedAt.set(invocationKey(sessionID, callID), Date.now());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
finish(sessionID: string, callID: string): number | undefined {
|
|
19
|
+
const key = invocationKey(sessionID, callID);
|
|
20
|
+
const startedAt = this.startedAt.get(key);
|
|
21
|
+
this.startedAt.delete(key);
|
|
22
|
+
return startedAt;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setReadObservation(sessionID: string, callID: string, observation: ReadObservation): void {
|
|
26
|
+
this.readObservations.set(invocationKey(sessionID, callID), observation);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
takeReadObservation(sessionID: string, callID: string): ReadObservation | undefined {
|
|
30
|
+
const key = invocationKey(sessionID, callID);
|
|
31
|
+
const observation = this.readObservations.get(key);
|
|
32
|
+
this.readObservations.delete(key);
|
|
33
|
+
return observation;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setPostEditSnapshots(sessionID: string, callID: string, root: string, snapshots: FileSnapshot[]): void {
|
|
37
|
+
this.postEditSnapshots.set(invocationKey(sessionID, callID), { root, snapshots });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
takePostEditSnapshots(sessionID: string, callID: string): { root: string; snapshots: FileSnapshot[] } | undefined {
|
|
41
|
+
const key = invocationKey(sessionID, callID);
|
|
42
|
+
const snapshots = this.postEditSnapshots.get(key);
|
|
43
|
+
this.postEditSnapshots.delete(key);
|
|
44
|
+
return snapshots;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setRecoveryRun(sessionID: string, run: number): void {
|
|
48
|
+
this.recoveryRuns.set(sessionID, run);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
takeRecoveryRun(sessionID: string): number | undefined {
|
|
52
|
+
const run = this.recoveryRuns.get(sessionID);
|
|
53
|
+
this.recoveryRuns.delete(sessionID);
|
|
54
|
+
return run;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
clearSession(sessionID: string): void {
|
|
58
|
+
const prefix = `${sessionID}\0`;
|
|
59
|
+
for (const key of this.startedAt.keys()) {
|
|
60
|
+
if (key.startsWith(prefix)) this.startedAt.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface ToolOutcomePart {
|
|
4
|
+
type?: unknown;
|
|
5
|
+
sessionID?: unknown;
|
|
6
|
+
callID?: unknown;
|
|
7
|
+
tool?: unknown;
|
|
8
|
+
state?: {
|
|
9
|
+
status?: unknown;
|
|
10
|
+
error?: unknown;
|
|
11
|
+
time?: { start?: unknown; end?: unknown };
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ToolOutcome {
|
|
16
|
+
sessionID: string;
|
|
17
|
+
callID: string;
|
|
18
|
+
tool: string;
|
|
19
|
+
status: "completed" | "error";
|
|
20
|
+
durationMs?: number;
|
|
21
|
+
repeatedFailureCount?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ToolOutcomeTracker {
|
|
25
|
+
private readonly terminalCalls = new Map<string, Set<string>>();
|
|
26
|
+
private readonly failures = new Map<string, { signature: string; count: number }>();
|
|
27
|
+
private static readonly maxTerminalCallsPerSession = 1024;
|
|
28
|
+
|
|
29
|
+
record(part: ToolOutcomePart): ToolOutcome | undefined {
|
|
30
|
+
if (part.type !== "tool" || typeof part.sessionID !== "string" || typeof part.callID !== "string" || typeof part.tool !== "string") return undefined;
|
|
31
|
+
const status = part.state?.status;
|
|
32
|
+
if (status !== "completed" && status !== "error") return undefined;
|
|
33
|
+
let terminalCalls = this.terminalCalls.get(part.sessionID);
|
|
34
|
+
if (!terminalCalls) {
|
|
35
|
+
terminalCalls = new Set();
|
|
36
|
+
this.terminalCalls.set(part.sessionID, terminalCalls);
|
|
37
|
+
}
|
|
38
|
+
if (terminalCalls.has(part.callID)) return undefined;
|
|
39
|
+
terminalCalls.add(part.callID);
|
|
40
|
+
if (terminalCalls.size > ToolOutcomeTracker.maxTerminalCallsPerSession) {
|
|
41
|
+
terminalCalls.delete(terminalCalls.values().next().value!);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const start = part.state?.time?.start;
|
|
45
|
+
const end = part.state?.time?.end;
|
|
46
|
+
const durationMs = typeof start === "number" && typeof end === "number" ? Math.max(0, end - start) : undefined;
|
|
47
|
+
if (status === "completed") {
|
|
48
|
+
this.failures.delete(part.sessionID);
|
|
49
|
+
return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const error = typeof part.state?.error === "string" ? part.state.error : "";
|
|
53
|
+
const signature = createHash("sha256").update(`${part.tool}\0${error}`).digest("hex");
|
|
54
|
+
const previous = this.failures.get(part.sessionID);
|
|
55
|
+
const count = previous?.signature === signature ? previous.count + 1 : 1;
|
|
56
|
+
this.failures.set(part.sessionID, { signature, count });
|
|
57
|
+
return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs, repeatedFailureCount: count };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clearSession(sessionID: string): void {
|
|
61
|
+
this.failures.delete(sessionID);
|
|
62
|
+
this.terminalCalls.delete(sessionID);
|
|
63
|
+
}
|
|
64
|
+
}
|