opencode-workflow-guard 1.5.1 → 1.7.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 +15 -6
- package/bin/opencode-workflow-guard.mjs +59 -0
- package/package.json +7 -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 +478 -0
- package/src/lib/project-config.ts +68 -0
- package/src/lib/project-memory.ts +46 -3
- package/src/lib/shell.ts +399 -0
- package/src/lib/state.ts +23 -102
- 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 +3 -3
- 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 +171 -1118
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,
|
|
@@ -78,16 +79,16 @@ export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>()
|
|
|
78
79
|
export let lastReview: ReviewResult | undefined;
|
|
79
80
|
export const sessionReviews = new Map<string, ReviewResult>();
|
|
80
81
|
|
|
81
|
-
export function recordMutation(sessionID?: string): void {
|
|
82
|
+
export function recordMutation(sessionID?: string, actorSessionID?: string): void {
|
|
82
83
|
lastMutationTimestamp = Date.now();
|
|
83
84
|
mutationCount++;
|
|
84
|
-
|
|
85
|
-
sessionMutationTimestamps.set(
|
|
86
|
-
sessionMutationCounts.set(
|
|
87
|
-
sessionVerifyResults.delete(
|
|
85
|
+
for (const id of new Set([sessionID, actorSessionID].filter((value): value is string => Boolean(value)))) {
|
|
86
|
+
sessionMutationTimestamps.set(id, lastMutationTimestamp);
|
|
87
|
+
sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
|
|
88
|
+
sessionVerifyResults.delete(id);
|
|
89
|
+
sessionReviews.delete(id);
|
|
88
90
|
}
|
|
89
|
-
if (sessionID
|
|
90
|
-
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID) {
|
|
91
|
+
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
|
|
91
92
|
lastReview = undefined;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
@@ -99,6 +100,10 @@ export function getMutationCount(sessionID?: string): number {
|
|
|
99
100
|
return mutationCount;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
export function getSessionMutationCount(sessionID: string): number {
|
|
104
|
+
return sessionMutationCounts.get(sessionID) ?? 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
export function getLastMutationTimestamp(): number {
|
|
103
108
|
return lastMutationTimestamp;
|
|
104
109
|
}
|
|
@@ -124,6 +129,7 @@ export function recordVerifyResult(
|
|
|
124
129
|
workspaceRoot: resolve(root),
|
|
125
130
|
};
|
|
126
131
|
if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
|
|
132
|
+
persistVerifyHistory(lastVerify);
|
|
127
133
|
if (lastVerify.passed) {
|
|
128
134
|
persistVerifyCache(lastVerify);
|
|
129
135
|
}
|
|
@@ -173,99 +179,10 @@ export function resetReviewState(): void {
|
|
|
173
179
|
sessionReviews.clear();
|
|
174
180
|
}
|
|
175
181
|
|
|
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
182
|
export function getProjectConfig(root: string): ProjectConfig {
|
|
266
183
|
const active = runtimeState.getStore();
|
|
267
184
|
if (active?.workspaceRoot === root) return active.projectConfig;
|
|
268
|
-
return
|
|
185
|
+
return getCachedProjectConfig() ?? loadProjectConfig(root);
|
|
269
186
|
}
|
|
270
187
|
|
|
271
188
|
export function isReviewRequired(root: string): boolean {
|
|
@@ -289,11 +206,15 @@ export function getSubagentMutationBudget(root: string): number {
|
|
|
289
206
|
if (typeof cfg.maxSubagentMutations === "number" && cfg.maxSubagentMutations > 0) {
|
|
290
207
|
return cfg.maxSubagentMutations;
|
|
291
208
|
}
|
|
292
|
-
return
|
|
209
|
+
return 100;
|
|
293
210
|
}
|
|
294
211
|
|
|
295
212
|
export function isLearningEnabled(root: string): boolean {
|
|
296
|
-
return process.env.WORKFLOW_GUARD_LEARNING === "1";
|
|
213
|
+
return process.env.WORKFLOW_GUARD_LEARNING === "1" || getProjectConfig(root).learning === true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function isProjectMemoryEnabled(root: string): boolean {
|
|
217
|
+
return getProjectConfig(root).projectMemory !== false;
|
|
297
218
|
}
|
|
298
219
|
|
|
299
220
|
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
|
+
}
|