opencode-workflow-guard 1.4.1 → 1.5.1

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.
@@ -158,10 +158,11 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
158
158
  what: `tee to '${teeTargets[0]!}'`,
159
159
  };
160
160
  }
161
- const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
162
- if (sedMatch) {
163
- const tokens = sedMatch[0]!.split(/\s+/);
164
- const target = tokens[tokens.length - 1];
161
+ const sedWords = shellWords(unwrapShellCommand(segment));
162
+ if (sedWords[0] === "sed" && sedWords.slice(1).some((word) => /^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word))) {
163
+ const operands = sedWords.slice(1).filter((word) => !word.startsWith("-"));
164
+ const target = operands.at(-1);
165
+ if (!target) return undefined;
165
166
  return { kind: "command", target, what: `sed -i on '${target}'` };
166
167
  }
167
168
  const transfer = filesystemTransferInfo(segment);
@@ -220,6 +221,34 @@ export function simpleFilesystemMutations(segment: string): ShellMutation[] {
220
221
  };
221
222
  });
222
223
  }
224
+ if (command === "sed") {
225
+ let inPlace = false;
226
+ let scriptSupplied = false;
227
+ const targets: string[] = [];
228
+ for (let i = 1; i < words.length; i++) {
229
+ const word = words[i]!;
230
+ if (/^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word)) {
231
+ inPlace = true;
232
+ continue;
233
+ }
234
+ if (word === "-e" || word === "--expression" || word === "-f" || word === "--file") {
235
+ scriptSupplied = true;
236
+ i++;
237
+ continue;
238
+ }
239
+ if (/^(?:-e|--expression=|-f|--file=)/.test(word) || word.startsWith("-")) {
240
+ if (/^(?:-e|--expression=)/.test(word)) scriptSupplied = true;
241
+ continue;
242
+ }
243
+ if (!scriptSupplied) {
244
+ scriptSupplied = true;
245
+ continue;
246
+ }
247
+ targets.push(word);
248
+ }
249
+ if (!inPlace) return [];
250
+ return targets.map((target) => ({ kind: "command" as const, target, what: `sed -i on '${target}'` }));
251
+ }
223
252
  if (!SIMPLE_MUTATION_COMMANDS.has(command)) return [];
224
253
  return words
225
254
  .slice(1)
@@ -1,8 +1,8 @@
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/utils.ts";
6
6
 
7
7
  export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
8
8
 
@@ -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;
@@ -0,0 +1,83 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getSdkClient } from "../lib/state.ts";
3
+ import { effectiveTodosWithOwner, hasActiveTodo } from "./todo.ts";
4
+
5
+ const MAX_CONSECUTIVE_CONTINUATIONS = 3;
6
+ type ContinuationState = {
7
+ counts: Map<string, number>;
8
+ generatedMessageIDs: Map<string, Set<string>>;
9
+ inFlight: Set<string>;
10
+ };
11
+ const states = new WeakMap<object, ContinuationState>();
12
+
13
+ function getState(): ContinuationState | undefined {
14
+ const client = getSdkClient();
15
+ if (!client || (typeof client !== "object" && typeof client !== "function")) return undefined;
16
+ let state = states.get(client as object);
17
+ if (!state) {
18
+ state = { counts: new Map(), generatedMessageIDs: new Map(), inFlight: new Set() };
19
+ states.set(client as object, state);
20
+ }
21
+ return state;
22
+ }
23
+
24
+ export async function continueUnfinishedSession(sessionID: string): Promise<boolean> {
25
+ const state = getState();
26
+ if (!state || state.inFlight.has(sessionID)) return false;
27
+ state.inFlight.add(sessionID);
28
+ try {
29
+ const effective = await effectiveTodosWithOwner(sessionID);
30
+ if (!effective || !hasActiveTodo(effective.todos) || effective.ownerSessionID !== sessionID) return false;
31
+
32
+ const count = state.counts.get(sessionID) ?? 0;
33
+ if (count >= MAX_CONSECUTIVE_CONTINUATIONS) return false;
34
+
35
+ const session = getSdkClient()?.session;
36
+ if (typeof session?.promptAsync !== "function") return false;
37
+
38
+ const messageID = `workflow-guard-${randomUUID()}`;
39
+ state.counts.set(sessionID, count + 1);
40
+ const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
41
+ generatedIDs.add(messageID);
42
+ state.generatedMessageIDs.set(sessionID, generatedIDs);
43
+ try {
44
+ await session.promptAsync({
45
+ path: { id: sessionID },
46
+ body: {
47
+ messageID,
48
+ parts: [{
49
+ type: "text",
50
+ text: "Workflow Guard: unfinished todos remain. Continue working through them. If you need user input, use the question tool instead of ending the run.",
51
+ }],
52
+ },
53
+ });
54
+ return true;
55
+ } catch {
56
+ state.counts.set(sessionID, count);
57
+ generatedIDs.delete(messageID);
58
+ if (generatedIDs.size === 0) state.generatedMessageIDs.delete(sessionID);
59
+ return false;
60
+ }
61
+ } finally {
62
+ state.inFlight.delete(sessionID);
63
+ }
64
+ }
65
+
66
+ export function recordUserMessage(sessionID: string, messageID?: string): void {
67
+ const state = getState();
68
+ if (!state) return;
69
+ const generatedIDs = state.generatedMessageIDs.get(sessionID);
70
+ if (messageID && generatedIDs?.delete(messageID)) {
71
+ if (generatedIDs.size === 0) state.generatedMessageIDs.delete(sessionID);
72
+ return;
73
+ }
74
+ state.counts.delete(sessionID);
75
+ }
76
+
77
+ export function clearContinuationState(sessionID: string): void {
78
+ const state = getState();
79
+ if (!state) return;
80
+ state.counts.delete(sessionID);
81
+ state.generatedMessageIDs.delete(sessionID);
82
+ state.inFlight.delete(sessionID);
83
+ }
@@ -1,4 +1,4 @@
1
- import { asRecord } from "../lib/utils.ts";
1
+ import { asRecord, shellWords, splitShellSegments } from "../lib/utils.ts";
2
2
 
