@mortz.prk/opencode-auto-mode 1.0.1 → 1.0.2

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/shell.ts CHANGED
@@ -1,134 +1,29 @@
1
- import { parse } from "shell-quote";
2
- import { basename } from "node:path";
3
- import { classify } from "./tiers.js";
4
1
  import type { ReviewResult } from "./reviewer.js";
2
+ import {
3
+ BALANCED_QUOTES, CONTROL_OPERATORS, DYNAMIC, REDIRECT_OPERATORS, SCRIPT_HOSTS, SHELLS, STATEFUL,
4
+ denyCleanPrefix, denyInvocation, hasAmbiguousDescriptorClose, hasAssembledUndecodedSyntax, hasHeredoc,
5
+ hasUndecodedSyntax, heredocMarkers, isReadOnlyInvocation, logicalLines, normalizeContinuations, parseShell,
6
+ rawHeredocScript, resolveExecutable, stripDescriptorPrefixes,
7
+ } from "./shell-policy.js";
5
8
 
6
9
  const MAX_COMMAND_CHARS = 4096;
7
10
  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
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"]);
12
+ const DYNAMIC_SYNTAX = /\$\(|`|\$\(\s*\(|[<>]\(|\$\[/;
13
+ const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
26
14
 
27
15
  export function shellWords(command: string): string[] | undefined {
28
16
  if (!command || command.length > MAX_COMMAND_CHARS || /[$`~\r\n\0{}[\]]/.test(command) ||
29
17
  /[^\S \t]/.test(command) || !BALANCED_QUOTES.test(command)) return undefined;
30
18
  try {
31
- const words = parse(command);
19
+ const words = parseShell(command);
32
20
  return words.length && words.every((word) => typeof word === "string") ? words as string[] : undefined;
33
21
  } catch { return undefined; }
34
22
  }
35
23
 
36
- interface Segment { words: string[]; write: boolean }
24
+ interface Segment { words: string[]; globs: boolean[]; write: boolean }
37
25
  type Verdict = { decision: "deny"; reason: string } | { decision: "allow" | "review" | "ask" };
38
26
 
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
27
  /** True when the effective executable is a shell, a dynamic builtin, or a stateful builtin. */
