@mortz.prk/opencode-auto-mode 1.0.1 → 1.1.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.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +234 -0
  2. package/README.llm.md +314 -0
  3. package/README.md +175 -166
  4. package/dist/gui-experimental.cjs +1 -0
  5. package/package.json +27 -16
  6. package/src/audit.ts +136 -38
  7. package/src/classifier.ts +45 -37
  8. package/src/code-mode.ts +55 -0
  9. package/src/config.ts +104 -98
  10. package/src/debug-events.ts +223 -0
  11. package/src/decision.ts +72 -0
  12. package/src/errors.ts +39 -0
  13. package/src/gates.ts +16 -0
  14. package/src/global-options.ts +85 -0
  15. package/src/gui-state.ts +269 -0
  16. package/src/gui.tsx +78 -0
  17. package/src/index.ts +510 -131
  18. package/src/mode.ts +237 -0
  19. package/src/reviewer.ts +71 -85
  20. package/src/rpc.ts +127 -0
  21. package/src/score/files.ts +49 -0
  22. package/src/score/history.ts +141 -0
  23. package/src/score/index.ts +37 -0
  24. package/src/score/limits.ts +24 -0
  25. package/src/score/shell-argv.ts +74 -0
  26. package/src/score/shell-baseline.ts +113 -0
  27. package/src/score/shell-safety.ts +54 -0
  28. package/src/score/shell.ts +30 -0
  29. package/src/score/types.ts +14 -0
  30. package/src/score.ts +9 -0
  31. package/src/shell-policy.ts +1979 -0
  32. package/src/shell.ts +508 -225
  33. package/src/status.ts +85 -0
  34. package/src/tiers.ts +286 -40
  35. package/src/timeout.ts +30 -0
  36. package/src/tui.tsx +44 -34
  37. package/src/user-files.ts +75 -0
  38. package/src/worktree.ts +30 -0
  39. package/src/decisions.ts +0 -213
  40. package/src/logger.ts +0 -26
  41. package/src/otlp.ts +0 -74
  42. package/src/policy.ts +0 -36
  43. package/src/state.ts +0 -135
  44. package/src/storage.ts +0 -26
  45. package/src/traces.ts +0 -84
  46. package/src/transcript.ts +0 -208
  47. package/src/tui-state.ts +0 -7
package/src/shell.ts CHANGED
@@ -1,134 +1,23 @@
1
- import { parse } from "shell-quote";
2
- import { basename } from "node:path";
3
- import { classify } from "./tiers.js";
1
+ import { homedir } from "node:os";
2
+ import { basename, resolve } from "node:path";
4
3
  import type { ReviewResult } from "./reviewer.js";
4
+ import { isInProject, isProtected, isProtectedPathOperand, isProtectedShellWord } from "./tiers.js";
5
+ import {
6
+ BALANCED_QUOTES, CONTROL_OPERATORS, DYNAMIC, REDIRECT_OPERATORS, SCRIPT_HOSTS, SHELLS, STATEFUL,
7
+ denyCleanPrefix, denyInvocation, hasAmbiguousDescriptorClose, hasAssembledUndecodedSyntax, hasHeredoc,
8
+ hasUndecodedSyntax, heredocMarkers, isLongOption, isReadOnlyInvocation, logicalLines, normalizeContinuations,
9
+ operandRoles, parseShell, rawHeredocScript, resolveExecutable, stripDescriptorPrefixes,
10
+ } from "./shell-policy.js";
5
11
 
6
12
  const MAX_COMMAND_CHARS = 4096;
7
13
  const MAX_COMMAND_BYTES = 65_536;
