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/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 extractCommands(input: unknown): string[] {
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 (!record) {
16
- return [];
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 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
- // 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 function shellWords(command: string): string[] {
47
- const words: string[] = [];
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 splitShellSegments(command: string): string[] {
86
- const segments: string[] = [];
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 (SENSITIVE_ENV_RE.test(key)) {
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 { getCleanEnv, normalize } from "./utils.ts";
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
- try {
205
- child.kill("SIGKILL");
206
- } catch {}
207
- resolve({
208
- passed: false,
209
- output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
210
- durationMs: Date.now() - start,
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
+ }
@@ -7,7 +7,7 @@ import {
7
7
  getWorkspaceRootReal,
8
8
  recordMutation,
9
9
  } from "../lib/state.ts";
10
- import { shellWords, unwrapShellCommand } from "../lib/utils.ts";
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*(\S+)/gm;
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/utils.ts";
5
+ import { splitShellSegments, unwrapShellCommand, unwrapShellWords } from "../lib/shell.ts";
6
6
 
7
- export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
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 bodyMatch = command.match(
59
- /(?:--body|--description|-d|-b)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
60
- );
61
- const body = bodyMatch?.[1] ?? bodyMatch?.[2] ?? bodyMatch?.[3] ?? "";
62
- if (CHANGELOG_SECTION_RE.test(body)) {
63
- return true;
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
- const bodyFileMatch = command.match(
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(resolve(getWorkspaceRoot(), bodyFile), "utf8"),
172
+ readFileSync(isAbsolute(bodyFile) ? bodyFile : resolve(invocationRoot!, bodyFile), "utf8"),
74
173
  );
75
174
  } catch {
76
175
  return false;