133
28
  function dynamicExecution(words: string[]): boolean {
134
29
  const executable = resolveExecutable(words);
@@ -138,105 +33,184 @@ function dynamicExecution(words: string[]): boolean {
138
33
  .some((word) => /^-(?:exec|execdir|ok|okdir|delete)$/.test(word));
139
34
  }
140
35
 
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
36
  function analyzeSegment(segment: Segment): Verdict {
189
37
  if (!segment.words.length) return segment.write ? { decision: "review" } : { decision: "allow" };
190
38
  const command = segment.words[0];
191
39
  if (!command || command.includes("$")) return { decision: "ask" };
192
- const blocked = blockedCommand(segment.words.join(" "));
40
+ // A deny that is already identifiable wins over every uncertainty below, so
41
+ // `sudo id *.txt` stays a mandatory deny instead of becoming a review.
42
+ const blocked = denyInvocation(segment.words);
193
43
  if (blocked !== undefined) return { decision: "deny", reason: blocked };
194
44
  if (dynamicExecution(segment.words)) return { decision: "ask" };
195
- if (!segment.write && isReadOnly(segment.words)) return { decision: "allow" };
45
+ // `isReadOnlyInvocation` refuses any argument an unquoted expansion could turn
46
+ // into several words, so the literal flag policies only judge what will run.
47
+ if (!segment.write && isReadOnlyInvocation(segment.words, segment.globs)) return { decision: "allow" };
196
48
  return { decision: "review" };
197
49
  }
198
50
 
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 };
51
+ /** Token shapes this module consumes; `parse` returns a wider union. */
52
+ type Token = string | { comment: string } | { op: string; pattern?: string };
204
53
 
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" };
54
+ interface ParsedLine { segments: Segment[]; complete: boolean }
55
+
56
+ /** Characters that end an unquoted shell word. JS `\s` is wider than a shell, so the set is explicit. */
57
+ const WORD_DELIMITER = /[ \t\n;|&()<>]/;
58
+
59
+ /**
60
+ * One entry per shell word, true when the word contains an expansion that can
61
+ * turn it into several arguments.
62
+ *
63
+ * `shell-quote` only marks `*` and `?` as globs, but bracket (`[ab]`) and brace
64
+ * (`{a,b}`) expansion, and parameter expansion, change the argument count too.
65
+ * This scan is quote- and escape-aware, so quoted or escaped bracket, brace, and
66
+ * glob markers are not flagged here. `shell-quote` still reports an escaped
67
+ * `*`/`?` as a glob token, which stays conservatively reviewed.
68
+ *
69
+ * A parameter inside double quotes counts as uncertain for the same reason: it
70
+ * stays one word only after expansion, and the value decides both the text and —
71
+ * for `"$@"` — the count. `sort "${X:--oout}" file` writes a file when `X` is
72
+ * unset, `find . "${X:--delete}"` deletes, and `uniq "$@"` can become two
73
+ * operands, while `shell-quote` sees only an empty string. Single quotes and an
74
+ * escaped `\$` are literal and stay safe. Matching the flags by position keeps
75
+ * the raw text out of the parsed logic.
76
+ */
77
+ function wordExpansions(command: string): boolean[] {
78
+ const expansions: boolean[] = [];
79
+ let present = false;
80
+ let expansion = false;
81
+ let wordStart = true;
82
+ let quote: "'" | '"' | "$'" | undefined;
83
+ const flush = () => {
84
+ if (!present) return;
85
+ expansions.push(expansion);
86
+ present = false;
87
+ expansion = false;
88
+ };
89
+ for (let index = 0; index < command.length; index++) {
90
+ const character = command[index];
91
+ if (quote === "'") {
92
+ if (character === "'") quote = undefined;
93
+ continue;
94
+ }
95
+ if (quote === "$'") {
96
+ if (character === "\\") index++;
97
+ else if (character === "'") quote = undefined;
98
+ continue;
99
+ }
100
+ if (quote === '"') {
101
+ if (character === "\\") index++;
102
+ else if (character === '"') quote = undefined;
103
+ else if (character === "$") expansion = true;
104
+ continue;
105
+ }
106
+ if (character === "\\") {
107
+ present = true;
108
+ if (index + 1 < command.length && command[++index] !== "\n") wordStart = false;
109
+ continue;
110
+ }
111
+ if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
112
+ present = true;
113
+ wordStart = false;
114
+ if (character === "'" || character === '"') quote = character;
115
+ else { quote = "$'"; index++; }
116
+ continue;
117
+ }
118
+ if (character === "#" && wordStart) {
119
+ const end = command.indexOf("\n", index);
120
+ flush();
121
+ if (end === -1) break;
122
+ index = end;
123
+ wordStart = true;
124
+ continue;
125
+ }
126
+ if (WORD_DELIMITER.test(character)) {
127
+ flush();
128
+ wordStart = true;
129
+ continue;
130
+ }
131
+ if (character === "*" || character === "?" || character === "[" || character === "{" ||
132
+ character === "$") expansion = true;
133
+ present = true;
134
+ wordStart = false;
135
+ }
136
+ flush();
137
+ return expansions;
138
+ }
211
139
 