3
3
  export interface LivePattern {
4
4
  re: RegExp;
@@ -62,9 +62,48 @@ export const LIVE_MUTATION_PATTERNS: LivePattern[] = [
62
62
  { re: /\bmknod\s+\S+\s+p\b/, what: ["named FIFO pipe ", "creation (reverse shell backpipe)"].join("") },
63
63
  ];
64
64
 
65
+ const KUBECTL_VALUE_GLOBALS = new Set([
66
+ "--as", "--as-group", "--as-uid", "--as-user-extra", "--cache-dir", "--certificate-authority", "--client-certificate", "--client-key",
67
+ "--cluster", "--context", "--kubeconfig", "--kuberc", "--namespace", "--password", "--profile", "--profile-output", "--request-timeout",
68
+ "--server", "--storage-driver-buffer-duration", "--storage-driver-db", "--storage-driver-host", "--storage-driver-password", "--storage-driver-table",
69
+ "--storage-driver-user", "--tls-server-name", "--token", "--user", "--username", "-n", "-s",
70
+ ]);
71
+ const KUBECTL_BOOLEAN_GLOBALS = new Set(["--disable-compression", "--insecure-skip-tls-verify", "--match-server-version", "--storage-driver-secure", "--version", "--warnings-as-errors"]);
72
+
73
+ function normalizedKubectlCommands(command: string): string {
74
+ const normalized: string[] = [];
75
+ for (const segment of splitShellSegments(command)) {
76
+ const words = shellWords(segment);
77
+ for (let i = 0; i < words.length; i++) {
78
+ if (words[i] !== "kubectl") continue;
79
+ let j = i + 1;
80
+ while (j < words.length) {
81
+ const option = words[j];
82
+ const name = option.split("=", 1)[0];
83
+ if (/^-[ns].+/.test(option) && !option.startsWith("--")) {
84
+ j++;
85
+ continue;
86
+ }
87
+ if (KUBECTL_VALUE_GLOBALS.has(name)) {
88
+ j += option.includes("=") ? 1 : 2;
89
+ continue;
90
+ }
91
+ if (KUBECTL_BOOLEAN_GLOBALS.has(name)) {
92
+ j++;
93
+ continue;
94
+ }
95
+ break;
96
+ }
97
+ if (j < words.length) normalized.push(`kubectl ${words.slice(j).join(" ")}`);
98
+ }
99
+ }
100
+ return normalized.join(" ; ");
101
+ }
102
+
65
103
  export function liveMutationIn(command: string): string | undefined {
104
+ const optionNormalized = `${normalizedKubectlCommands(command)} ; ${command.replace(/\b(terraform|tofu)\s+(?:(?:-chdir(?:=\S+|\s+\S+)|-(?:no-color|version))\s+)*/g, "$1 ")}`;
66
105
  for (const { re, what } of LIVE_MUTATION_PATTERNS) {
67
- if (re.test(command)) {
106
+ if (re.test(command) || re.test(optionNormalized)) {
68
107
  return what;
69
108
  }
70
109
  }
@@ -16,6 +16,22 @@ export const PROTECTED_BRANCHES = new Set(["main", "master"]);
16
16
  export const PUSH_TO_MAIN_RE =
17
17
  /\bgit\s+push\b[^|;&]*(?:^|\s)\+?[\w./-]*:(?:main|master)(?![\w./-])|\bgit\s+push\b[^|;&]*(?:\s|\/)(?:main|master)(?![\w./-])/;
18
18
 
19
+ export function hasUnsafeGitAlias(command: string): boolean {
20
+ return splitShellSegments(command).some((segment) => {
21
+ const words = unwrapShellWords(segment);
22
+ const gitIndex = words.findIndex((word, index) =>
23
+ word === "git" && words.slice(0, index).every((prefix) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(prefix)),
24
+ );
25
+ if (gitIndex < 0) return false;
26
+ const args = words.slice(gitIndex + 1);
27
+ return args.some((arg, index) =>
28
+ /^-calias\./i.test(arg) ||
29
+ /^--config-env=alias\./i.test(arg) ||
30
+ ((arg === "-c" || arg === "--config-env") && /^alias\./i.test(args[index + 1] ?? "")),
31
+ );
32
+ });
33
+ }
34
+
19
35
  export const GIT_BRANCH_CREATE_RE =
20
36
  /\bgit\s+(?:checkout\s+-b|switch\s+(?:-c|--create))\b/;
21
37
 
@@ -4,7 +4,7 @@ import { isPathOutsideWorkspace } from "./boundary.ts";
4
4
  export function extractInterpreterPayload(segment: string): string[] {
5
5
  const payloads: string[] = [];
6
6
  const inlineMatch = segment.match(
7
- /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
7
+ /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*(?:"([^"]*)"|'([^']*)')/i,
8
8
  );
9
9
  if (inlineMatch?.[1] || inlineMatch?.[2]) {
10
10
  payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
@@ -49,9 +49,14 @@ export function secretPathInPayload(payload: string): string | undefined {
49
49
  return undefined;
50
50
  }
51
51
 
52
- const FUNCTION_TARGET_PATTERNS: RegExp[] = [
52
+ const WRITE_TARGET_PATTERNS: RegExp[] = [
53
53
  /\b(?:writeFileSync|writeFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rmSync|rmdirSync|mkdirSync|renameSync|copyFileSync|cpSync|truncateSync)\s*\(\s*["']([^"']+)["']/g,
54
- /\b(?:open|os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move|Path)\s*\(\s*["']([^"']+)["']/g,
54
+ /\b(?:renameSync|copyFileSync|cpSync)\s*\(\s*["'][^"']+["']\s*,\s*["']([^"']+)["']/g,
55
+ /\b(?:os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move)\s*\(\s*["']([^"']+)["']/g,
56
+ /\b(?:os\.rename|shutil\.copy|shutil\.copy2|shutil\.move)\s*\(\s*["'][^"']+["']\s*,\s*["']([^"']+)["']/g,
57
+ /\bopen\s*\(\s*["']([^"']+)["']\s*,\s*["'][^"']*[wax+][^"']*["']/g,
58
+ /\bPath\s*\(\s*["']([^"']+)["']\s*\)\s*\.\s*(?:write_text|write_bytes|unlink|mkdir|touch|rename|replace)\s*\(/g,
59
+ /\bPath\s*\(\s*["'][^"']+["']\s*\)\s*\.\s*(?:rename|replace)\s*\(\s*["']([^"']+)["']/g,
55
60
  /[>]{1,2}\s*["']?([^\s;&"'|)]+)/g,
56
61
  ];
57
62
 
@@ -63,7 +68,7 @@ const FUNCTION_TARGET_PATTERNS: RegExp[] = [
63
68
  export function outsideWritePathInPayload(payload: string, root: string): string | undefined {
64
69
  const candidates = new Set<string>();
65
70
 
66
- for (const pattern of FUNCTION_TARGET_PATTERNS) {
71
+ for (const pattern of WRITE_TARGET_PATTERNS) {
67
72
  const matches = payload.matchAll(pattern);
68
73
  for (const m of matches) {
69
74
  if (m[1]) candidates.add(m[1]);
@@ -78,3 +83,13 @@ export function outsideWritePathInPayload(payload: string, root: string): string
78
83
  }
79
84
  return undefined;
80
85
  }
86
+
87
+ export function writePathsInPayload(payload: string): string[] {
88
+ const candidates = new Set<string>();
89
+ for (const pattern of WRITE_TARGET_PATTERNS) {
90
+ for (const match of payload.matchAll(pattern)) {
91
+ if (match[1]) candidates.add(match[1]);
92
+ }
93
+ }
94
+ return [...candidates];
95
+ }
@@ -0,0 +1,50 @@
1
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+
4
+ const PRIMARY_TODO_NAMES = ["TODO.md", "todo.md"];
5
+ const FALLBACK_NAMES = [
6
+ "ROADMAP.md", "roadmap.md",
7
+ "PLAN.md", "plan.md",
8
+ "TASKS.md", "tasks.md",
9
+ "BACKLOG.md", "backlog.md",
10
+ ];
11
+
12
+ export interface PlanningSource {
13
+ path: string;
14
+ content: string;
15
+ }
16
+
17
+ export function discoverPlanningSources(root: string): PlanningSource[] {
18
+ let realRoot: string;
19
+ try {
20
+ realRoot = realpathSync(root);
21
+ } catch {
22
+ return [];
23
+ }
24
+ const isConfined = (path: string): boolean => {
25
+ const rel = relative(realRoot, realpathSync(path));
26
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
27
+ };
28
+ const readSources = (paths: string[]): PlanningSource[] => paths.flatMap((path) => {
29
+ try {
30
+ // Planning discovery is read-only and must not become a symlink escape hatch.
31
+ if (!lstatSync(path).isFile() || !isConfined(path)) return [];
32
+ return [{ path: relative(root, path), content: readFileSync(path, "utf8").slice(0, 20_000) }];
33
+ } catch {
34
+ return [];
35
+ }
36
+ });
37
+
38
+ const primary = readSources(PRIMARY_TODO_NAMES.map((name) => join(root, name)));
39
+ if (primary.length > 0) return primary;
40
+
41
+ const candidates = FALLBACK_NAMES.flatMap((name) => [join(root, name), join(root, "docs", name)]);
42
+ const plansDir = join(root, "docs", "plans");
43
+ try {
44
+ if (!lstatSync(plansDir).isDirectory() || !isConfined(plansDir)) return readSources(candidates);
45
+ for (const entry of readdirSync(plansDir, { withFileTypes: true })) {
46
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) candidates.push(join(plansDir, entry.name));
47
+ }
48
+ } catch {}
49
+ return readSources(candidates);
50
+ }
@@ -1,6 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
3
  import { getWorkspaceRoot } from "../lib/state.ts";
4
+ import { shellWords, unwrapShellCommand } from "../lib/utils.ts";
4
5
 
5
6
  export const SAFE_ENV_FIXTURE_RE =
6
7
  /\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
@@ -154,5 +155,16 @@ export function secretFileReadIn(segment: string): string | undefined {
154
155
  }
155
156
  const simpleMatch = segment.match(SIMPLE_FILE_READ_COMMAND_RE);
156
157
  if (simpleMatch?.[1] && isSecretPath(simpleMatch[1])) return simpleMatch[1];
158
+ const words = shellWords(unwrapShellCommand(segment));
159
+ if (["cat", "head", "tail", "less", "more", "od", "hexdump", "strings", "base64", "xxd", "nl", "view", "nano", "vim"].includes(words[0] ?? "")) {
160
+ for (const operand of words.slice(1).filter((word) => !word.startsWith("-"))) {
161
+ if (isSecretPath(operand)) return operand;
162
+ }
163
+ }
164
+ if (words[0] === "dd") {
165
+ for (const word of words.slice(1)) {
166
+ if (word.startsWith("if=") && isSecretPath(word.slice(3))) return word.slice(3);
167
+ }
168
+ }
157
169
  return undefined;
158
170
  }
@@ -35,6 +35,12 @@ export async function fetchParentSessionID(sessionID: string): Promise<string |
35
35
  export async function effectiveTodos(
36
36
  sessionID: string | undefined,
37
37
  ): Promise<TodoItem[] | undefined> {
38
+ return (await effectiveTodosWithOwner(sessionID))?.todos;
39
+ }
40
+
41
+ export async function effectiveTodosWithOwner(
42
+ sessionID: string | undefined,
43
+ ): Promise<{ todos: TodoItem[]; ownerSessionID?: string } | undefined> {
38
44
  if (!sessionID) return undefined;
39
45
  const seen = new Set<string>();
40
46
  let current: string | undefined = sessionID;
@@ -42,10 +48,10 @@ export async function effectiveTodos(
42
48
  seen.add(current);
43
49
  const todos = await fetchSessionTodos(current);
44
50
  if (todos === undefined) return undefined;
45
- if (todos.length > 0) return todos;
51
+ if (todos.length > 0) return { todos, ownerSessionID: current };
46
52
  current = await fetchParentSessionID(current);
47
53
  }
48
- return [];
54
+ return { todos: [] };
49
55
  }
50
56
 
51
57
  export async function effectiveTodoOwnerSessionID(