opencode-workflow-guard 1.4.0 → 1.5.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.
@@ -127,23 +127,29 @@ export function teeTargetsIn(segment: string): string[] {
127
127
  return targets;
128
128
  }
129
129
 
130
- export function shellMutationIn(segment: string): ShellMutation | undefined {
131
- const redirectMatch = segment.match(
132
- /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/,
133
- );
134
- if (redirectMatch?.[1] && redirectMatch?.[2]) {
130
+ function redirectMutationsIn(segment: string): ShellMutation[] {
131
+ const mutations: ShellMutation[] = [];
132
+ const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/g;
133
+ for (const redirectMatch of segment.matchAll(redirectRe)) {
134
+ if (!redirectMatch[1] || !redirectMatch[2]) continue;
135
135
  const op = redirectMatch[1];
136
136
  const target = redirectMatch[2];
137
137
  // Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
138
138
  const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
139
139
  if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
140
- return {
140
+ mutations.push({
141
141
  kind: "redirect",
142
142
  target,
143
143
  what: `file redirect to '${target}'`,
144
- };
144
+ });
145
145
  }
146
146
  }
147
+ return mutations;
148
+ }
149
+
150
+ export function shellMutationIn(segment: string): ShellMutation | undefined {
151
+ const redirectMutation = redirectMutationsIn(segment)[0];
152
+ if (redirectMutation) return redirectMutation;
147
153
  const teeTargets = teeTargetsIn(segment);
148
154
  if (teeTargets.length > 0) {
149
155
  return {
@@ -152,10 +158,11 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
152
158
  what: `tee to '${teeTargets[0]!}'`,
153
159
  };
154
160
  }
155
- const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
156
- if (sedMatch) {
157
- const tokens = sedMatch[0]!.split(/\s+/);
158
- 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;
159
166
  return { kind: "command", target, what: `sed -i on '${target}'` };
160
167
  }
161
168
  const transfer = filesystemTransferInfo(segment);
@@ -192,12 +199,57 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
192
199
  return undefined;
193
200
  }
194
201
 
202
+ const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate"]);
203
+ const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
204
+
195
205
  export function simpleFilesystemMutations(segment: string): ShellMutation[] {
196
206
  const words = shellWords(unwrapShellCommand(segment));
197
207
  const command = words[0];
198
- if (!command || !new Set(["touch", "mkdir", "rm", "unlink", "rmdir"]).has(command)) {
208
+ if (!command) {
199
209
  return [];
200
210
  }
211
+ if (command === "dd") {
212
+ return words
213
+ .slice(1)
214
+ .filter((word) => word.startsWith("of=") && word.length > 3)
215
+ .map((word) => {
216
+ const target = word.slice(3);
217
+ return {
218
+ kind: "command" as const,
219
+ target,
220
+ what: `dd output to '${target}'`,
221
+ };
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
+ }
252
+ if (!SIMPLE_MUTATION_COMMANDS.has(command)) return [];
201
253
  return words
202
254
  .slice(1)
203
255
  .filter((word) => !word.startsWith("-"))
@@ -212,7 +264,7 @@ export function filesystemTransferInfo(
212
264
  segment: string,
213
265
  ): { sources: string[]; destination?: string } | undefined {
214
266
  const words = shellWords(unwrapShellCommand(segment));
215
- if (!words[0] || !new Set(["cp", "mv", "ln"]).has(words[0])) return undefined;
267
+ if (!words[0] || !TRANSFER_COMMANDS.has(words[0])) return undefined;
216
268
  const operands: string[] = [];
217
269
  let targetDirectory: string | undefined;
218
270
  for (let i = 1; i < words.length; i++) {
@@ -295,14 +347,12 @@ export async function guardShellMutation(
295
347
  target,
296
348
  what: `tee to '${target}'`,
297
349
  }));
298
- const fallbackMutation = shellMutationIn(segment.trim());
299
- const mutations = simpleMutations.length > 0
300
- ? simpleMutations
301
- : teeMutations.length > 0
302
- ? teeMutations
303
- : fallbackMutation
304
- ? [fallbackMutation]
305
- : [];
350
+ const redirectMutations = redirectMutationsIn(segment.trim());
351
+ const mutations = [...simpleMutations, ...teeMutations, ...redirectMutations];
352
+ if (mutations.length === 0) {
353
+ const fallbackMutation = shellMutationIn(segment.trim());
354
+ if (fallbackMutation) mutations.push(fallbackMutation);
355
+ }
306
356
  for (const mutation of mutations) {
307
357
  hasMutation = true;
308
358
  const secret = secretIn(segment);
@@ -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
+ }
@@ -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
  }
@@ -38,7 +38,9 @@ export function checkPackageHygiene(command: string): { isViolating: boolean; na
38
38
 
39
39
  // AI agents running in non-interactive terminal subshells hang indefinitely when an
40
40
  // interactive prompt (vim, nano, less, top, sudo, npm init without -y, apt without -y,
41
- // git rebase -i) waits for TTY input.
41
+ // git rebase -i) waits for TTY input. Process monitors are handled separately in
42
+ // checkProcessMonitorCommand because `top` can run in a safe batch mode, while the
43
+ // other monitors cannot.
42
44
 
43
45
  const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
44
46
  {
@@ -51,11 +53,6 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
51
53
  name: "terminal pager",
52
54
  advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
53
55
  },
54
- {
55
- regex: /\b(?:top|htop|btop|atop|glances)\b/i,
56
- name: "interactive process monitor",
57
- advice: "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.",
58
- },
59
56
  {
60
57
  regex: /\bsudo\b/i,
61
58
  name: "sudo with password prompt",
@@ -88,7 +85,29 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
88
85
  },
89
86
  ];
90
87
 
88
+ const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
89
+ // Process monitoring tokens are matched on whitespace/segment boundaries so that
90
+ // an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
91
+ // or a quoted argument like `echo "top"` is not mistaken for the `top` command.
92
+ const ALWAYS_INTERACTIVE_MONITOR_RE = /(?:^|[;&|\s])(?:htop|btop|atop|glances)(?:$|[;&|\s])/i;
93
+ const TOP_COMMAND_RE = /(?:^|[;&|\s])top(?:$|[;&|\s])/i;
94
+ const TOP_BATCH_FLAG_RE = /(?:^|\s)(?:--batch|-[A-Za-z]*b[A-Za-z]*)(?:\s|$)/i;
95
+
96
+ function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
97
+ for (const segment of command.split(/[;&|\n]+/)) {
98
+ if (ALWAYS_INTERACTIVE_MONITOR_RE.test(segment)) {
99
+ return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
100
+ }
101
+ if (TOP_COMMAND_RE.test(segment) && !TOP_BATCH_FLAG_RE.test(segment)) {
102
+ return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
103
+ }
104
+ }
105
+ return { isInteractive: false };
106
+ }
107
+
91
108
  export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
109
+ const monitor = checkProcessMonitorCommand(command);
110
+ if (monitor.isInteractive) return monitor;
92
111
  for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
93
112
  if (regex.test(command)) {
94
113
  return { isInteractive: true, name, advice };