212
- const segments: Segment[] = [{ words: [], write: false }];
140
+ /** Splits parsed tokens into simple commands, tracking whether the whole line parsed. */
141
+ function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] = []): ParsedLine {
142
+ const segments: Segment[] = [{ words: [], globs: [], write: false }];
213
143
  let current = segments[0];
214
144
  let redirect: { write: boolean } | undefined;
145
+ let wordIndex = 0;
146
+ const nextExpansion = () => expansions[wordIndex++] ?? false;
215
147
  for (const token of tokens) {
216
148
  if (typeof token === "string") {
217
149
  if (redirect) {
218
150
  const target = token;
151
+ nextExpansion();
219
152
  if (redirect.write && target !== "/dev/null" && !/^\d+$/.test(target) && !target.startsWith("&"))
220
153
  current.write = true;
221
154
  redirect = undefined;
222
- } else current.words.push(token);
155
+ } else {
156
+ current.words.push(token);
157
+ current.globs.push(nextExpansion());
158
+ }
223
159
  continue;
224
160
  }
225
161
  if ("comment" in token) continue;
226
- if (token.op === "glob") { current.words.push(token.pattern); continue; }
162
+ if (token.op === "glob" && token.pattern !== undefined) {
163
+ if (redirect) {
164
+ // A glob is still a redirection target, so it never becomes a command word.
165
+ nextExpansion();
166
+ if (redirect.write) current.write = true;
167
+ redirect = undefined;
168
+ } else {
169
+ nextExpansion();
170
+ current.words.push(token.pattern);
171
+ current.globs.push(true);
172
+ }
173
+ continue;
174
+ }
227
175
  if (CONTROL_OPERATORS.has(token.op)) {
228
- current = { words: [], write: false };
176
+ // A control operator before the redirection target is invalid syntax.
177
+ if (redirect) return { segments, complete: false };
178
+ current = { words: [], globs: [], write: false };
229
179
  segments.push(current);
230
180
  continue;
231
181
  }
232
182
  if (REDIRECT_OPERATORS.has(token.op)) {
233
- if (current.words.length && /^\d+$/.test(current.words[current.words.length - 1])) current.words.pop();
183
+ // Descriptor prefixes (`2>` in `2>/dev/null`) are stripped from the source
184
+ // before parsing, so a numeric word here is an operand and must survive.
234
185
  redirect = { write: WRITE_OPERATORS.has(token.op) };
235
186
  continue;
236
187
  }
237
- return { decision: "ask" };
188
+ return { segments, complete: false };
238
189
  }
