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/types.ts
CHANGED
|
@@ -23,8 +23,16 @@ export interface TodoSdkClient {
|
|
|
23
23
|
export interface ProjectConfig {
|
|
24
24
|
protectedBranches?: string[];
|
|
25
25
|
verifyCommand?: string;
|
|
26
|
+
postEditValidators?: Array<{
|
|
27
|
+
pattern: string;
|
|
28
|
+
command: string;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}>;
|
|
26
31
|
requireReview?: boolean;
|
|
27
32
|
requireDocumentation?: boolean;
|
|
33
|
+
recoveryCheckpoints?: boolean;
|
|
34
|
+
projectMemory?: boolean;
|
|
35
|
+
learning?: boolean;
|
|
28
36
|
maxSubagentMutations?: number;
|
|
29
37
|
maxLearningInterventions?: number;
|
|
30
38
|
}
|
|
@@ -55,8 +63,11 @@ export interface ReviewResult {
|
|
|
55
63
|
export interface AuditEntry {
|
|
56
64
|
ts: string;
|
|
57
65
|
sessionID?: string;
|
|
66
|
+
callID?: string;
|
|
58
67
|
tool: string;
|
|
59
68
|
decision: "allow" | "block";
|
|
69
|
+
phase?: "decision" | "outcome" | "event";
|
|
70
|
+
durationMs?: number;
|
|
60
71
|
reason?: string;
|
|
61
72
|
input?: unknown;
|
|
62
73
|
evidence?: {
|
|
@@ -140,3 +151,28 @@ export interface ProjectMemoryInput {
|
|
|
140
151
|
paths?: string[];
|
|
141
152
|
supersedes?: string;
|
|
142
153
|
}
|
|
154
|
+
|
|
155
|
+
export type ReviewFollowupSeverity = "P2" | "P3";
|
|
156
|
+
|
|
157
|
+
export interface ReviewFollowup {
|
|
158
|
+
id: string;
|
|
159
|
+
projectId: string;
|
|
160
|
+
severity: ReviewFollowupSeverity;
|
|
161
|
+
summary: string;
|
|
162
|
+
reviewer: string;
|
|
163
|
+
createdAt: number;
|
|
164
|
+
resolvedAt?: number;
|
|
165
|
+
sessionID?: string;
|
|
166
|
+
commit?: string;
|
|
167
|
+
paths: string[];
|
|
168
|
+
status: "open" | "resolved";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ReviewFollowupInput {
|
|
172
|
+
severity: ReviewFollowupSeverity;
|
|
173
|
+
summary: string;
|
|
174
|
+
reviewer: string;
|
|
175
|
+
sessionID?: string;
|
|
176
|
+
commit?: string;
|
|
177
|
+
paths?: string[];
|
|
178
|
+
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,393 +1,32 @@
|
|
|
1
1
|
import { getSdkClient } from "./state.ts";
|
|
2
2
|
|
|
3
3
|
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
4
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
5
|
-
return undefined;
|
|
6
|
-
}
|
|
4
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
7
5
|
return value as Record<string, unknown>;
|
|
8
6
|
}
|
|
9
7
|
|
|
10
|
-
export function
|
|
11
|
-
if (typeof input === "string") {
|
|
12
|
-
return [input];
|
|
13
|
-
}
|
|
8
|
+
export function extractRecordTargetPath(input: unknown): string | undefined {
|
|
14
9
|
const record = asRecord(input);
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const commands: string[] = [];
|
|
19
|
-
if (typeof record.command === "string") commands.push(record.command);
|
|
20
|
-
if (Array.isArray(record.commands)) {
|
|
21
|
-
for (const c of record.commands) {
|
|
22
|
-
if (typeof c === "string") commands.push(c);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return commands;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function normalize(cmd: string): string {
|
|
29
|
-
return cmd.replace(/\s+/g, " ");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function decodeShellEscapes(text: string): string {
|
|
33
|
-
return text
|
|
34
|
-
// ANSI-C quoting wrapper: $'...' -> ...
|
|
35
|
-
.replace(/\$'([^']*)'/g, "$1")
|
|
36
|
-
// Hex escapes: \xHH
|
|
37
|
-
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
38
|
-
// Unicode escapes: \uHHHH
|
|
39
|
-
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
40
|
-
// Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
|
|
41
|
-
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function shellWords(command: string): string[] {
|
|
45
|
-
const words: string[] = [];
|
|
46
|
-
let word = "";
|
|
47
|
-
let quote: "'" | '"' | undefined;
|
|
48
|
-
let escaped = false;
|
|
49
|
-
let started = false;
|
|
50
|
-
for (const char of command) {
|
|
51
|
-
if (escaped) {
|
|
52
|
-
word += char;
|
|
53
|
-
escaped = false;
|
|
54
|
-
started = true;
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
if (char === "\\" && quote !== "'") {
|
|
58
|
-
escaped = true;
|
|
59
|
-
started = true;
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
if (char === "'" || char === '"') {
|
|
63
|
-
if (!quote) quote = char;
|
|
64
|
-
else if (quote === char) quote = undefined;
|
|
65
|
-
else word += char;
|
|
66
|
-
started = true;
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
if (!quote && /\s/.test(char)) {
|
|
70
|
-
if (started) words.push(word);
|
|
71
|
-
word = "";
|
|
72
|
-
started = false;
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
word += char;
|
|
76
|
-
started = true;
|
|
77
|
-
}
|
|
78
|
-
if (escaped) word += "\\";
|
|
79
|
-
if (started) words.push(word);
|
|
80
|
-
return words;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function envSplitWords(value: string): string[] {
|
|
84
|
-
const words: string[] = [];
|
|
85
|
-
let word = "";
|
|
86
|
-
let quote: "'" | '"' | undefined;
|
|
87
|
-
let started = false;
|
|
88
|
-
const push = () => {
|
|
89
|
-
if (started) words.push(word);
|
|
90
|
-
word = "";
|
|
91
|
-
started = false;
|
|
92
|
-
};
|
|
93
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
94
|
-
const char = value[i]!;
|
|
95
|
-
if (char === "'" || char === '"') {
|
|
96
|
-
if (!quote) quote = char;
|
|
97
|
-
else if (quote === char) quote = undefined;
|
|
98
|
-
else word += char;
|
|
99
|
-
started = true;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (char === "\\" && i + 1 < value.length) {
|
|
103
|
-
const next = value[++i]!;
|
|
104
|
-
if (quote === "'" && next !== "'" && next !== "\\") {
|
|
105
|
-
word += `\\${next}`;
|
|
106
|
-
started = true;
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (next === "c" && !quote) break;
|
|
110
|
-
if (next === "_" && quote !== "'") {
|
|
111
|
-
if (quote === '"') {
|
|
112
|
-
word += " ";
|
|
113
|
-
started = true;
|
|
114
|
-
} else push();
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
const escapes: Record<string, string> = { f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "#": "#", "$": "$", '"': '"', "'": "'", "\\": "\\" };
|
|
118
|
-
word += escapes[next] ?? next;
|
|
119
|
-
started = true;
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (!quote && /[\s\f\v]/.test(char)) {
|
|
123
|
-
push();
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
if (!quote && char === "#" && !started) break;
|
|
127
|
-
if (char === "$" && quote !== "'" && value[i + 1] === "{") {
|
|
128
|
-
const end = value.indexOf("}", i + 2);
|
|
129
|
-
if (end >= 0) {
|
|
130
|
-
const name = value.slice(i + 2, end);
|
|
131
|
-
word += process.env[name] ?? "";
|
|
132
|
-
started = true;
|
|
133
|
-
i = end;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
word += char;
|
|
138
|
-
started = true;
|
|
139
|
-
}
|
|
140
|
-
push();
|
|
141
|
-
return words;
|
|
10
|
+
if (typeof record?.filePath === "string") return record.filePath;
|
|
11
|
+
if (typeof record?.path === "string") return record.path;
|
|
12
|
+
return undefined;
|
|
142
13
|
}
|
|
143
14
|
|
|
144
|
-
export function
|
|
145
|
-
|
|
146
|
-
let current = "";
|
|
147
|
-
let quote: "'" | '"' | "ansi" | undefined;
|
|
148
|
-
let escaped = false;
|
|
149
|
-
for (let i = 0; i < command.length; i += 1) {
|
|
150
|
-
const char = command[i]!;
|
|
151
|
-
if (escaped) {
|
|
152
|
-
current += char;
|
|
153
|
-
escaped = false;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
157
|
-
current += "$'";
|
|
158
|
-
quote = "ansi";
|
|
159
|
-
i += 1;
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (char === "\\" && quote !== "'") {
|
|
163
|
-
current += char;
|
|
164
|
-
escaped = true;
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (quote === "ansi" && char === "'") {
|
|
168
|
-
quote = undefined;
|
|
169
|
-
current += char;
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (char === "'" || char === '"') {
|
|
173
|
-
if (!quote) quote = char;
|
|
174
|
-
else if (quote === char) quote = undefined;
|
|
175
|
-
current += char;
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
if (!quote && /[\n|;&]/.test(char)) {
|
|
179
|
-
if (current.trim()) segments.push(current.trim());
|
|
180
|
-
current = "";
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
current += char;
|
|
184
|
-
}
|
|
185
|
-
if (current.trim()) segments.push(current.trim());
|
|
186
|
-
return segments;
|
|
15
|
+
export function extractTargetPath(input: unknown): string | undefined {
|
|
16
|
+
return extractRecordTargetPath(input) ?? (typeof input === "string" ? input : undefined);
|
|
187
17
|
}
|
|
188
18
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
|
|
192
|
-
while (
|
|
193
|
-
(cleanCmd.startsWith("(") && cleanCmd.endsWith(")")) ||
|
|
194
|
-
(cleanCmd.startsWith("{") && cleanCmd.endsWith("}"))
|
|
195
|
-
) {
|
|
196
|
-
cleanCmd = cleanCmd.slice(1, -1).trim();
|
|
197
|
-
if (cleanCmd.endsWith(";")) cleanCmd = cleanCmd.slice(0, -1).trim();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const words = shellWords(cleanCmd);
|
|
201
|
-
let i = 0;
|
|
202
|
-
let changesCwd = false;
|
|
203
|
-
while (i < words.length) {
|
|
204
|
-
const rawWord = words[i]!;
|
|
205
|
-
const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
|
|
206
|
-
if (!word) {
|
|
207
|
-
i++;
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
|
|
212
|
-
i++;
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
if (word === "nice") {
|
|
216
|
-
i++;
|
|
217
|
-
if (i < words.length && (words[i] === "-n" || words[i]?.startsWith("-n"))) {
|
|
218
|
-
if (words[i] === "-n") i += 2;
|
|
219
|
-
else i++;
|
|
220
|
-
}
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (word === "timeout") {
|
|
224
|
-
i++;
|
|
225
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
226
|
-
const opt = words[i++]!;
|
|
227
|
-
if ((opt === "-k" || opt === "-s" || opt === "--signal" || opt === "--kill-after") && i < words.length) {
|
|
228
|
-
i++;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (i < words.length && /^\d+[smhd]?$/.test(words[i]!)) {
|
|
232
|
-
i++;
|
|
233
|
-
}
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
if (word === "stdbuf") {
|
|
237
|
-
i++;
|
|
238
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
239
|
-
const opt = words[i++]!;
|
|
240
|
-
if ((opt === "-i" || opt === "-o" || opt === "-e") && i < words.length) {
|
|
241
|
-
i++;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
if (word === "time") {
|
|
247
|
-
i++;
|
|
248
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
249
|
-
i++;
|
|
250
|
-
}
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (word === "sudo" || word === "doas") {
|
|
254
|
-
i++;
|
|
255
|
-
const valueOptions = new Set([
|
|
256
|
-
"-u",
|
|
257
|
-
"--user",
|
|
258
|
-
"-g",
|
|
259
|
-
"--group",
|
|
260
|
-
"-h",
|
|
261
|
-
"--host",
|
|
262
|
-
"-p",
|
|
263
|
-
"--prompt",
|
|
264
|
-
"-C",
|
|
265
|
-
"--close-from",
|
|
266
|
-
"-R",
|
|
267
|
-
"--chroot",
|
|
268
|
-
"-D",
|
|
269
|
-
"--chdir",
|
|
270
|
-
]);
|
|
271
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
272
|
-
const option = words[i++]!;
|
|
273
|
-
if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) changesCwd = true;
|
|
274
|
-
if (valueOptions.has(option) && i < words.length) i++;
|
|
275
|
-
}
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
if (word === "env") {
|
|
279
|
-
i++;
|
|
280
|
-
const valueOptions = new Set([
|
|
281
|
-
"-u",
|
|
282
|
-
"--unset",
|
|
283
|
-
"--argv0",
|
|
284
|
-
"-C",
|
|
285
|
-
"--chdir",
|
|
286
|
-
"-S",
|
|
287
|
-
"--split-string",
|
|
288
|
-
]);
|
|
289
|
-
while (i < words.length) {
|
|
290
|
-
const w = words[i]!;
|
|
291
|
-
if (w === "--") {
|
|
292
|
-
i++;
|
|
293
|
-
break;
|
|
294
|
-
}
|
|
295
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
296
|
-
i++;
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
let splitValue: string | undefined;
|
|
300
|
-
let consumed = 0;
|
|
301
|
-
const clustered = /^-[iv0]*([SC])(.*)$/.exec(w);
|
|
302
|
-
if (clustered?.[1] === "C") {
|
|
303
|
-
changesCwd = true;
|
|
304
|
-
i++;
|
|
305
|
-
if (!clustered[2] && i < words.length) i++;
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (clustered?.[1] === "S") {
|
|
309
|
-
if (clustered[2]) splitValue = clustered[2];
|
|
310
|
-
else if (i + 1 < words.length) {
|
|
311
|
-
splitValue = words[i + 1]!;
|
|
312
|
-
consumed = 1;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
else if (w.startsWith("-S") && w !== "-S") splitValue = w.slice(2);
|
|
316
|
-
else if (w.startsWith("--split-string=")) splitValue = w.slice("--split-string=".length);
|
|
317
|
-
else if ((w === "-S" || w === "--split-string") && i + 1 < words.length) {
|
|
318
|
-
splitValue = words[i + 1]!;
|
|
319
|
-
consumed = 1;
|
|
320
|
-
}
|
|
321
|
-
if (splitValue !== undefined) {
|
|
322
|
-
words.splice(i, consumed + 1, ...envSplitWords(splitValue));
|
|
323
|
-
continue;
|
|
324
|
-
}
|
|
325
|
-
const clusteredValue = /^-[iv0]*([ua])(.*)$/.exec(w);
|
|
326
|
-
if (clusteredValue) {
|
|
327
|
-
i++;
|
|
328
|
-
if (!clusteredValue[2] && i < words.length) i++;
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
if (w.startsWith("-C") && w !== "-C") {
|
|
332
|
-
changesCwd = true;
|
|
333
|
-
i++;
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
if (!w.startsWith("-")) break;
|
|
337
|
-
i++;
|
|
338
|
-
if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) changesCwd = true;
|
|
339
|
-
if (valueOptions.has(w) && i < words.length) i++;
|
|
340
|
-
}
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
344
|
-
i++;
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
break;
|
|
348
|
-
}
|
|
349
|
-
return { words: words
|
|
350
|
-
.slice(i)
|
|
351
|
-
.map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
|
|
352
|
-
.filter(Boolean), changesCwd };
|
|
353
|
-
}
|
|
19
|
+
export const SENSITIVE_ENV_KEYS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_KEY", "KUBECONFIG", "NPM_TOKEN", "DOCKER_AUTH", "GOOGLE_APPLICATION_CREDENTIALS", "GCLOUD_AUTH", "AZURE_CREDENTIALS", "SLACK_TOKEN"];
|
|
20
|
+
export const SENSITIVE_ENV_RE = /^(AWS_|KUBE|OPENAI|ANTHROPIC|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
|
|
354
21
|
|
|
355
|
-
export function
|
|
356
|
-
return
|
|
22
|
+
export function isSensitiveEnvKey(key: string): boolean {
|
|
23
|
+
return SENSITIVE_ENV_KEYS.includes(key) || SENSITIVE_ENV_RE.test(key);
|
|
357
24
|
}
|
|
358
25
|
|
|
359
|
-
export function shellWrappersChangeCwd(command: string): boolean {
|
|
360
|
-
return unwrapShellWordsImpl(command).changesCwd;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export function unwrapShellCommand(command: string): string {
|
|
364
|
-
return unwrapShellWords(command).join(" ");
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export const SENSITIVE_ENV_KEYS = [
|
|
368
|
-
"OPENAI_API_KEY",
|
|
369
|
-
"ANTHROPIC_API_KEY",
|
|
370
|
-
"OPENAI_KEY",
|
|
371
|
-
"KUBECONFIG",
|
|
372
|
-
"NPM_TOKEN",
|
|
373
|
-
"DOCKER_AUTH",
|
|
374
|
-
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
375
|
-
"GCLOUD_AUTH",
|
|
376
|
-
"AZURE_CREDENTIALS",
|
|
377
|
-
"SLACK_TOKEN",
|
|
378
|
-
];
|
|
379
|
-
export const SENSITIVE_ENV_RE =
|
|
380
|
-
/^(AWS_|KUBE|OPENAI|ANTHROPIC|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
|
|
381
|
-
|
|
382
26
|
export function getCleanEnv(): Record<string, string> {
|
|
383
27
|
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
384
|
-
for (const key of SENSITIVE_ENV_KEYS) {
|
|
385
|
-
delete env[key];
|
|
386
|
-
}
|
|
387
28
|
for (const key of Object.keys(env)) {
|
|
388
|
-
if (
|
|
389
|
-
delete env[key];
|
|
390
|
-
}
|
|
29
|
+
if (isSensitiveEnvKey(key)) delete env[key];
|
|
391
30
|
}
|
|
392
31
|
return env;
|
|
393
32
|
}
|
|
@@ -395,12 +34,6 @@ export function getCleanEnv(): Record<string, string> {
|
|
|
395
34
|
export async function showBlockToast(message: string): Promise<void> {
|
|
396
35
|
try {
|
|
397
36
|
const client = getSdkClient();
|
|
398
|
-
await client?.tui?.showToast?.({
|
|
399
|
-
body: {
|
|
400
|
-
title: "Workflow Guard Blocked",
|
|
401
|
-
message: message.slice(0, 180),
|
|
402
|
-
variant: "warning",
|
|
403
|
-
},
|
|
404
|
-
});
|
|
37
|
+
await client?.tui?.showToast?.({ body: { title: "Workflow Guard Blocked", message: message.slice(0, 180), variant: "warning" } });
|
|
405
38
|
} catch {}
|
|
406
39
|
}
|
package/src/lib/verify.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { readFileSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
|
|
6
|
+
import { getCleanEnv } from "./utils.ts";
|
|
6
7
|
import { getProjectConfig } from "./state.ts";
|
|
7
8
|
import { normalizeGitCommands } from "../policies/git.ts";
|
|
8
9
|
import { liveMutationIn } from "../policies/destructive.ts";
|
|
@@ -163,6 +164,14 @@ export async function runVerify(
|
|
|
163
164
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
164
165
|
|
|
165
166
|
if (!allowLive) {
|
|
167
|
+
const dynamicSyntax = dynamicShellSyntaxIn(command);
|
|
168
|
+
if (dynamicSyntax) {
|
|
169
|
+
return {
|
|
170
|
+
passed: false,
|
|
171
|
+
output: `Verification command blocked: contains ${dynamicSyntax}`,
|
|
172
|
+
durationMs: 0,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
166
175
|
const normalized = normalizeGitCommands(normalize(command));
|
|
167
176
|
const liveCheck = liveMutationIn(normalized);
|
|
168
177
|
if (liveCheck) {
|
|
@@ -184,11 +193,13 @@ export async function runVerify(
|
|
|
184
193
|
return new Promise((resolve) => {
|
|
185
194
|
let output = "";
|
|
186
195
|
let timer: NodeJS.Timeout | undefined;
|
|
196
|
+
let timedOut = false;
|
|
187
197
|
let child: ReturnType<typeof spawn>;
|
|
188
198
|
try {
|
|
189
199
|
child = spawn(command, {
|
|
190
200
|
cwd: root,
|
|
191
201
|
shell: true,
|
|
202
|
+
detached: process.platform !== "win32",
|
|
192
203
|
env: getCleanEnv(),
|
|
193
204
|
stdio: ["ignore", "pipe", "pipe"],
|
|
194
205
|
});
|
|
@@ -201,13 +212,13 @@ export async function runVerify(
|
|
|
201
212
|
}
|
|
202
213
|
|
|
203
214
|
timer = setTimeout(() => {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
215
|
+
timedOut = true;
|
|
216
|
+
void terminateProcessTree(child.pid, () => child.kill("SIGKILL")).finally(() => {
|
|
217
|
+
resolve({
|
|
218
|
+
passed: false,
|
|
219
|
+
output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
|
|
220
|
+
durationMs: Date.now() - start,
|
|
221
|
+
});
|
|
211
222
|
});
|
|
212
223
|
}, timeoutMs);
|
|
213
224
|
|
|
@@ -221,11 +232,61 @@ export async function runVerify(
|
|
|
221
232
|
});
|
|
222
233
|
child.on("close", (code) => {
|
|
223
234
|
if (timer) clearTimeout(timer);
|
|
235
|
+
if (timedOut) return;
|
|
224
236
|
resolve({ passed: code === 0, output, durationMs: Date.now() - start });
|
|
225
237
|
});
|
|
226
238
|
child.on("error", (err) => {
|
|
227
239
|
if (timer) clearTimeout(timer);
|
|
240
|
+
if (timedOut) return;
|
|
228
241
|
resolve({ passed: false, output: `(spawn failed: ${err.message})`, durationMs: Date.now() - start });
|
|
229
242
|
});
|
|
230
243
|
});
|
|
231
244
|
}
|
|
245
|
+
|
|
246
|
+
type WindowsTreeKiller = (pid: number) => Promise<void>;
|
|
247
|
+
|
|
248
|
+
function killWindowsProcessTree(pid: number): Promise<void> {
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
let settled = false;
|
|
251
|
+
let killer: ReturnType<typeof spawn> | undefined;
|
|
252
|
+
const finish = (err?: Error) => {
|
|
253
|
+
if (!settled) {
|
|
254
|
+
settled = true;
|
|
255
|
+
clearTimeout(cleanupTimer);
|
|
256
|
+
err ? reject(err) : resolve();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const cleanupTimer = setTimeout(() => {
|
|
260
|
+
try { killer?.kill("SIGKILL"); } catch {}
|
|
261
|
+
finish(new Error("taskkill timed out"));
|
|
262
|
+
}, 5_000);
|
|
263
|
+
try {
|
|
264
|
+
killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore" });
|
|
265
|
+
killer.once("close", (code) => finish(code === 0 ? undefined : new Error(`taskkill exited ${code}`)));
|
|
266
|
+
killer.once("error", (err) => finish(err));
|
|
267
|
+
} catch (err) {
|
|
268
|
+
finish(err instanceof Error ? err : new Error("taskkill failed"));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function terminateProcessTree(
|
|
274
|
+
pid: number | undefined,
|
|
275
|
+
fallbackKill: () => void,
|
|
276
|
+
platform: NodeJS.Platform = process.platform,
|
|
277
|
+
windowsTreeKiller: WindowsTreeKiller = killWindowsProcessTree,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
try {
|
|
280
|
+
if (platform === "win32" && pid) {
|
|
281
|
+
await windowsTreeKiller(pid);
|
|
282
|
+
} else if (pid) {
|
|
283
|
+
process.kill(-pid, "SIGKILL");
|
|
284
|
+
} else {
|
|
285
|
+
fallbackKill();
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
try {
|
|
289
|
+
fallbackKill();
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getWorkspaceRootReal,
|
|
8
8
|
recordMutation,
|
|
9
9
|
} from "../lib/state.ts";
|
|
10
|
-
import { shellWords, unwrapShellCommand } from "../lib/
|
|
10
|
+
import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
11
11
|
import { isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
12
12
|
import { isSecretPath, secretIn } from "./secrets.ts";
|
|
13
13
|
import { onProtectedBranch, branchGuardReason } from "./git.ts";
|
|
@@ -91,7 +91,7 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
|
|
|
91
91
|
export function extractPatchPaths(patchText: string): string[] {
|
|
92
92
|
const paths: string[] = [];
|
|
93
93
|
const markerRe =
|
|
94
|
-
/^\*\*\*\s+(?:Add File|Update File|Delete File|Move to|Move from):\s*(\
|
|
94
|
+
/^\*\*\*\s+(?:Add File|Update File|Delete File|Move to|Move from):\s*(.+?)\s*$/gm;
|
|
95
95
|
let match: RegExpExecArray | null;
|
|
96
96
|
while ((match = markerRe.exec(patchText)) !== null) {
|
|
97
97
|
if (match[1]) paths.push(match[1]);
|
|
@@ -2,9 +2,9 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, resolve } from "node:path";
|
|
4
4
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
5
|
-
import { splitShellSegments, unwrapShellCommand, unwrapShellWords } from "../lib/
|
|
5
|
+
import { splitShellSegments, unwrapShellCommand, unwrapShellWords } from "../lib/shell.ts";
|
|
6
6
|
|
|
7
|
-
export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(
|
|
7
|
+
export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?(?:changelog|changes|release notes?|summary)\s*(?::\s*\S.+|:?\s*\r?\n\s*(?![\\#])\S.+)/im;
|
|
8
8
|
|
|
9
9
|
export function hasPrCreateInvocation(command: string): boolean {
|
|
10
10
|
return splitShellSegments(command)
|
|
@@ -74,6 +74,11 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
|
|
|
74
74
|
state.counts.delete(sessionID);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function isGeneratedContinuationMessage(sessionID: string, messageID?: string): boolean {
|
|
78
|
+
if (!messageID) return false;
|
|
79
|
+
return getState()?.generatedMessageIDs.get(sessionID)?.has(messageID) === true;
|
|
80
|
+
}
|
|
81
|
+
|
|
77
82
|
export function clearContinuationState(sessionID: string): void {
|
|
78
83
|
const state = getState();
|
|
79
84
|
if (!state) return;
|