8
- const BALANCED_QUOTES = /^(?:[^'"\\]|\\[^\r\n]|'[^']*'|"(?:[^"\\]|\\[^\r\n])*")*$/;
9
- const CONTROL_OPERATORS = new Set([";", "&&", "||", "|", "|&", "&"]);
10
- const REDIRECT_OPERATORS = new Set([">", ">>", ">&", "<&", "<", "<<", "<<-", "<<<", "<>", ">|", "&>", "&>>"]);
11
- const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
12
- const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "pwsh", "powershell", "cmd"]);
13
- const SCRIPT_HOSTS = new Set(["python", "python3", "node", "perl", "ruby", "php", "awk", "gawk", "lua"]);
14
- const STATEFUL = new Set(["alias", "unalias", "export", "unset", "set", "shopt", "trap", "umask",
15
- "readonly", "declare", "typeset", "local", "function"]);
16
- const DYNAMIC = new Set(["eval", "source", ".", "exec", "xargs"]);
17
- const DYNAMIC_SYNTAX = /\$\(|`|\$\(\s*\(|[<>]\(/;
18
- const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
19
- const WRAPPERS = new Set(["env", "command", "builtin", "exec", "time", "!", "nohup", "setsid", "nice",
20
- "ionice", "stdbuf", "timeout"]);
21
- const VALUE_OPTIONS = new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-a", "--argv0",
22
- "-n", "--adjustment", "-s", "--signal", "-k", "--kill-after"]);
23
- const READ_ONLY = new Set(["pwd", "whoami", "hostname", "uname", "id", "groups", "ls", "cat", "head", "tail",
24
- "wc", "date", "echo", "printf", "true", ":", "rg", "grep", "sort", "uniq", "cut", "tr", "jq", "stat", "file",
25
- "du", "df", "tree", "bat", "which", "type", "sleep", "test", "basename", "dirname", "realpath", "diff"]);
14
+ const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>", ">&"]);
15
+ const DYNAMIC_SYNTAX = /\$\(|`|\$\(\s*\(|[<>]\(|\$\[/;
16
+ const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
26
17
 
27
- export function shellWords(command: string): string[] | undefined {
28
- if (!command || command.length > MAX_COMMAND_CHARS || /[$`~\r\n\0{}[\]]/.test(command) ||
29
- /[^\S \t]/.test(command) || !BALANCED_QUOTES.test(command)) return undefined;
30
- try {
31
- const words = parse(command);
32
- return words.length && words.every((word) => typeof word === "string") ? words as string[] : undefined;
33
- } catch { return undefined; }
34
- }
35
-
36
- interface Segment { words: string[]; write: boolean }
18
+ interface Segment { words: string[]; globs: boolean[]; write: boolean }
37
19
  type Verdict = { decision: "deny"; reason: string } | { decision: "allow" | "review" | "ask" };
38
20
 
39
- function blockedCommand(command: string): string | undefined {
40
- const normalized = shellWords(command);
41
- const result = classify("shell", [command, ...(normalized ? [normalized.join(" ")] : [])]);
42
- return result.tier === "auto-deny" ? result.reason : undefined;
43
- }
44
-
45
- /** True when the command contains a heredoc marker outside quotes. Heredoc bodies are data. */
46
- function hasHeredoc(command: string): boolean {
47
- let quote: "'" | '"' | undefined;
48
- for (let index = 0; index < command.length; index++) {
49
- const character = command[index];
50
- if (quote === "'") { if (character === "'") quote = undefined; continue; }
51
- if (quote === '"') {
52
- if (character === "\\") { index++; continue; }
53
- if (character === '"') quote = undefined;
54
- continue;
55
- }
56
- if (character === "'" || character === '"') { quote = character; continue; }
57
- if (character === "\\") { index++; continue; }
58
- if (character === "<" && command[index + 1] === "<" && command[index + 2] !== "<") return true;
59
- }
60
- return false;
61
- }
62
-
63
- interface HeredocMarker { delimiter: string; quoted: boolean; stripTabs: boolean }
64
-
65
- /** Reads heredoc markers on one line, ignoring markers inside quoted strings and comments. */
66
- function heredocMarkers(line: string): HeredocMarker[] {
67
- const markers: HeredocMarker[] = [];
68
- let quote: "'" | '"' | undefined;
69
- for (let index = 0; index < line.length; index++) {
70
- const character = line[index];
71
- if (quote === "'") { if (character === "'") quote = undefined; continue; }
72
- if (quote === '"') {
73
- if (character === "\\") { index++; continue; }
74
- if (character === '"') quote = undefined;
75
- continue;
76
- }
77
- if (character === "'" || character === '"') { quote = character; continue; }
78
- if (character === "\\") { index++; continue; }
79
- if (character === "#" && (index === 0 || /\s/.test(line[index - 1] ?? ""))) break;
80
- if (character !== "<" || line[index + 1] !== "<" || line[index + 2] === "<") continue;
81
- let cursor = index + 2;
82
- const stripTabs = line[cursor] === "-";
83
- if (stripTabs) cursor++;
84
- while (line[cursor] === " " || line[cursor] === "\t") cursor++;
85
- let delimiter = "";
86
- let quoted = false;
87
- if (line[cursor] === "'" || line[cursor] === '"') {
88
- const closing = line[cursor];
89
- quoted = true;
90
- cursor++;
91
- while (cursor < line.length && line[cursor] !== closing) delimiter += line[cursor++];
92
- } else {
93
- while (cursor < line.length && /[A-Za-z0-9_]/.test(line[cursor])) delimiter += line[cursor++];
94
- }
95
- if (delimiter) markers.push({ delimiter, quoted, stripTabs });
96
- index = cursor - 1;
97
- }
98
- return markers;
99
- }
100
-
101
- /** Resolves leading assignments and wrapper options to the effective executable. */
102
- function resolveExecutable(words: string[]): { name: string; index: number } | undefined {
103
- let index = 0;
104
- while (index < words.length && ASSIGNMENT.test(words[index])) index++;
105
- while (index < words.length) {
106
- const name = basename(words[index]);
107
- if (!WRAPPERS.has(name)) break;
108
- index++;
109
- if (name === "timeout") {
110
- while (index < words.length && words[index].startsWith("-") && words[index] !== "-") {
111
- const option = words[index].split("=", 1)[0];
112
- index += VALUE_OPTIONS.has(option) && !words[index].includes("=") ? 2 : 1;
113
- }
114
- if (index < words.length) index++;
115
- continue;
116
- }
117
- while (index < words.length) {
118
- const word = words[index];
119
- if (word === "--") { index++; break; }
120
- if (word.startsWith("-") && word !== "-") {
121
- const option = word.split("=", 1)[0];
122
- index += VALUE_OPTIONS.has(option) && !word.includes("=") ? 2 : 1;
123
- continue;
124
- }
125
- if (ASSIGNMENT.test(word)) { index++; continue; }
126
- break;
127
- }
128
- }
129
- return index < words.length ? { name: basename(words[index]), index } : undefined;
130
- }
131
-
132
21
  /** True when the effective executable is a shell, a dynamic builtin, or a stateful builtin. */
133
22
  function dynamicExecution(words: string[]): boolean {
134
23
  const executable = resolveExecutable(words);
@@ -138,105 +27,303 @@ function dynamicExecution(words: string[]): boolean {
138
27
  .some((word) => /^-(?:exec|execdir|ok|okdir|delete)$/.test(word));
139
28
  }
140
29
 
141
- function isReadOnlyGit(args: string[]): boolean {
142
- if (args.some((arg) =>
143
- /^(?:-c|--git-dir|--work-tree|--exec-path|--config-env)(?:=|$)/.test(arg) ||
144
- arg === "--ext-diff" || arg === "--exec" || arg.startsWith("--output"))) return false;
145
- const subcommand = args[0];
146
- if (subcommand === undefined) return false;
147
- if (["status", "diff", "log", "show", "rev-parse"].includes(subcommand)) return true;
148
- if (subcommand !== "branch") return false;
149
- if (args.length === 1) return true;
150
- return args.slice(1).every((arg) =>
151
- ["--show-current", "--list", "-a", "-r", "-v", "-vv", "--merged", "--no-merged", "--contains", "--points-at", "--format"]
152
- .some((safe) => arg === safe || arg.startsWith(`${safe}=`)));
153
- }
154
-
155
- function isReadOnlySed(args: string[]): boolean {
156
- if (args.some((arg) => arg === "-i" || arg.startsWith("--in-place") || arg === "-e" ||
157
- arg === "--expression" || arg === "-f" || arg === "--file")) return false;
158
- return args[0] === "-n" && args[1] !== undefined && /^\d+(?:,\d+)?p$/.test(args[1]);
159
- }
160
-
161
- function isReadOnlyGh(args: string[]): boolean {
162
- const [group, subcommand] = args;
163
- if (group === "label" && subcommand === "list") return true;
164
- if (group === "api") {
165
- return !args.some((arg) => arg === "-X" || arg === "--method" || arg.startsWith("-X") ||
166
- arg.startsWith("--method=") || ["-f", "--field", "-F", "--raw-field", "--input"].includes(arg));
167
- }
168
- return new Set(["pr view", "pr list", "pr diff", "pr checks", "issue view", "issue list", "run view",
169
- "run list", "repo view", "auth status", "status", "search"]).has(`${group} ${subcommand}`);
170
- }
171
-
172
- function isReadOnly(words: string[]): boolean {
173
- const command = basename(words[0]);
174
- const args = words.slice(1);
175
- if (READ_ONLY.has(command)) {
176
- return command !== "date" || !args.some((arg) => arg === "-s" || arg === "--set" || arg.startsWith("--set="));
177
- }
178
- if (command === "git") return isReadOnlyGit(args);
179
- if (command === "find") return !args.some((arg) => /^-(?:fprint|fprint0|fprintf|fls)$/.test(arg));
180
- if (command === "sed") return isReadOnlySed(args);
181
- if (command === "gh") return isReadOnlyGh(args);
182
- if (command === "docker") return args[0] === "logs";
183
- if (command === "bun")
184
- return args[0] === "test" || (args[0] === "run" && (args[1] === "lint" || args[1] === "typecheck"));
185
- return false;
186
- }
187
-
188
30
  function analyzeSegment(segment: Segment): Verdict {
189
31
  if (!segment.words.length) return segment.write ? { decision: "review" } : { decision: "allow" };
190
32
  const command = segment.words[0];
191
33
  if (!command || command.includes("$")) return { decision: "ask" };
192
- const blocked = blockedCommand(segment.words.join(" "));
34
+ // A deny that is already identifiable wins over every uncertainty below, so
35
+ // `sudo id *.txt` stays a mandatory deny instead of becoming a review.
36
+ const blocked = denyInvocation(segment.words);
193
37
  if (blocked !== undefined) return { decision: "deny", reason: blocked };
194
38
  if (dynamicExecution(segment.words)) return { decision: "ask" };
195
- if (!segment.write && isReadOnly(segment.words)) return { decision: "allow" };
39
+ // `isReadOnlyInvocation` refuses any argument an unquoted expansion could turn
40
+ // into several words, so the literal flag policies only judge what will run.
41
+ if (!segment.write && isReadOnlyInvocation(segment.words, segment.globs)) return { decision: "allow" };
196
42
  return { decision: "review" };
197
43
  }
198
44
 
199
- function analyzeLine(command: string): Verdict {
200
- if (!command.trim()) return { decision: "allow" };
201
- if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
202
- const blocked = blockedCommand(command);
203
- if (blocked !== undefined) return { decision: "deny", reason: blocked };
45
+ /** Token shapes this module consumes; `parse` returns a wider union. */
46
+ type Token = string | { comment: string } | { op: string; pattern?: string };
204
47
 
205
- let tokens;
206
- try { tokens = parse(command); }
207
- catch { return { decision: "ask" }; }
208
- const blockedTokens = blockedCommand(tokens.filter((token): token is string => typeof token === "string").join(" "));
209
- if (blockedTokens !== undefined) return { decision: "deny", reason: blockedTokens };
210
- if (DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
48
+ export interface ParsedShellCommands {
49
+ /** Simple argv lists, excluding redirection operands. */
50
+ segments: Segment[];
51
+ /** Includes redirection targets for filesystem safety checks. */
52
+ operands: string[];
53
+ /** Redirection targets, including glob patterns: these always name a file. */
54
+ redirects: string[];
55
+ /** String tokens no simple command consumed, such as words inside a substitution. */
56
+ leftovers: string[];
57
+ /** False for syntax whose execution cannot be bounded by this parser. */
58
+ complete: boolean;
59
+ compound: boolean;
60
+ }
61
+ interface ParsedLine { segments: Segment[]; redirects: string[]; leftovers: string[]; complete: boolean }
62
+
63
+ /** Characters that end an unquoted shell word. JS `\s` is wider than a shell, so the set is explicit. */
64
+ const WORD_DELIMITER = /[ \t\n;|&()<>]/;
65
+
66
+ /**
67
+ * One entry per shell word, true when the word contains an expansion that can
68
+ * turn it into several arguments.
69
+ *
70
+ * `shell-quote` only marks `*` and `?` as globs, but bracket (`[ab]`) and brace
71
+ * (`{a,b}`) expansion, and parameter expansion, change the argument count too.
72
+ * This scan is quote- and escape-aware, so quoted or escaped bracket, brace, and
73
+ * glob markers are not flagged here. `shell-quote` still reports an escaped
74
+ * `*`/`?` as a glob token, which stays conservatively reviewed.
75
+ *
76
+ * A parameter inside double quotes counts as uncertain for the same reason: it
77
+ * stays one word only after expansion, and the value decides both the text and —
78
+ * for `"$@"` — the count. `sort "${X:--oout}" file` writes a file when `X` is
79
+ * unset, `find . "${X:--delete}"` deletes, and `uniq "$@"` can become two
80
+ * operands, while `shell-quote` sees only an empty string. Single quotes and an
81
+ * escaped `\$` are literal and stay safe. Matching the flags by position keeps
82
+ * the raw text out of the parsed logic.
83
+ */
84
+ function wordExpansions(command: string): boolean[] {
85
+ const expansions: boolean[] = [];
86
+ let present = false;
87
+ let expansion = false;
88
+ let wordStart = true;
89
+ let quote: "'" | '"' | "$'" | undefined;
90
+ const flush = () => {
91
+ if (!present) return;
92
+ expansions.push(expansion);
93
+ present = false;
94
+ expansion = false;
95
+ };
96
+ for (let index = 0; index < command.length; index++) {
97
+ const character = command[index];
98
+ if (quote === "'") {
99
+ if (character === "'") quote = undefined;
100
+ continue;
101
+ }
102
+ if (quote === "$'") {
103
+ if (character === "\\") index++;
104
+ else if (character === "'") quote = undefined;
105
+ continue;
106
+ }
107
+ if (quote === '"') {
108
+ if (character === "\\") index++;
109
+ else if (character === '"') quote = undefined;
110
+ else if (character === "$") expansion = true;
111
+ continue;
112
+ }
113
+ if (character === "\\") {
114
+ present = true;
115
+ if (index + 1 < command.length && command[++index] !== "\n") wordStart = false;
116
+ continue;
117
+ }
118
+ if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
119
+ present = true;
120
+ wordStart = false;
121
+ if (character === "'" || character === '"') quote = character;
122
+ else { quote = "$'"; index++; }
123
+ continue;
124
+ }
125
+ if (character === "#" && wordStart) {
126
+ const end = command.indexOf("\n", index);
127
+ flush();
128
+ if (end === -1) break;
129
+ index = end;
130
+ wordStart = true;
131
+ continue;
132
+ }
133
+ if (WORD_DELIMITER.test(character)) {
134
+ flush();
135
+ wordStart = true;
136
+ continue;
137
+ }
138
+ if (character === "*" || character === "?" || character === "[" || character === "{" ||
139
+ character === "$") expansion = true;
140
+ present = true;
141
+ wordStart = false;
142
+ }
143
+ flush();
144
+ return expansions;
145
+ }
146
+
147
+ /** String tokens that no simple command consumed, such as words inside `$(...)`. */
148
+ function leftoverWords(tokens: readonly Token[], consumed: ReadonlySet<number>): string[] {
149
+ return tokens.flatMap((token, index) => {
150
+ if (consumed.has(index)) return [];
151
+ if (typeof token === "string") return [token];
152
+ if ("op" in token && token.op === "glob" && token.pattern !== undefined) return [token.pattern];
153
+ return [];
154
+ });
155
+ }
211
156
 
212
- const segments: Segment[] = [{ words: [], write: false }];
157
+ /** Splits parsed tokens into simple commands, tracking whether the whole line parsed. */
158
+ function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] = []): ParsedLine {
159
+ const segments: Segment[] = [{ words: [], globs: [], write: false }];
160
+ const redirects: string[] = [];
161
+ const consumed = new Set<number>();
213
162
  let current = segments[0];
214
- let redirect: { write: boolean } | undefined;
163
+ let redirect: { write: boolean; op: string } | undefined;
164
+ let uncertainRedirect = false;
165
+ let wordIndex = 0;
166
+ let position = 0;
167
+ const nextExpansion = () => expansions[wordIndex++] ?? false;
168
+ const failed = (): ParsedLine => ({ segments, redirects, leftovers: leftoverWords(tokens, consumed), complete: false });
215
169
  for (const token of tokens) {
170
+ const index = position++;
216
171
  if (typeof token === "string") {
217
172
  if (redirect) {
218
173
  const target = token;
219
- if (redirect.write && target !== "/dev/null" && !/^\d+$/.test(target) && !target.startsWith("&"))
174
+ const expanded = nextExpansion();
175
+ // Heredoc delimiters are quote-removed, not expanded. Their bodies and
176
+ // boundaries remain governed by the dedicated heredoc analyzer.
177
+ uncertainRedirect ||= expanded && !["<<", "<<-"].includes(redirect.op);
178
+ const descriptorClose = target === "-" && [">&", "<&"].includes(redirect.op);
179
+ if (redirect.write && target !== "/dev/null" && !descriptorClose)
220
180
  current.write = true;
181
+ redirects.push(target);
182
+ consumed.add(index);
221
183
  redirect = undefined;
222
- } else current.words.push(token);
184
+ } else {
185
+ current.words.push(token);
186
+ current.globs.push(nextExpansion());
187
+ consumed.add(index);
188
+ }
223
189
  continue;
224
190
  }
225
191
  if ("comment" in token) continue;
226
- if (token.op === "glob") { current.words.push(token.pattern); continue; }
192
+ if (token.op === "glob" && token.pattern !== undefined) {
193
+ if (redirect) {
194
+ // A glob is still a redirection target, so it never becomes a command word.
195
+ nextExpansion();
196
+ uncertainRedirect = true;
197
+ redirects.push(token.pattern);
198
+ consumed.add(index);
199
+ if (redirect.write) current.write = true;
200
+ redirect = undefined;
201
+ } else {
202
+ nextExpansion();
203
+ current.words.push(token.pattern);
204
+ current.globs.push(true);
205
+ consumed.add(index);
206
+ }
207
+ continue;
208
+ }
227
209
  if (CONTROL_OPERATORS.has(token.op)) {
228
- current = { words: [], write: false };
210
+ // A control operator before the redirection target is invalid syntax.
211
+ if (redirect) return failed();
212
+ current = { words: [], globs: [], write: false };
229
213
  segments.push(current);
230
214
  continue;
231
215
  }
232
216
  if (REDIRECT_OPERATORS.has(token.op)) {
233
- if (current.words.length && /^\d+$/.test(current.words[current.words.length - 1])) current.words.pop();
234
- redirect = { write: WRITE_OPERATORS.has(token.op) };
217
+ // Descriptor prefixes (`2>` in `2>/dev/null`) are stripped from the source
218
+ // before parsing, so a numeric word here is an operand and must survive.
219
+ redirect = { write: WRITE_OPERATORS.has(token.op), op: token.op };
235
220
  continue;
236
221
  }
237
- return { decision: "ask" };
222
+ return failed();
223
+ }
224
+ return { segments, redirects, leftovers: leftoverWords(tokens, consumed), complete: redirect === undefined && !uncertainRedirect };
225
+ }
226
+
227
+ /** The same token-to-command representation used by the guard and residual scoring. */
228
+ function parsedLine(command: string): ParsedShellCommands {
229
+ const tokens = parseShell(command);
230
+ const parsed = lineSegments(tokens, wordExpansions(stripDescriptorPrefixes(command)));
231
+ return {
232
+ ...parsed,
233
+ operands: tokens.flatMap((token) => typeof token === "string" ? [token] :
234
+ "op" in token && token.op === "glob" && token.pattern !== undefined ? [token.pattern] : []),
235
+ complete: parsed.complete && !hasAmbiguousDescriptorClose(command) && !DYNAMIC_SYNTAX.test(command),
236
+ compound: tokens.some((token) => typeof token !== "string" && "op" in token && CONTROL_OPERATORS.has(token.op)),
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Bounded literal commands only. No heredoc/script interpretation or dynamic
242
+ * expansion is inferred here; callers must retain guardShell's manual floor.
243
+ * `limits.maxChars` lets the pre-ask deny scan parse an oversized command
244
+ * without changing the guard's own manual-approval limit.
245
+ */
246
+ export function parseShellCommands(command: string, limits: { maxChars?: number } = {}): ParsedShellCommands | undefined {
247
+ const maxChars = limits.maxChars ?? MAX_COMMAND_CHARS;
248
+ if (!command || command.length > maxChars || hasUndecodedSyntax(command)) return undefined;
249
+ const normalized = normalizeContinuations(command);
250
+ if (hasUndecodedSyntax(normalized) || hasHeredoc(normalized)) return undefined;
251
+ const { lines, certain } = logicalLines(normalized);
252
+ if (certain < lines.length) return undefined;
253
+ const result: ParsedShellCommands = { segments: [], operands: [], redirects: [], leftovers: [], complete: true, compound: false };
254
+ try {
255
+ for (const line of lines) {
256
+ if (!line.trim() || line.trim().startsWith("#")) continue;
257
+ if (!BALANCED_QUOTES.test(line)) return undefined;
258
+ const parsed = parsedLine(line);
259
+ result.segments.push(...parsed.segments);
260
+ result.operands.push(...parsed.operands);
261
+ result.redirects.push(...parsed.redirects);
262
+ result.leftovers.push(...parsed.leftovers);
263
+ result.complete &&= parsed.complete;
264
+ result.compound ||= parsed.compound;
265
+ }
266
+ } catch { return undefined; }
267
+ result.compound ||= result.segments.filter((segment) => segment.words.length).length > 1;
268
+ return result;
269
+ }
270
+
271
+ /**
272
+ * True when one parsed simple command reads or writes a protected path.
273
+ *
274
+ * The command's own grammar decides which words are files, which are data and
275
+ * which are ordinary options. A data word (`grep auth file`'s pattern, a `sed`
276
+ * script, a `jq` filter, a `--grep=` value) is never treated as a path, while a
277
+ * path word is checked with `isProtectedPathOperand` and every other word keeps
278
+ * the generic shell-word check.
279
+ */
280
+ export function protectedSegment(segment: { words: readonly string[] }, projectDir?: string): boolean {
281
+ const roles = operandRoles(segment.words);
282
+ const data = new Set(roles.data);
283
+ const paths = new Set(roles.paths);
284
+ if (roles.pathValues.some((value) => isProtectedPathOperand(value, projectDir))) return true;
285
+ return segment.words.some((word, index) => {
286
+ if (data.has(index)) return false;
287
+ return paths.has(index) ? isProtectedPathOperand(word, projectDir) : isProtectedShellWord(word, projectDir);
288
+ });
289
+ }
290
+
291
+ function analyzeLine(command: string, projectDir?: string): Verdict {
292
+ if (!command.trim()) return { decision: "allow" };
293
+ if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
294
+ if (FORK_BOMB.test(command)) return { decision: "deny", reason: "dangerous shell operation" };
295
+
296
+ let tokens;
297
+ try { tokens = parseShell(command); }
298
+ catch { return { decision: "ask" }; }
299
+
300
+ const { segments, redirects, leftovers, complete } = parsedLine(command);
301
+ // A redirection always names a file, so its target takes the full path-aware
302
+ // check. Each simple command is judged by its own grammar: a pattern, script
303
+ // or filter is data even when it is spelled like a path, while a file operand
304
+ // keeps the protected-path check. Tokens no command consumed (words inside a
305
+ // substitution) keep the generic shell-word check. None of this depends on the
306
+ // line parsing completely: a deny visible in the literal text must win over the
307
+ // manual-approval floor.
308
+ if (redirects.some((target) => isProtected(target, projectDir)) ||
309
+ segments.some((segment) => protectedSegment(segment, projectDir)) ||
310
+ leftovers.some((word) => isProtectedShellWord(word, projectDir)))
311
+ return { decision: "deny", reason: "protected path access" };
312
+ // Deny rules that are already visible must win even when later arguments are
313
+ // dynamic (`sudo id $(...)`) and even when the line only parses partially.
314
+ for (const segment of segments) {
315
+ const blocked = denyInvocation(segment.words);
316
+ if (blocked !== undefined) return { decision: "deny", reason: blocked };
238
317
  }
239
- if (redirect) return { decision: "ask" };
318
+ if (segments.some((segment) => ["curl", "wget"].includes(resolveExecutable(segment.words)?.name ?? "")) &&
319
+ segments.some((segment) => SHELLS.has(resolveExecutable(segment.words)?.name ?? "")) &&
320
+ tokens.some((token) => typeof token !== "string" && "op" in token && ["|", "|&"].includes(token.op)))
321
+ return { decision: "deny", reason: "remote download piped to a shell" };
322
+ // Bash closes the descriptor and runs the rest of the word as a command
323
+ // (`2>&-sudo id` runs sudo), while shell-quote reads `-sudo` as the redirect
324
+ // target. Neither reading may approve the invocation.
325
+ if (hasAmbiguousDescriptorClose(command)) return { decision: "ask" };
326
+ if (!complete || DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
240
327
 
241
328
  let ask = false;
242
329
  let review = false;
@@ -250,7 +337,27 @@ function analyzeLine(command: string): Verdict {
250
337
  return { decision: review ? "review" : "allow" };
251
338
  }
252
339
 
253
- function analyzeHeredoc(command: string): Verdict {
340
+ /** Analyzes every logical line, keeping deny precedence over ask and review. */
341
+ function analyzeLines(command: string, projectDir?: string): Verdict {
342
+ let ask = false;
343
+ let review = false;
344
+ const { lines, certain } = logicalLines(command);
345
+ // An undecodable header or a body that can move its terminator leaves the
346
+ // script/data split unknown, so nothing past that line may be auto-approved.
347
+ if (certain < lines.length) ask = true;
348
+ for (const line of lines) {
349
+ const trimmed = line.trim();
350
+ if (!trimmed || trimmed.startsWith("#")) continue;
351
+ const verdict = analyzeLine(trimmed, projectDir);
352
+ if (verdict.decision === "deny") return verdict;
353
+ if (verdict.decision === "ask") ask = true;
354
+ else if (verdict.decision === "review") review = true;
355
+ }
356
+ if (ask) return { decision: "ask" };
357
+ return { decision: review ? "review" : "allow" };
358
+ }
359
+
360
+ function analyzeHeredoc(command: string, projectDir?: string): Verdict {
254
361
  const lines = command.split("\n");
255
362
  const firstLine = lines[0] ?? "";
256
363
  const markers = heredocMarkers(firstLine);
@@ -270,51 +377,226 @@ function analyzeHeredoc(command: string): Verdict {
270
377
  bodies.push({ lines: body, quoted: marker.quoted });
271
378
  }
272
379
 
273
- let ask = false;
274
- let review = false;
275
- for (const line of [firstLine, ...lines.slice(index)]) {
276
- if (!line.trim()) continue;
277
- const verdict = analyzeLine(line);
278
- if (verdict.decision === "deny") return verdict;
279
- if (verdict.decision === "ask") ask = true;
280
- else if (verdict.decision === "review") review = true;
380
+ // The header line is script, and so is the text after the bodies — but only if
381
+ // the bodies really end where the raw text says. The header is analyzed on its
382
+ // own because its markers have already been consumed above: handing them back
383
+ // to the heredoc scanner would swallow the tail as a second body.
384
+ const header = analyzeLine(firstLine, projectDir);
385
+ if (header.decision === "deny") return header;
386
+ // An unquoted body is matched against its terminator after the shell removes
387
+ // continuations, so a body line ending in a backslash moves the boundary —
388
+ // including the last one, whose `\`-newline joins the terminator line into the
389
+ // body. That has to fail closed before the tail is judged, or a tail that is
390
+ // really body data would deny. A quoted body keeps its backslashes literally.
391
+ for (const body of bodies) {
392
+ if (body.quoted) continue;
393
+ if (body.lines.some((line) => line.endsWith("\\"))) return { decision: "ask" };
281
394
  }
395
+ // The tail is analyzed as its own script so a further heredoc there is routed
396
+ // the same way, and a blocked command spanning a quoted newline is still seen
397
+ // whole.
398
+ const tail = lines.slice(index).join("\n");
399
+ const rest: Verdict = tail ? analyze(tail, projectDir) : { decision: "allow" };
400
+ if (rest.decision === "deny") return rest;
401
+ let ask = header.decision === "ask" || rest.decision === "ask";
402
+ const review = header.decision === "review" || rest.decision === "review";
403
+ // Unquoted heredoc bodies undergo command and parameter expansion. That can run
404
+ // a substitution, so it fails closed, but it cannot move the terminator: the
405
+ // tail denied above stays a deny.
282
406
  for (const body of bodies) {
283
- // Unquoted heredoc bodies undergo command and parameter expansion.
284
- if (!body.quoted && DYNAMIC_SYNTAX.test(body.lines.join("\n"))) return { decision: "ask" };
407
+ if (body.quoted) continue;
408
+ if (DYNAMIC_SYNTAX.test(body.lines.join("\n"))) return { decision: "ask" };
285
409
  }
286
410
  try {
287
- const words = parse(firstLine).filter((token): token is string => typeof token === "string");
411
+ const words = parseShell(firstLine).filter((token): token is string => typeof token === "string");
288
412
  const executable = resolveExecutable(words);
289
413
  if (executable === undefined || SHELLS.has(executable.name) || SCRIPT_HOSTS.has(executable.name) ||
290
- DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) return { decision: "ask" };
414
+ DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) ask = true;
291
415
  } catch { return { decision: "ask" }; }
292
416
  if (ask) return { decision: "ask" };
293
417
  return { decision: review ? "review" : "allow" };
294
418
  }
295
419
 
296
- function analyze(command: string): Verdict {
297
- if (hasHeredoc(command)) return analyzeHeredoc(command);
298
- const blocked = blockedCommand(command);
299
- if (blocked !== undefined) return { decision: "deny", reason: blocked };
300
- if (command.includes("\n")) {
301
- let ask = false;
302
- let review = false;
303
- for (const line of command.split("\n")) {
304
- const trimmed = line.trim();
305
- if (!trimmed || trimmed.startsWith("#")) continue;
306
- const verdict = analyzeLine(trimmed);
420
+ /**
421
+ * True when a recursive or forced `rm` targets the user's home directory or a
422
+ * path outside the project.
423
+ *
424
+ * GNU option parsing accepts abbreviations, so `--recursi` and `--forc` are the
425
+ * same flags as `--recursive` and `--force`. `~` and `~/path` are expanded
426
+ * against the home directory and prefix-matched by containment; the home
427
+ * directory itself is always external, and another user's `~user` cannot be
428
+ * resolved locally, so it also stays external. `$HOME` is not expanded here, and
429
+ * the parser turns an unquoted `$HOME` into an empty word, so both spellings
430
+ * count as the home target.
431
+ */
432
+ export function recursiveExternalDelete(words: readonly string[], projectDir?: string, worktreeRoot = projectDir): boolean {
433
+ if (words[0] !== "rm") return false;
434
+ let risky = false;
435
+ let end = false;
436
+ const targets: string[] = [];
437
+ for (const word of words.slice(1)) {
438
+ if (!end && word === "--") { end = true; continue; }
439
+ if (!end && word.startsWith("--")) {
440
+ // Prefix matching covers `--recursi`/`--forc` and other unambiguous prefixes.
441
+ if (isLongOption(word, "--recursive") || isLongOption(word, "--force")) risky = true;
442
+ continue;
443
+ }
444
+ if (!end && word.startsWith("-") && word.length > 1) {
445
+ if (/^-[^-]*[rRf]/.test(word)) risky = true;
446
+ continue;
447
+ }
448
+ targets.push(word);
449
+ }
450
+ if (!risky || targets.length === 0) return false;
451
+ const home = homedir();
452
+ return targets.some((target) => {
453
+ // `$HOME` is not expanded here, and the parser turns an unquoted `$HOME`
454
+ // into an empty word; both spellings name the home directory.
455
+ if (!target || target.startsWith("$HOME") || target.startsWith("${HOME}")) return true;
456
+ if (target.startsWith("~")) {
457
+ // `~` and `~/path` expand against the home directory; another user's
458
+ // `~user` cannot be resolved locally and stays external.
459
+ const expanded = target === "~" || target === "~/" ? home
460
+ : target.startsWith("~/") ? resolve(home, target.slice(2)) : undefined;
461
+ if (expanded === undefined || expanded === home) return true;
462
+ return !projectDir || !worktreeRoot || !isInProject(expanded, worktreeRoot);
463
+ }
464
+ // Without a verified project there is no inside that a delete could be bounded to.
465
+ if (!projectDir || !worktreeRoot) return true;
466
+ return !isInProject(resolve(projectDir, target), worktreeRoot);
467
+ });
468
+ }
469
+
470
+ /**
471
+ * The literal prefix of a line that precedes syntax this policy does not decode.
472
+ * The marker and everything after it are never inspected, because an undecoded
473
+ * quote or expansion can move word and line boundaries.
474
+ */
475
+ function visiblePrefix(line: string): string {
476
+ let cut = line.length;
477
+ for (const opener of ["$'", '$"', "$["]) {
478
+ const index = line.indexOf(opener);
479
+ if (index !== -1 && index < cut) cut = index;
480
+ }
481
+ const whitespace = line.search(/[^\S \t]/);
482
+ if (whitespace !== -1 && whitespace < cut) cut = whitespace;
483
+ return line.slice(0, cut);
484
+ }
485
+
486
+ /** The executable name and effective argv after leading wrappers are removed. */
487
+ function effectiveInvocation(words: readonly string[]): { name: string; argv: string[] } {
488
+ const executable = resolveExecutable(words);
489
+ const argv = executable ? words.slice(executable.index) : [...words];
490
+ const name = basename(argv[0] ?? "");
491
+ return { name, argv: [name, ...argv.slice(1)] };
492
+ }
493
+
494
+ /**
495
+ * Mandatory denies visible before the syntax that makes the guard ask.
496
+ *
497
+ * The guard cannot approve text it cannot decode, but a deny it can already see
498
+ * must still win over that manual-approval floor: `cat .env # $'` is still a
499
+ * protected-path read, `sudo id\necho $'x'` still elevates, and `rm -rf
500
+ * /outside # $'` still deletes outside the project. Only the literal prefix of
501
+ * each line is judged; a line that is not certainly script, or whose prefix is
502
+ * not balanced and static, contributes no deny and keeps the ask.
503
+ *
504
+ * The protected-path, blocked-invocation and recursive-delete checks run on
505
+ * whatever the prefix parsed into; `complete` only gates the pipeline shape that
506
+ * needs the whole line. Otherwise a trailing `$(...)` or `$'...'` would erase a
507
+ * deny that is already visible.
508
+ */
509
+ export function visibleShellDeny(resources: readonly string[], projectDir?: string, worktreeRoot = projectDir): string | undefined {
510
+ for (const resource of resources) {
511
+ // Per-resource cap, not a shared budget: an earlier resource must not spend
512
+ // the bytes that would reveal a deny in a later one.
513
+ const text = resource.slice(0, MAX_COMMAND_BYTES);
514
+ const { lines, certain } = logicalLines(normalizeContinuations(text));
515
+ for (const line of lines.slice(0, certain)) {
516
+ const visible = visiblePrefix(line).trim();
517
+ if (!visible || visible.startsWith("#")) continue;
518
+ if (FORK_BOMB.test(visible)) return "dangerous shell operation";
519
+ const parsed = parseShellCommands(visible, { maxChars: MAX_COMMAND_BYTES });
520
+ if (parsed === undefined) continue;
521
+ if (parsed.redirects.some((target) => isProtected(target, projectDir)) ||
522
+ parsed.segments.some((segment) => protectedSegment(segment, projectDir)) ||
523
+ parsed.leftovers.some((word) => isProtectedShellWord(word, projectDir)))
524
+ return "protected path access";
525
+ const commands = parsed.segments.map((segment) => effectiveInvocation(segment.words));
526
+ for (const command of commands) {
527
+ const blocked = denyInvocation(command.argv);
528
+ if (blocked !== undefined) return blocked;
529
+ }
530
+ if (commands.some((command) => recursiveExternalDelete(command.argv, projectDir, worktreeRoot)))
531
+ return "Recursive/force deletion outside the project";
532
+ if (!parsed.complete) continue;
533
+ if (commands.some((command) => ["curl", "wget"].includes(command.name)) &&
534
+ commands.some((command) => SHELLS.has(command.name))) {
535
+ try {
536
+ if (parseShell(visible).some((token) => typeof token !== "string" && "op" in token &&
537
+ ["|", "|&"].includes(token.op))) return "remote download piped to a shell";
538
+ } catch { /* The prefix parse above already succeeded; treat an unknown pipe shape as no deny. */ }
539
+ }
540
+ }
541
+ }
542
+ return undefined;
543
+ }
544
+
545
+ function analyze(command: string, projectDir?: string): Verdict {
546
+ // A shell separates words only on space, tab, and newline. JS `\s` (and the
547
+ // parser behind it) also matches CR, FF, VT, NBSP, and Unicode spaces, which
548
+ // can move a heredoc boundary or turn a midword hash into a comment, so any of
549
+ // them fails closed before normalization, the deny pass, or parsing.
550
+ //
551
+ // `$[` is the legacy arithmetic expansion: the `<<` inside it is a shift, not
552
+ // a heredoc, so heredoc routing would treat the rest of the script as a body
553
+ // and swallow a trailing command. Extended quoting has the same problem from
554
+ // the other side: `$'...'` lets a backslash escape its own quote, so a quote
555
+ // scan can end the string early and read `<<EOF` as a heredoc, and `$"..."`
556
+ // is a locale quote this parser does not decode. Reject all three on the raw
557
+ // source before normalization or heredoc routing, conservatively including
558
+ // quoted and heredoc data.
559
+ if (hasUndecodedSyntax(command)) return { decision: "ask" };
560
+ // Fail closed on any raw heredoc header that normalization cannot be trusted
561
+ // on: the raw source is the only place its marker and delimiter are still
562
+ // intact. Every header is validated, not just the first, because a continued
563
+ // header after a stable body is the same problem. Each raw line before the
564
+ // ambiguity is certainly script — the header line itself runs before the shell
565
+ // reads its body — so a deny visible there is mandatory; everything after may
566
+ // be data and only reaches manual approval.
567
+ const raw = rawHeredocScript(command);
568
+ if (raw.ambiguous) {
569
+ for (const line of raw.lines) {
570
+ // A line whose own normalization assembles unsupported quoting cannot be
571
+ // parsed safely; its deny is left to manual approval instead of guessed.
572
+ if (hasAssembledUndecodedSyntax(line)) continue;
573
+ // The raw line may still carry a `\`-newline that the shell removes before
574
+ // it forms this header; removing it here only reaches the deny pass, and
575
+ // the header line itself is always script.
576
+ const verdict = analyzeLine(normalizeContinuations(line).trim(), projectDir);
307
577
  if (verdict.decision === "deny") return verdict;
308
- if (verdict.decision === "ask") ask = true;
309
- else if (verdict.decision === "review") review = true;
310
578
  }
311
- if (ask) return { decision: "ask" };
312
- return { decision: review ? "review" : "allow" };
579
+ return { decision: "ask" };
580
+ }
581
+ // Normalization removes `\`-newlines, so the shell can parse an unsupported
582
+ // form the raw check above could not see: `sort $`+newline+`'\x2doout'` is
583
+ // `sort -oout` to Bash once the pair is gone. The marker itself is never parsed.
584
+ // A deny in the clean prefix before it is still mandatory, so check that first;
585
+ // only then repeat the gate and fail closed before heredoc routing.
586
+ const normalized = normalizeContinuations(command);
587
+ if (hasUndecodedSyntax(normalized)) {
588
+ const blocked = denyCleanPrefix(normalized);
589
+ if (blocked !== undefined) return { decision: "deny", reason: blocked };
590
+ return { decision: "ask" };
313
591
  }
314
- return analyzeLine(command);
592
+ // `logicalLines` must split on the newlines the shell actually treats as
593
+ // separators, and heredoc bodies stay untouched.
594
+ if (hasHeredoc(normalized)) return analyzeHeredoc(normalized, projectDir);
595
+ if (normalized.includes("\n")) return analyzeLines(normalized, projectDir);
596
+ return analyzeLine(normalized, projectDir);
315
597
  }
316
598
 
317
- export function guardShell(resources: readonly string[]): ReviewResult | undefined {
599
+ export function guardShell(resources: readonly string[], projectDir?: string): ReviewResult | undefined {
318
600
  if (process.platform === "win32") return { decision: "ask", reason: "Shell dialect requires manual approval" };
319
601
  if (!resources.length || resources.length > 256 || resources.some((resource) => resource.length > MAX_COMMAND_CHARS) ||
320
602
  resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) > MAX_COMMAND_BYTES)
@@ -322,7 +604,7 @@ export function guardShell(resources: readonly string[]): ReviewResult | undefin
322
604
  let ask = false;
323
605
  let review = false;
324
606
  for (const resource of resources) {
325
- const verdict = analyze(resource);
607
+ const verdict = analyze(resource, projectDir);
326
608
  if (verdict.decision === "deny") return { decision: "deny", reason: verdict.reason };
327
609
  if (verdict.decision === "ask") ask = true;
328
610
  else if (verdict.decision === "review") review = true;
@@ -330,3 +612,4 @@ export function guardShell(resources: readonly string[]): ReviewResult | undefin
330
612
  if (ask) return { decision: "ask", reason: "Shell syntax requires manual approval" };
331
613
  return review ? undefined : { decision: "allow", reason: "read-only command" };
332
614
  }
615
+