239
- if (redirect) return { decision: "ask" };
190
+ return { segments, complete: redirect === undefined };
191
+ }
192
+
193
+ function analyzeLine(command: string): Verdict {
194
+ if (!command.trim()) return { decision: "allow" };
195
+ if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
196
+ if (FORK_BOMB.test(command)) return { decision: "deny", reason: "dangerous shell operation" };
197
+
198
+ let tokens;
199
+ try { tokens = parseShell(command); }
200
+ catch { return { decision: "ask" }; }
201
+
202
+ const { segments, complete } = lineSegments(tokens, wordExpansions(stripDescriptorPrefixes(command)));
203
+ // Deny rules that are already visible must win even when later arguments are
204
+ // dynamic (`sudo id $(...)`) and even when the line only parses partially.
205
+ for (const segment of segments) {
206
+ const blocked = denyInvocation(segment.words);
207
+ if (blocked !== undefined) return { decision: "deny", reason: blocked };
208
+ }
209
+ // Bash closes the descriptor and runs the rest of the word as a command
210
+ // (`2>&-sudo id` runs sudo), while shell-quote reads `-sudo` as the redirect
211
+ // target. Neither reading may approve the invocation.
212
+ if (hasAmbiguousDescriptorClose(command)) return { decision: "ask" };
213
+ if (!complete || DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
240
214
 
241
215
  let ask = false;
242
216
  let review = false;
@@ -250,6 +224,26 @@ function analyzeLine(command: string): Verdict {
250
224
  return { decision: review ? "review" : "allow" };
251
225
  }
252
226
 
227
+ /** Analyzes every logical line, keeping deny precedence over ask and review. */
228
+ function analyzeLines(command: string): Verdict {
229
+ let ask = false;
230
+ let review = false;
231
+ const { lines, certain } = logicalLines(command);
232
+ // An undecodable header or a body that can move its terminator leaves the
233
+ // script/data split unknown, so nothing past that line may be auto-approved.
234
+ if (certain < lines.length) ask = true;
235
+ for (const line of lines) {
236
+ const trimmed = line.trim();
237
+ if (!trimmed || trimmed.startsWith("#")) continue;
238
+ const verdict = analyzeLine(trimmed);
239
+ if (verdict.decision === "deny") return verdict;
240
+ if (verdict.decision === "ask") ask = true;
241
+ else if (verdict.decision === "review") review = true;
242
+ }
243
+ if (ask) return { decision: "ask" };
244
+ return { decision: review ? "review" : "allow" };
245
+ }
246
+
253
247
  function analyzeHeredoc(command: string): Verdict {
254
248
  const lines = command.split("\n");
255
249
  const firstLine = lines[0] ?? "";
@@ -270,48 +264,98 @@ function analyzeHeredoc(command: string): Verdict {
270
264
  bodies.push({ lines: body, quoted: marker.quoted });
271
265
  }
272
266
 
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;
267
+ // The header line is script, and so is the text after the bodies — but only if
268
+ // the bodies really end where the raw text says. The header is analyzed on its
269
+ // own because its markers have already been consumed above: handing them back
270
+ // to the heredoc scanner would swallow the tail as a second body.
271
+ const header = analyzeLine(firstLine);
272
+ if (header.decision === "deny") return header;
273
+ // An unquoted body is matched against its terminator after the shell removes
274
+ // continuations, so a body line ending in a backslash moves the boundary —
275
+ // including the last one, whose `\`-newline joins the terminator line into the
276
+ // body. That has to fail closed before the tail is judged, or a tail that is
277
+ // really body data would deny. A quoted body keeps its backslashes literally.
278
+ for (const body of bodies) {
279
+ if (body.quoted) continue;
280
+ if (body.lines.some((line) => line.endsWith("\\"))) return { decision: "ask" };
281
281
  }
282
+ // The tail is analyzed as its own script so a further heredoc there is routed
283
+ // the same way, and a blocked command spanning a quoted newline is still seen
284
+ // whole.
285
+ const tail = lines.slice(index).join("\n");
286
+ const rest: Verdict = tail ? analyze(tail) : { decision: "allow" };
287
+ if (rest.decision === "deny") return rest;
288
+ let ask = header.decision === "ask" || rest.decision === "ask";
289
+ const review = header.decision === "review" || rest.decision === "review";
290
+ // Unquoted heredoc bodies undergo command and parameter expansion. That can run
291
+ // a substitution, so it fails closed, but it cannot move the terminator: the
292
+ // tail denied above stays a deny.
282
293
  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" };
294
+ if (body.quoted) continue;
295
+ if (DYNAMIC_SYNTAX.test(body.lines.join("\n"))) return { decision: "ask" };
285
296
  }
286
297
  try {
287
- const words = parse(firstLine).filter((token): token is string => typeof token === "string");
298
+ const words = parseShell(firstLine).filter((token): token is string => typeof token === "string");
288
299
  const executable = resolveExecutable(words);
289
300
  if (executable === undefined || SHELLS.has(executable.name) || SCRIPT_HOSTS.has(executable.name) ||
290
- DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) return { decision: "ask" };
301
+ DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) ask = true;
291
302
  } catch { return { decision: "ask" }; }
292
303
  if (ask) return { decision: "ask" };
293
304
  return { decision: review ? "review" : "allow" };
294
305
  }
295
306
 
296
307
  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);
