opencode-workflow-guard 1.5.0 → 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 +8 -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 +40 -0
- package/src/lib/utils.ts +13 -256
- package/src/lib/verify.ts +69 -8
- package/src/policies/boundary.ts +2 -2
- package/src/policies/changelog.ts +115 -16
- package/src/policies/continuation.ts +88 -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/planning.ts +50 -0
- 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 +33 -17
- package/src/workflow-guard-ui.ts +75 -0
- package/src/workflow-guard.ts +192 -1086
package/src/lib/types.ts
CHANGED
|
@@ -7,6 +7,10 @@ export interface TodoSdkClient {
|
|
|
7
7
|
session?: {
|
|
8
8
|
todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
|
|
9
9
|
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
|
|
10
|
+
promptAsync?: (opts: {
|
|
11
|
+
path: { id: string };
|
|
12
|
+
body: { messageID?: string; parts: Array<{ type: "text"; text: string }> };
|
|
13
|
+
}) => Promise<unknown>;
|
|
10
14
|
};
|
|
11
15
|
tui?: {
|
|
12
16
|
showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
|
|
@@ -19,8 +23,16 @@ export interface TodoSdkClient {
|
|
|
19
23
|
export interface ProjectConfig {
|
|
20
24
|
protectedBranches?: string[];
|
|
21
25
|
verifyCommand?: string;
|
|
26
|
+
postEditValidators?: Array<{
|
|
27
|
+
pattern: string;
|
|
28
|
+
command: string;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}>;
|
|
22
31
|
requireReview?: boolean;
|
|
23
32
|
requireDocumentation?: boolean;
|
|
33
|
+
recoveryCheckpoints?: boolean;
|
|
34
|
+
projectMemory?: boolean;
|
|
35
|
+
learning?: boolean;
|
|
24
36
|
maxSubagentMutations?: number;
|
|
25
37
|
maxLearningInterventions?: number;
|
|
26
38
|
}
|
|
@@ -51,8 +63,11 @@ export interface ReviewResult {
|
|
|
51
63
|
export interface AuditEntry {
|
|
52
64
|
ts: string;
|
|
53
65
|
sessionID?: string;
|
|
66
|
+
callID?: string;
|
|
54
67
|
tool: string;
|
|
55
68
|
decision: "allow" | "block";
|
|
69
|
+
phase?: "decision" | "outcome" | "event";
|
|
70
|
+
durationMs?: number;
|
|
56
71
|
reason?: string;
|
|
57
72
|
input?: unknown;
|
|
58
73
|
evidence?: {
|
|
@@ -136,3 +151,28 @@ export interface ProjectMemoryInput {
|
|
|
136
151
|
paths?: string[];
|
|
137
152
|
supersedes?: string;
|
|
138
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,269 +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, " ");
|
|
10
|
+
if (typeof record?.filePath === "string") return record.filePath;
|
|
11
|
+
if (typeof record?.path === "string") return record.path;
|
|
12
|
+
return undefined;
|
|
30
13
|
}
|
|
31
14
|
|
|
32
|
-
export function
|
|
33
|
-
return
|
|
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
|
-
// Single character escapes: \c -> c (e.g. \r\m -> rm)
|
|
43
|
-
.replace(/\\(.)/g, "$1");
|
|
15
|
+
export function extractTargetPath(input: unknown): string | undefined {
|
|
16
|
+
return extractRecordTargetPath(input) ?? (typeof input === "string" ? input : undefined);
|
|
44
17
|
}
|
|
45
18
|
|
|
46
|
-
export
|
|
47
|
-
|
|
48
|
-
let word = "";
|
|
49
|
-
let quote: "'" | '"' | undefined;
|
|
50
|
-
let escaped = false;
|
|
51
|
-
let started = false;
|
|
52
|
-
for (const char of command) {
|
|
53
|
-
if (escaped) {
|
|
54
|
-
word += char;
|
|
55
|
-
escaped = false;
|
|
56
|
-
started = true;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (char === "\\" && quote !== "'") {
|
|
60
|
-
escaped = true;
|
|
61
|
-
started = true;
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
if (char === "'" || char === '"') {
|
|
65
|
-
if (!quote) quote = char;
|
|
66
|
-
else if (quote === char) quote = undefined;
|
|
67
|
-
else word += char;
|
|
68
|
-
started = true;
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
if (!quote && /\s/.test(char)) {
|
|
72
|
-
if (started) words.push(word);
|
|
73
|
-
word = "";
|
|
74
|
-
started = false;
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
word += char;
|
|
78
|
-
started = true;
|
|
79
|
-
}
|
|
80
|
-
if (escaped) word += "\\";
|
|
81
|
-
if (started) words.push(word);
|
|
82
|
-
return words;
|
|
83
|
-
}
|
|
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)/;
|
|
84
21
|
|
|
85
|
-
export function
|
|
86
|
-
|
|
87
|
-
let current = "";
|
|
88
|
-
let quote: "'" | '"' | undefined;
|
|
89
|
-
let escaped = false;
|
|
90
|
-
for (const char of command) {
|
|
91
|
-
if (escaped) {
|
|
92
|
-
current += char;
|
|
93
|
-
escaped = false;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (char === "\\" && quote !== "'") {
|
|
97
|
-
current += char;
|
|
98
|
-
escaped = true;
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (char === "'" || char === '"') {
|
|
102
|
-
if (!quote) quote = char;
|
|
103
|
-
else if (quote === char) quote = undefined;
|
|
104
|
-
current += char;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (!quote && /[\n|;&]/.test(char)) {
|
|
108
|
-
if (current.trim()) segments.push(current.trim());
|
|
109
|
-
current = "";
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
current += char;
|
|
113
|
-
}
|
|
114
|
-
if (current.trim()) segments.push(current.trim());
|
|
115
|
-
return segments;
|
|
22
|
+
export function isSensitiveEnvKey(key: string): boolean {
|
|
23
|
+
return SENSITIVE_ENV_KEYS.includes(key) || SENSITIVE_ENV_RE.test(key);
|
|
116
24
|
}
|
|
117
25
|
|
|
118
|
-
export function unwrapShellWords(command: string): string[] {
|
|
119
|
-
let cleanCmd = decodeShellEscapes(command.trim());
|
|
120
|
-
// Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
|
|
121
|
-
while (
|
|
122
|
-
(cleanCmd.startsWith("(") && cleanCmd.endsWith(")")) ||
|
|
123
|
-
(cleanCmd.startsWith("{") && cleanCmd.endsWith("}"))
|
|
124
|
-
) {
|
|
125
|
-
cleanCmd = cleanCmd.slice(1, -1).trim();
|
|
126
|
-
if (cleanCmd.endsWith(";")) cleanCmd = cleanCmd.slice(0, -1).trim();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const words = shellWords(cleanCmd);
|
|
130
|
-
let i = 0;
|
|
131
|
-
while (i < words.length) {
|
|
132
|
-
const rawWord = words[i]!;
|
|
133
|
-
const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
|
|
134
|
-
if (!word) {
|
|
135
|
-
i++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
|
|
140
|
-
i++;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
if (word === "nice") {
|
|
144
|
-
i++;
|
|
145
|
-
if (i < words.length && (words[i] === "-n" || words[i]?.startsWith("-n"))) {
|
|
146
|
-
if (words[i] === "-n") i += 2;
|
|
147
|
-
else i++;
|
|
148
|
-
}
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (word === "timeout") {
|
|
152
|
-
i++;
|
|
153
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
154
|
-
const opt = words[i++]!;
|
|
155
|
-
if ((opt === "-k" || opt === "-s" || opt === "--signal" || opt === "--kill-after") && i < words.length) {
|
|
156
|
-
i++;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (i < words.length && /^\d+[smhd]?$/.test(words[i]!)) {
|
|
160
|
-
i++;
|
|
161
|
-
}
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
if (word === "stdbuf") {
|
|
165
|
-
i++;
|
|
166
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
167
|
-
const opt = words[i++]!;
|
|
168
|
-
if ((opt === "-i" || opt === "-o" || opt === "-e") && i < words.length) {
|
|
169
|
-
i++;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
if (word === "time") {
|
|
175
|
-
i++;
|
|
176
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
177
|
-
i++;
|
|
178
|
-
}
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
if (word === "sudo" || word === "doas") {
|
|
182
|
-
i++;
|
|
183
|
-
const valueOptions = new Set([
|
|
184
|
-
"-u",
|
|
185
|
-
"--user",
|
|
186
|
-
"-g",
|
|
187
|
-
"--group",
|
|
188
|
-
"-h",
|
|
189
|
-
"--host",
|
|
190
|
-
"-p",
|
|
191
|
-
"--prompt",
|
|
192
|
-
"-C",
|
|
193
|
-
"--close-from",
|
|
194
|
-
"-R",
|
|
195
|
-
"--chroot",
|
|
196
|
-
"-D",
|
|
197
|
-
"--chdir",
|
|
198
|
-
]);
|
|
199
|
-
while (i < words.length && words[i]!.startsWith("-")) {
|
|
200
|
-
const option = words[i++]!;
|
|
201
|
-
if (valueOptions.has(option) && i < words.length) i++;
|
|
202
|
-
}
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
if (word === "env") {
|
|
206
|
-
i++;
|
|
207
|
-
const valueOptions = new Set([
|
|
208
|
-
"-u",
|
|
209
|
-
"--unset",
|
|
210
|
-
"-C",
|
|
211
|
-
"--chdir",
|
|
212
|
-
"-S",
|
|
213
|
-
"--split-string",
|
|
214
|
-
]);
|
|
215
|
-
while (i < words.length) {
|
|
216
|
-
const w = words[i]!;
|
|
217
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
218
|
-
i++;
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
if (!w.startsWith("-")) break;
|
|
222
|
-
i++;
|
|
223
|
-
if (valueOptions.has(w) && i < words.length) i++;
|
|
224
|
-
}
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
228
|
-
i++;
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
return words
|
|
234
|
-
.slice(i)
|
|
235
|
-
.map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
|
|
236
|
-
.filter(Boolean);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function unwrapShellCommand(command: string): string {
|
|
240
|
-
return unwrapShellWords(command).join(" ");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export const SENSITIVE_ENV_KEYS = [
|
|
244
|
-
"OPENAI_API_KEY",
|
|
245
|
-
"ANTHROPIC_API_KEY",
|
|
246
|
-
"OPENAI_KEY",
|
|
247
|
-
"KUBECONFIG",
|
|
248
|
-
"NPM_TOKEN",
|
|
249
|
-
"DOCKER_AUTH",
|
|
250
|
-
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
251
|
-
"GCLOUD_AUTH",
|
|
252
|
-
"AZURE_CREDENTIALS",
|
|
253
|
-
"SLACK_TOKEN",
|
|
254
|
-
];
|
|
255
|
-
export const SENSITIVE_ENV_RE =
|
|
256
|
-
/^(AWS_|KUBE|OPENAI|ANTHROPIC|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
|
|
257
|
-
|
|
258
26
|
export function getCleanEnv(): Record<string, string> {
|
|
259
27
|
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
260
|
-
for (const key of SENSITIVE_ENV_KEYS) {
|
|
261
|
-
delete env[key];
|
|
262
|
-
}
|
|
263
28
|
for (const key of Object.keys(env)) {
|
|
264
|
-
if (
|
|
265
|
-
delete env[key];
|
|
266
|
-
}
|
|
29
|
+
if (isSensitiveEnvKey(key)) delete env[key];
|
|
267
30
|
}
|
|
268
31
|
return env;
|
|
269
32
|
}
|
|
@@ -271,12 +34,6 @@ export function getCleanEnv(): Record<string, string> {
|
|
|
271
34
|
export async function showBlockToast(message: string): Promise<void> {
|
|
272
35
|
try {
|
|
273
36
|
const client = getSdkClient();
|
|
274
|
-
await client?.tui?.showToast?.({
|
|
275
|
-
body: {
|
|
276
|
-
title: "Workflow Guard Blocked",
|
|
277
|
-
message: message.slice(0, 180),
|
|
278
|
-
variant: "warning",
|
|
279
|
-
},
|
|
280
|
-
});
|
|
37
|
+
await client?.tui?.showToast?.({ body: { title: "Workflow Guard Blocked", message: message.slice(0, 180), variant: "warning" } });
|
|
281
38
|
} catch {}
|
|
282
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]);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
4
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
5
|
-
import { splitShellSegments, unwrapShellCommand } 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)
|
|
@@ -20,6 +20,84 @@ export function hasPrCreateInvocation(command: string): boolean {
|
|
|
20
20
|
// Accepts either a CHANGELOG file modification or a .changeset/*.md file.
|
|
21
21
|
const CHANGELOG_OR_CHANGESET_RE = /(?:changelog|\.changeset\/[^/]+\.md$)/i;
|
|
22
22
|
|
|
23
|
+
function decodeAnsiCLineBreaks(value: string): string {
|
|
24
|
+
let decoded = "";
|
|
25
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
26
|
+
if (value[i] !== "\\" || i + 1 >= value.length) {
|
|
27
|
+
decoded += value[i];
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const next = value[++i];
|
|
31
|
+
if (next === "n") decoded += "\n";
|
|
32
|
+
else if (next === "r") decoded += "\r";
|
|
33
|
+
else if (next === "\\") decoded += "\\";
|
|
34
|
+
else decoded += `\\${next}`;
|
|
35
|
+
}
|
|
36
|
+
return decoded;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function preserveAnsiCLineBreaks(command: string): string {
|
|
40
|
+
return command.replace(/\$'((?:\\.|[^'])*)'/g, (_match, value: string) => {
|
|
41
|
+
const decoded = decodeAnsiCLineBreaks(value).replace(/'/g, "'\\''");
|
|
42
|
+
return `'${decoded}'`;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function prShellWords(command: string): string[] {
|
|
47
|
+
const words: string[] = [];
|
|
48
|
+
let word = "";
|
|
49
|
+
let quote: "'" | '"' | "ansi" | undefined;
|
|
50
|
+
let started = false;
|
|
51
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
52
|
+
const char = command[i]!;
|
|
53
|
+
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
54
|
+
quote = "ansi";
|
|
55
|
+
started = true;
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (quote === "ansi") {
|
|
60
|
+
if (char === "'") {
|
|
61
|
+
quote = undefined;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "\\" && i + 1 < command.length) {
|
|
65
|
+
let escape = char + command[++i];
|
|
66
|
+
word += decodeAnsiCLineBreaks(escape);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
word += char;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === "'" || char === '"') {
|
|
73
|
+
if (!quote) quote = char;
|
|
74
|
+
else if (quote === char) quote = undefined;
|
|
75
|
+
else word += char;
|
|
76
|
+
started = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (char === "\\" && quote !== "'") {
|
|
80
|
+
const next = command[++i];
|
|
81
|
+
if (next !== "\n") {
|
|
82
|
+
if (quote === '"' && next && !['$', '`', '"', '\\'].includes(next)) word += "\\";
|
|
83
|
+
word += next ?? "\\";
|
|
84
|
+
}
|
|
85
|
+
started = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!quote && /\s/.test(char)) {
|
|
89
|
+
if (started) words.push(word);
|
|
90
|
+
word = "";
|
|
91
|
+
started = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
word += char;
|
|
95
|
+
started = true;
|
|
96
|
+
}
|
|
97
|
+
if (started) words.push(word);
|
|
98
|
+
return words;
|
|
99
|
+
}
|
|
100
|
+
|
|
23
101
|
export function branchHasChangelogChange(root: string): boolean {
|
|
24
102
|
try {
|
|
25
103
|
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master"];
|
|
@@ -54,23 +132,44 @@ export function branchHasChangelogChange(root: string): boolean {
|
|
|
54
132
|
}
|
|
55
133
|
}
|
|
56
134
|
|
|
57
|
-
export function prBodyIncludesChangelog(command: string): boolean {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
135
|
+
export function prBodyIncludesChangelog(command: string, invocationRoot: string | null = getWorkspaceRoot()): boolean {
|
|
136
|
+
const parsedWords = prShellWords(command);
|
|
137
|
+
const envIndex = parsedWords.indexOf("env");
|
|
138
|
+
const usesEnvSplitString = envIndex >= 0 && parsedWords.slice(envIndex + 1).some((word) =>
|
|
139
|
+
word === "-S" || word.startsWith("-S") || word === "--split-string" || word.startsWith("--split-string="));
|
|
140
|
+
const commandWords = usesEnvSplitString
|
|
141
|
+
? unwrapShellWords(preserveAnsiCLineBreaks(command))
|
|
142
|
+
: parsedWords;
|
|
143
|
+
const cliIndex = commandWords.findIndex((word) => word === "gh" || word === "az");
|
|
144
|
+
const words = cliIndex >= 0 ? commandWords.slice(cliIndex) : [];
|
|
145
|
+
let body = "";
|
|
146
|
+
let bodyFile: string | undefined;
|
|
147
|
+
const valueOptions = new Set([
|
|
148
|
+
"--assignee", "--base", "--head", "--head-repo", "--label", "--milestone",
|
|
149
|
+
"--project", "--recover", "--reviewer", "--template", "--title",
|
|
150
|
+
"--bypass-policy-reason", "--labels", "--merge-commit-message", "--org",
|
|
151
|
+
"--repository", "--required-reviewer-ids", "--reviewer-ids", "--source-branch",
|
|
152
|
+
"--target-branch", "--work-items", "-a", "-B", "-H", "-l", "-m", "-p", "-r", "-T", "-t",
|
|
153
|
+
]);
|
|
154
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
155
|
+
const word = words[i]!;
|
|
156
|
+
if (["--description", "-d"].includes(word)) {
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
while (i + 1 < words.length && !words[i + 1]!.startsWith("-")) lines.push(words[++i]!);
|
|
159
|
+
body = lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
else if (["--body", "-b"].includes(word)) body = words[++i] ?? "";
|
|
162
|
+
else if (/^(?:--body|--description)=/.test(word)) body = word.slice(word.indexOf("=") + 1);
|
|
163
|
+
else if (["--body-file", "--description-file", "-F"].includes(word)) bodyFile = words[++i];
|
|
164
|
+
else if (/^(?:--body-file|--description-file)=/.test(word)) bodyFile = word.slice(word.indexOf("=") + 1);
|
|
165
|
+
else if (valueOptions.has(word)) i += 1;
|
|
64
166
|
}
|
|
65
|
-
|
|
66
|
-
/(?:--body-file|--description-file|-F)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
|
|
67
|
-
);
|
|
68
|
-
const bodyFile =
|
|
69
|
-
bodyFileMatch?.[1] ?? bodyFileMatch?.[2] ?? bodyFileMatch?.[3];
|
|
167
|
+
if (CHANGELOG_SECTION_RE.test(body)) return true;
|
|
70
168
|
if (bodyFile) {
|
|
71
169
|
try {
|
|
170
|
+
if (!isAbsolute(bodyFile) && invocationRoot === null) return false;
|
|
72
171
|
return CHANGELOG_SECTION_RE.test(
|
|
73
|
-
readFileSync(
|
|
172
|
+
readFileSync(isAbsolute(bodyFile) ? bodyFile : resolve(invocationRoot!, bodyFile), "utf8"),
|
|
74
173
|
);
|
|
75
174
|
} catch {
|
|
76
175
|
return false;
|