308
+ // A shell separates words only on space, tab, and newline. JS `\s` (and the
309
+ // parser behind it) also matches CR, FF, VT, NBSP, and Unicode spaces, which
310
+ // can move a heredoc boundary or turn a midword hash into a comment, so any of
311
+ // them fails closed before normalization, the deny pass, or parsing.
312
+ //
313
+ // `$[` is the legacy arithmetic expansion: the `<<` inside it is a shift, not
314
+ // a heredoc, so heredoc routing would treat the rest of the script as a body
315
+ // and swallow a trailing command. Extended quoting has the same problem from
316
+ // the other side: `$'...'` lets a backslash escape its own quote, so a quote
317
+ // scan can end the string early and read `<<EOF` as a heredoc, and `$"..."`
318
+ // is a locale quote this parser does not decode. Reject all three on the raw
319
+ // source before normalization or heredoc routing, conservatively including
320
+ // quoted and heredoc data.
321
+ if (hasUndecodedSyntax(command)) return { decision: "ask" };
322
+ // Fail closed on any raw heredoc header that normalization cannot be trusted
323
+ // on: the raw source is the only place its marker and delimiter are still
324
+ // intact. Every header is validated, not just the first, because a continued
325
+ // header after a stable body is the same problem. Each raw line before the
326
+ // ambiguity is certainly script — the header line itself runs before the shell
327
+ // reads its body — so a deny visible there is mandatory; everything after may
328
+ // be data and only reaches manual approval.
329
+ const raw = rawHeredocScript(command);
330
+ if (raw.ambiguous) {
331
+ for (const line of raw.lines) {
332
+ // A line whose own normalization assembles unsupported quoting cannot be
333
+ // parsed safely; its deny is left to manual approval instead of guessed.
334
+ if (hasAssembledUndecodedSyntax(line)) continue;
335
+ // The raw line may still carry a `\`-newline that the shell removes before
336
+ // it forms this header; removing it here only reaches the deny pass, and
337
+ // the header line itself is always script.
338
+ const verdict = analyzeLine(normalizeContinuations(line).trim());
307
339
  if (verdict.decision === "deny") return verdict;
308
- if (verdict.decision === "ask") ask = true;
309
- else if (verdict.decision === "review") review = true;
310
340
  }
311
- if (ask) return { decision: "ask" };
312
- return { decision: review ? "review" : "allow" };
341
+ return { decision: "ask" };
313
342
  }
314
- return analyzeLine(command);
343
+ // Normalization removes `\`-newlines, so the shell can parse an unsupported
344
+ // form the raw check above could not see: `sort $`+newline+`'\x2doout'` is
345
+ // `sort -oout` to Bash once the pair is gone. The marker itself is never parsed.
346
+ // A deny in the clean prefix before it is still mandatory, so check that first;
347
+ // only then repeat the gate and fail closed before heredoc routing.
348
+ const normalized = normalizeContinuations(command);
349
+ if (hasUndecodedSyntax(normalized)) {
350
+ const blocked = denyCleanPrefix(normalized);
351
+ if (blocked !== undefined) return { decision: "deny", reason: blocked };
352
+ return { decision: "ask" };
353
+ }
354
+ // `logicalLines` must split on the newlines the shell actually treats as
355
+ // separators, and heredoc bodies stay untouched.
356
+ if (hasHeredoc(normalized)) return analyzeHeredoc(normalized);
357
+ if (normalized.includes("\n")) return analyzeLines(normalized);
358
+ return analyzeLine(normalized);
315
359
  }
316
360
 
317
361
  export function guardShell(resources: readonly string[]): ReviewResult | undefined {
@@ -330,3 +374,22 @@ export function guardShell(resources: readonly string[]): ReviewResult | undefin
330
374
  if (ask) return { decision: "ask", reason: "Shell syntax requires manual approval" };
331
375
  return review ? undefined : { decision: "allow", reason: "read-only command" };
332
376
  }
377
+
378
+ export interface ShellClassification {
379
+ tier: "auto-allow" | "auto-deny" | "review";
380
+ reason: string;
381
+ }
382
+
383
+ /**
384
+ * Static tier classification for shell actions. The shell guard is the single
385
+ * source of truth so the classifier can never allow or deny something the guard
386
+ * would not.
387
+ */
388
+ export function classifyShell(resources: readonly string[]): ShellClassification {
389
+ const verdict = guardShell(resources);
390
+ if (verdict?.decision === "deny") return { tier: "auto-deny", reason: verdict.reason };
391
+ if (verdict?.decision === "allow") return { tier: "auto-allow", reason: verdict.reason };
392
+ if (verdict?.decision === "ask") return { tier: "review", reason: "shell syntax requires manual approval" };
393
+ return { tier: "review", reason: "evaluate the entire command as one action" };
394
+ }
395
+
package/src/status.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { AutoModeDecision, AutoModeStatus } from "./rpc.js";
2
+
3
+ const MAX_RECENT = 100;
4
+ // Match the audit record bounds so live status and durable history describe
5
+ // the same action, not an unbounded copy of the permission event.
6
+ const MAX_ACTION_CHARS = 128;
7
+ const MAX_STAGE_CHARS = 64;
8
+ const MAX_REASON_CHARS = 200;
9
+ const MAX_REVIEWING = 256;
10
+
11
+ function clip(value: string, max = MAX_REASON_CHARS): string {
12
+ // Count code points, not UTF-16 units, so clipping cannot cut a surrogate
13
+ // pair in half.
14
+ const characters = Array.from(value);
15
+ return characters.length > max ? `${characters.slice(0, max - 1).join("")}…` : value;
16
+ }
17
+
18
+ export interface StatusTracker {
19
+ start(sessionID: string, reviewID: string, at: number): void;
20
+ finish(decision: Omit<AutoModeDecision, "reason"> & { reason: string }): AutoModeDecision;
21
+ snapshot(input: { enabled: boolean; configError?: string; classifiers: string[]; reviewTimeoutMs: number; sessionID?: string }): AutoModeStatus;
22
+ recent(limit: number, sessionID?: string): AutoModeDecision[];
23
+ }
24
+
25
+ /**
26
+ * Small in-memory view of AutoMode for clients. It holds no prompts and is
27
+ * bounded in both entries and text so a long session cannot grow it without
28
+ * limit; durable history stays in the audit log.
29
+ */
30
+ export function createStatusTracker(): StatusTracker {
31
+ // Keyed by review rather than session: a session can ask about several
32
+ // actions at once, and finishing one must not hide the others.
33
+ const reviewing = new Map<string, { sessionID: string; startedAt: number }>();
34
+ let recent: AutoModeDecision[] = [];
35
+
36
+ return {
37
+ start(sessionID, reviewID, at) {
38
+ if (reviewing.size >= MAX_REVIEWING && !reviewing.has(reviewID)) {
39
+ const oldest = reviewing.keys().next().value;
40
+ if (oldest !== undefined) reviewing.delete(oldest);
41
+ }
42
+ reviewing.set(reviewID, { sessionID, startedAt: at });
43
+ },
44
+ finish(decision) {
45
+ reviewing.delete(decision.reviewID);
46
+ const entry: AutoModeDecision = {
47
+ ...decision,
48
+ action: clip(decision.action, MAX_ACTION_CHARS),
49
+ stage: clip(decision.stage, MAX_STAGE_CHARS),
50
+ reason: clip(decision.reason),
51
+ };
52
+ recent = [...recent, entry].slice(-MAX_RECENT);
53
+ return entry;
54
+ },
55
+ snapshot(input) {
56
+ return {
57
+ enabled: input.enabled,
58
+ ...(input.configError === undefined ? {} : { configError: clip(input.configError) }),
59
+ classifiers: input.classifiers,
60
+ reviewTimeoutMs: input.reviewTimeoutMs,
61
+ reviewing: [...reviewing.entries()]
62
+ .filter(([, entry]) => input.sessionID === undefined || entry.sessionID === input.sessionID)
63
+ .map(([reviewID, entry]) => ({ sessionID: entry.sessionID, reviewID, startedAt: entry.startedAt })),
64
+ recent: input.sessionID === undefined ? recent.slice(-20) : recent.filter((entry) => entry.sessionID === input.sessionID).slice(-20),
65
+ };
66
+ },
67
+ recent(limit, sessionID) {
68
+ const matches = sessionID === undefined ? recent : recent.filter((entry) => entry.sessionID === sessionID);
69
+ return matches.slice(-limit);
70
+ },
71
+ };
72
+ }