@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.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,26 +1,20 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, resolve } from "node:path";
1
3
  import type { ReviewResult } from "./reviewer.js";
4
+ import { isInProject, isProtected, isProtectedPathOperand, isProtectedShellWord } from "./tiers.js";
2
5
  import {
3
6
  BALANCED_QUOTES, CONTROL_OPERATORS, DYNAMIC, REDIRECT_OPERATORS, SCRIPT_HOSTS, SHELLS, STATEFUL,
4
7
  denyCleanPrefix, denyInvocation, hasAmbiguousDescriptorClose, hasAssembledUndecodedSyntax, hasHeredoc,
5
- hasUndecodedSyntax, heredocMarkers, isReadOnlyInvocation, logicalLines, normalizeContinuations, parseShell,
6
- rawHeredocScript, resolveExecutable, stripDescriptorPrefixes,
8
+ hasUndecodedSyntax, heredocMarkers, isLongOption, isReadOnlyInvocation, logicalLines, normalizeContinuations,
9
+ operandRoles, parseShell, rawHeredocScript, resolveExecutable, stripDescriptorPrefixes,
7
10
  } from "./shell-policy.js";
8
11
 
9
12
  const MAX_COMMAND_CHARS = 4096;
10
13
  const MAX_COMMAND_BYTES = 65_536;
11
- const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
14
+ const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>", ">&"]);
12
15
  const DYNAMIC_SYNTAX = /\$\(|`|\$\(\s*\(|[<>]\(|\$\[/;
13
16
  const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
14
17
 
15
- export function shellWords(command: string): string[] | undefined {
16
- if (!command || command.length > MAX_COMMAND_CHARS || /[$`~\r\n\0{}[\]]/.test(command) ||
17
- /[^\S \t]/.test(command) || !BALANCED_QUOTES.test(command)) return undefined;
18
- try {
19
- const words = parseShell(command);
20
- return words.length && words.every((word) => typeof word === "string") ? words as string[] : undefined;
21
- } catch { return undefined; }
22
- }
23
-
24
18
  interface Segment { words: string[]; globs: boolean[]; write: boolean }
25
19
  type Verdict = { decision: "deny"; reason: string } | { decision: "allow" | "review" | "ask" };
26
20
 
@@ -51,7 +45,20 @@ function analyzeSegment(segment: Segment): Verdict {
51
45
  /** Token shapes this module consumes; `parse` returns a wider union. */
52
46
  type Token = string | { comment: string } | { op: string; pattern?: string };
53
47
 
54
- interface ParsedLine { segments: Segment[]; complete: boolean }
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 }
55
62
 
56
63
  /** Characters that end an unquoted shell word. JS `\s` is wider than a shell, so the set is explicit. */
57
64
  const WORD_DELIMITER = /[ \t\n;|&()<>]/;
@@ -137,24 +144,47 @@ function wordExpansions(command: string): boolean[] {
137
144
  return expansions;
138
145
  }
139
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
+ }
156
+
140
157
  /** Splits parsed tokens into simple commands, tracking whether the whole line parsed. */
141
158
  function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] = []): ParsedLine {
142
159
  const segments: Segment[] = [{ words: [], globs: [], write: false }];
160
+ const redirects: string[] = [];
161
+ const consumed = new Set<number>();
143
162
  let current = segments[0];
144
- let redirect: { write: boolean } | undefined;
163
+ let redirect: { write: boolean; op: string } | undefined;
164
+ let uncertainRedirect = false;
145
165
  let wordIndex = 0;
166
+ let position = 0;
146
167
  const nextExpansion = () => expansions[wordIndex++] ?? false;
168
+ const failed = (): ParsedLine => ({ segments, redirects, leftovers: leftoverWords(tokens, consumed), complete: false });
147
169
  for (const token of tokens) {
170
+ const index = position++;
148
171
  if (typeof token === "string") {
149
172
  if (redirect) {
150
173
  const target = token;
151
- nextExpansion();
152
- 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)
153
180
  current.write = true;
181
+ redirects.push(target);
182
+ consumed.add(index);
154
183
  redirect = undefined;
155
184
  } else {
156
185
  current.words.push(token);
157
186
  current.globs.push(nextExpansion());
187
+ consumed.add(index);
158
188
  }
159
189
  continue;
160
190
  }
@@ -163,18 +193,22 @@ function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] =
163
193
  if (redirect) {
164
194
  // A glob is still a redirection target, so it never becomes a command word.
165
195
  nextExpansion();
196
+ uncertainRedirect = true;
197
+ redirects.push(token.pattern);
198
+ consumed.add(index);
166
199
  if (redirect.write) current.write = true;
167
200
  redirect = undefined;
168
201
  } else {
169
202
  nextExpansion();
170
203
  current.words.push(token.pattern);
171
204
  current.globs.push(true);
205
+ consumed.add(index);
172
206
  }
173
207
  continue;
174
208
  }
175
209
  if (CONTROL_OPERATORS.has(token.op)) {
176
210
  // A control operator before the redirection target is invalid syntax.
177
- if (redirect) return { segments, complete: false };
211
+ if (redirect) return failed();
178
212
  current = { words: [], globs: [], write: false };
179
213
  segments.push(current);
180
214
  continue;
@@ -182,15 +216,85 @@ function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] =
182
216
  if (REDIRECT_OPERATORS.has(token.op)) {
183
217
  // Descriptor prefixes (`2>` in `2>/dev/null`) are stripped from the source
184
218
  // before parsing, so a numeric word here is an operand and must survive.
185
- redirect = { write: WRITE_OPERATORS.has(token.op) };
219
+ redirect = { write: WRITE_OPERATORS.has(token.op), op: token.op };
186
220
  continue;
187
221
  }
188
- return { segments, complete: false };
222
+ return failed();
189
223
  }
190
- return { segments, complete: redirect === undefined };
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, includeGit = true): 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, includeGit))) return true;
285
+ return segment.words.some((word, index) => {
286
+ if (data.has(index)) return false;
287
+ return paths.has(index) ? isProtectedPathOperand(word, projectDir, includeGit) : isProtectedShellWord(word, projectDir, includeGit);
288
+ });
191
289
  }
192
290
 
193
- function analyzeLine(command: string): Verdict {
291
+ function protectedCommands(parsed: ParsedLine, projectDir?: string, includeGit = true): boolean {
292
+ return parsed.redirects.some((target) => isProtected(target, projectDir, includeGit)) ||
293
+ parsed.segments.some((segment) => protectedSegment(segment, projectDir, includeGit)) ||
294
+ parsed.leftovers.some((word) => isProtectedShellWord(word, projectDir, includeGit));
295
+ }
296
+
297
+ function analyzeLine(command: string, projectDir?: string): Verdict {
194
298
  if (!command.trim()) return { decision: "allow" };
195
299
  if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
196
300
  if (FORK_BOMB.test(command)) return { decision: "deny", reason: "dangerous shell operation" };
@@ -199,13 +303,26 @@ function analyzeLine(command: string): Verdict {
199
303
  try { tokens = parseShell(command); }
200
304
  catch { return { decision: "ask" }; }
201
305
 
202
- const { segments, complete } = lineSegments(tokens, wordExpansions(stripDescriptorPrefixes(command)));
306
+ const { segments, redirects, leftovers, complete } = parsedLine(command);
307
+ // A redirection always names a file, so its target takes the full path-aware
308
+ // check. Each simple command is judged by its own grammar: a pattern, script
309
+ // or filter is data even when it is spelled like a path, while a file operand
310
+ // keeps the protected-path check. Tokens no command consumed (words inside a
311
+ // substitution) keep the generic shell-word check. None of this depends on the
312
+ // line parsing completely: a deny visible in the literal text must win over the
313
+ // parser uncertainty.
314
+ if (protectedCommands({ segments, redirects, leftovers, complete }, projectDir, false))
315
+ return { decision: "deny", reason: "protected path access" };
203
316
  // Deny rules that are already visible must win even when later arguments are
204
317
  // dynamic (`sudo id $(...)`) and even when the line only parses partially.
205
318
  for (const segment of segments) {
206
319
  const blocked = denyInvocation(segment.words);
207
320
  if (blocked !== undefined) return { decision: "deny", reason: blocked };
208
321
  }
322
+ if (segments.some((segment) => ["curl", "wget"].includes(resolveExecutable(segment.words)?.name ?? "")) &&
323
+ segments.some((segment) => SHELLS.has(resolveExecutable(segment.words)?.name ?? "")) &&
324
+ tokens.some((token) => typeof token !== "string" && "op" in token && ["|", "|&"].includes(token.op)))
325
+ return { decision: "deny", reason: "remote download piped to a shell" };
209
326
  // Bash closes the descriptor and runs the rest of the word as a command
210
327
  // (`2>&-sudo id` runs sudo), while shell-quote reads `-sudo` as the redirect
211
328
  // target. Neither reading may approve the invocation.
@@ -213,7 +330,7 @@ function analyzeLine(command: string): Verdict {
213
330
  if (!complete || DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
214
331
 
215
332
  let ask = false;
216
- let review = false;
333
+ let review = protectedCommands({ segments, redirects, leftovers, complete }, projectDir);
217
334
  for (const segment of segments) {
218
335
  const verdict = analyzeSegment(segment);
219
336
  if (verdict.decision === "deny") return verdict;
@@ -225,7 +342,7 @@ function analyzeLine(command: string): Verdict {
225
342
  }
226
343
 
227
344
  /** Analyzes every logical line, keeping deny precedence over ask and review. */
228
- function analyzeLines(command: string): Verdict {
345
+ function analyzeLines(command: string, projectDir?: string): Verdict {
229
346
  let ask = false;
230
347
  let review = false;
231
348
  const { lines, certain } = logicalLines(command);
@@ -235,7 +352,7 @@ function analyzeLines(command: string): Verdict {
235
352
  for (const line of lines) {
236
353
  const trimmed = line.trim();
237
354
  if (!trimmed || trimmed.startsWith("#")) continue;
238
- const verdict = analyzeLine(trimmed);
355
+ const verdict = analyzeLine(trimmed, projectDir);
239
356
  if (verdict.decision === "deny") return verdict;
240
357
  if (verdict.decision === "ask") ask = true;
241
358
  else if (verdict.decision === "review") review = true;
@@ -244,7 +361,7 @@ function analyzeLines(command: string): Verdict {
244
361
  return { decision: review ? "review" : "allow" };
245
362
  }
246
363
 
247
- function analyzeHeredoc(command: string): Verdict {
364
+ function analyzeHeredoc(command: string, projectDir?: string): Verdict {
248
365
  const lines = command.split("\n");
249
366
  const firstLine = lines[0] ?? "";
250
367
  const markers = heredocMarkers(firstLine);
@@ -268,7 +385,7 @@ function analyzeHeredoc(command: string): Verdict {
268
385
  // the bodies really end where the raw text says. The header is analyzed on its
269
386
  // own because its markers have already been consumed above: handing them back
270
387
  // to the heredoc scanner would swallow the tail as a second body.
271
- const header = analyzeLine(firstLine);
388
+ const header = analyzeLine(firstLine, projectDir);
272
389
  if (header.decision === "deny") return header;
273
390
  // An unquoted body is matched against its terminator after the shell removes
274
391
  // continuations, so a body line ending in a backslash moves the boundary —
@@ -283,7 +400,7 @@ function analyzeHeredoc(command: string): Verdict {
283
400
  // the same way, and a blocked command spanning a quoted newline is still seen
284
401
  // whole.
285
402
  const tail = lines.slice(index).join("\n");
286
- const rest: Verdict = tail ? analyze(tail) : { decision: "allow" };
403
+ const rest: Verdict = tail ? analyze(tail, projectDir) : { decision: "allow" };
287
404
  if (rest.decision === "deny") return rest;
288
405
  let ask = header.decision === "ask" || rest.decision === "ask";
289
406
  const review = header.decision === "review" || rest.decision === "review";
@@ -304,7 +421,130 @@ function analyzeHeredoc(command: string): Verdict {
304
421
  return { decision: review ? "review" : "allow" };
305
422
  }
306
423
 
307
- function analyze(command: string): Verdict {
424
+ /**
425
+ * True when a recursive or forced `rm` targets the user's home directory or a
426
+ * path outside the project.
427
+ *
428
+ * GNU option parsing accepts abbreviations, so `--recursi` and `--forc` are the
429
+ * same flags as `--recursive` and `--force`. `~` and `~/path` are expanded
430
+ * against the home directory and prefix-matched by containment; the home
431
+ * directory itself is always external, and another user's `~user` cannot be
432
+ * resolved locally, so it also stays external. `$HOME` is not expanded here, and
433
+ * the parser turns an unquoted `$HOME` into an empty word, so both spellings
434
+ * count as the home target.
435
+ */
436
+ export function recursiveExternalDelete(words: readonly string[], projectDir?: string, worktreeRoot = projectDir): boolean {
437
+ if (words[0] !== "rm") return false;
438
+ let risky = false;
439
+ let end = false;
440
+ const targets: string[] = [];
441
+ for (const word of words.slice(1)) {
442
+ if (!end && word === "--") { end = true; continue; }
443
+ if (!end && word.startsWith("--")) {
444
+ // Prefix matching covers `--recursi`/`--forc` and other unambiguous prefixes.
445
+ if (isLongOption(word, "--recursive") || isLongOption(word, "--force")) risky = true;
446
+ continue;
447
+ }
448
+ if (!end && word.startsWith("-") && word.length > 1) {
449
+ if (/^-[^-]*[rRf]/.test(word)) risky = true;
450
+ continue;
451
+ }
452
+ targets.push(word);
453
+ }
454
+ if (!risky || targets.length === 0) return false;
455
+ const home = homedir();
456
+ return targets.some((target) => {
457
+ // `$HOME` is not expanded here, and the parser turns an unquoted `$HOME`
458
+ // into an empty word; both spellings name the home directory.
459
+ if (!target || target.startsWith("$HOME") || target.startsWith("${HOME}")) return true;
460
+ if (target.startsWith("~")) {
461
+ // `~` and `~/path` expand against the home directory; another user's
462
+ // `~user` cannot be resolved locally and stays external.
463
+ const expanded = target === "~" || target === "~/" ? home
464
+ : target.startsWith("~/") ? resolve(home, target.slice(2)) : undefined;
465
+ if (expanded === undefined || expanded === home) return true;
466
+ return !projectDir || !worktreeRoot || !isInProject(expanded, worktreeRoot);
467
+ }
468
+ // Without a verified project there is no inside that a delete could be bounded to.
469
+ if (!projectDir || !worktreeRoot) return true;
470
+ return !isInProject(resolve(projectDir, target), worktreeRoot);
471
+ });
472
+ }
473
+
474
+ /**
475
+ * The literal prefix of a line that precedes syntax this policy does not decode.
476
+ * The marker and everything after it are never inspected, because an undecoded
477
+ * quote or expansion can move word and line boundaries.
478
+ */
479
+ function visiblePrefix(line: string): string {
480
+ let cut = line.length;
481
+ for (const opener of ["$'", '$"', "$["]) {
482
+ const index = line.indexOf(opener);
483
+ if (index !== -1 && index < cut) cut = index;
484
+ }
485
+ const whitespace = line.search(/[^\S \t]/);
486
+ if (whitespace !== -1 && whitespace < cut) cut = whitespace;
487
+ return line.slice(0, cut);
488
+ }
489
+
490
+ /** The executable name and effective argv after leading wrappers are removed. */
491
+ function effectiveInvocation(words: readonly string[]): { name: string; argv: string[] } {
492
+ const executable = resolveExecutable(words);
493
+ const argv = executable ? words.slice(executable.index) : [...words];
494
+ const name = basename(argv[0] ?? "");
495
+ return { name, argv: [name, ...argv.slice(1)] };
496
+ }
497
+
498
+ /**
499
+ * Mandatory denies visible before the syntax that makes the guard ask.
500
+ *
501
+ * The guard cannot approve text it cannot decode, but a deny it can already see
502
+ * must still win over that manual-approval floor: `cat .env # $'` is still a
503
+ * protected-path read, `sudo id\necho $'x'` still elevates, and `rm -rf
504
+ * /outside # $'` still deletes outside the project. Only the literal prefix of
505
+ * each line is judged; a line that is not certainly script, or whose prefix is
506
+ * not balanced and static, contributes no deny and keeps the ask.
507
+ *
508
+ * The protected-path, blocked-invocation and recursive-delete checks run on
509
+ * whatever the prefix parsed into; `complete` only gates the pipeline shape that
510
+ * needs the whole line. Otherwise a trailing `$(...)` or `$'...'` would erase a
511
+ * deny that is already visible.
512
+ */
513
+ export function visibleShellDeny(resources: readonly string[], projectDir?: string, worktreeRoot = projectDir): string | undefined {
514
+ for (const resource of resources) {
515
+ // Per-resource cap, not a shared budget: an earlier resource must not spend
516
+ // the bytes that would reveal a deny in a later one.
517
+ const text = resource.slice(0, MAX_COMMAND_BYTES);
518
+ const { lines, certain } = logicalLines(normalizeContinuations(text));
519
+ for (const line of lines.slice(0, certain)) {
520
+ const visible = visiblePrefix(line).trim();
521
+ if (!visible || visible.startsWith("#")) continue;
522
+ if (FORK_BOMB.test(visible)) return "dangerous shell operation";
523
+ const parsed = parseShellCommands(visible, { maxChars: MAX_COMMAND_BYTES });
524
+ if (parsed === undefined) continue;
525
+ if (protectedCommands(parsed, projectDir, false))
526
+ return "protected path access";
527
+ const commands = parsed.segments.map((segment) => effectiveInvocation(segment.words));
528
+ for (const command of commands) {
529
+ const blocked = denyInvocation(command.argv);
530
+ if (blocked !== undefined) return blocked;
531
+ }
532
+ if (commands.some((command) => recursiveExternalDelete(command.argv, projectDir, worktreeRoot)))
533
+ return "Recursive/force deletion outside the project";
534
+ if (!parsed.complete) continue;
535
+ if (commands.some((command) => ["curl", "wget"].includes(command.name)) &&
536
+ commands.some((command) => SHELLS.has(command.name))) {
537
+ try {
538
+ if (parseShell(visible).some((token) => typeof token !== "string" && "op" in token &&
539
+ ["|", "|&"].includes(token.op))) return "remote download piped to a shell";
540
+ } catch { /* The prefix parse above already succeeded; treat an unknown pipe shape as no deny. */ }
541
+ }
542
+ }
543
+ }
544
+ return undefined;
545
+ }
546
+
547
+ function analyze(command: string, projectDir?: string): Verdict {
308
548
  // A shell separates words only on space, tab, and newline. JS `\s` (and the
309
549
  // parser behind it) also matches CR, FF, VT, NBSP, and Unicode spaces, which
310
550
  // can move a heredoc boundary or turn a midword hash into a comment, so any of
@@ -335,7 +575,7 @@ function analyze(command: string): Verdict {
335
575
  // The raw line may still carry a `\`-newline that the shell removes before
336
576
  // it forms this header; removing it here only reaches the deny pass, and
337
577
  // the header line itself is always script.
338
- const verdict = analyzeLine(normalizeContinuations(line).trim());
578
+ const verdict = analyzeLine(normalizeContinuations(line).trim(), projectDir);
339
579
  if (verdict.decision === "deny") return verdict;
340
580
  }
341
581
  return { decision: "ask" };
@@ -353,12 +593,12 @@ function analyze(command: string): Verdict {
353
593
  }
354
594
  // `logicalLines` must split on the newlines the shell actually treats as
355
595
  // 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);
596
+ if (hasHeredoc(normalized)) return analyzeHeredoc(normalized, projectDir);
597
+ if (normalized.includes("\n")) return analyzeLines(normalized, projectDir);
598
+ return analyzeLine(normalized, projectDir);
359
599
  }
360
600
 
361
- export function guardShell(resources: readonly string[]): ReviewResult | undefined {
601
+ export function guardShell(resources: readonly string[], projectDir?: string): ReviewResult | undefined {
362
602
  if (process.platform === "win32") return { decision: "ask", reason: "Shell dialect requires manual approval" };
363
603
  if (!resources.length || resources.length > 256 || resources.some((resource) => resource.length > MAX_COMMAND_CHARS) ||
364
604
  resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) > MAX_COMMAND_BYTES)
@@ -366,30 +606,11 @@ export function guardShell(resources: readonly string[]): ReviewResult | undefin
366
606
  let ask = false;
367
607
  let review = false;
368
608
  for (const resource of resources) {
369
- const verdict = analyze(resource);
609
+ const verdict = analyze(resource, projectDir);
370
610
  if (verdict.decision === "deny") return { decision: "deny", reason: verdict.reason };
371
611
  if (verdict.decision === "ask") ask = true;
372
612
  else if (verdict.decision === "review") review = true;
373
613
  }
374
- if (ask) return { decision: "ask", reason: "Shell syntax requires manual approval" };
614
+ if (ask) return { decision: "ask", reason: "Shell syntax requires further review" };
375
615
  return review ? undefined : { decision: "allow", reason: "read-only command" };
376
616
  }
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 CHANGED
@@ -1,41 +1,51 @@
1
- import type { AutoModeDecision, AutoModeStatus } from "./rpc.js";
1
+ import {
2
+ MAX_STATUS_ACTION_CHARS,
3
+ MAX_STATUS_REASON_CHARS,
4
+ MAX_STATUS_REVIEWING,
5
+ MAX_STATUS_STAGE_CHARS,
6
+ type AutoModeCounts,
7
+ type AutoModeDecision,
8
+ type AutoModeErrorKind,
9
+ type AutoModeMode,
10
+ type AutoModeStatus,
11
+ } from "./rpc.js";
2
12
 
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.
13
+ function clip(value: string, max = MAX_STATUS_REASON_CHARS): string {
14
+ // Count code points so clipping cannot split a surrogate pair.
14
15
  const characters = Array.from(value);
15
16
  return characters.length > max ? `${characters.slice(0, max - 1).join("")}…` : value;
16
17
  }
17
18
 
19
+ function counts(value: AutoModeCounts | undefined): AutoModeCounts {
20
+ // Rebuild exactly three counters so arbitrary stored/transport keys never leak.
21
+ return { allow: value?.allow ?? 0, ask: value?.ask ?? 0, deny: value?.deny ?? 0 };
22
+ }
23
+
24
+ export interface StatusSnapshotInput {
25
+ enabled: boolean;
26
+ mode: AutoModeMode;
27
+ configError?: string;
28
+ errorKind?: AutoModeErrorKind;
29
+ reviewTimeoutMs: number;
30
+ sessionID?: string;
31
+ /** Today's audit counts, supplied by the server rather than accumulated in UI memory. */
32
+ counts?: AutoModeCounts;
33
+ breaker?: boolean;
34
+ }
35
+
18
36
  export interface StatusTracker {
19
37
  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[];
38
+ finish(decision: AutoModeDecision): AutoModeDecision;
39
+ snapshot(input: StatusSnapshotInput): AutoModeStatus;
23
40
  }
24
41
 
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
- */
42
+ /** Bounded active-review tracking. Finished decisions are not retained. */
30
43
  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
44
  const reviewing = new Map<string, { sessionID: string; startedAt: number }>();
34
- let recent: AutoModeDecision[] = [];
35
45
 
36
46
  return {
37
47
  start(sessionID, reviewID, at) {
38
- if (reviewing.size >= MAX_REVIEWING && !reviewing.has(reviewID)) {
48
+ if (reviewing.size >= MAX_STATUS_REVIEWING && !reviewing.has(reviewID)) {
39
49
  const oldest = reviewing.keys().next().value;
40
50
  if (oldest !== undefined) reviewing.delete(oldest);
41
51
  }
@@ -43,30 +53,33 @@ export function createStatusTracker(): StatusTracker {
43
53
  },
44
54
  finish(decision) {
45
55
  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),
56
+ // Return only the bounded live-decision surface; never retain arbitrary
57
+ // record fields such as removed rule or shadow metadata.
58
+ return {
59
+ reviewID: decision.reviewID,
60
+ sessionID: decision.sessionID,
61
+ action: clip(decision.action, MAX_STATUS_ACTION_CHARS),
62
+ decision: clip(decision.decision, MAX_STATUS_STAGE_CHARS),
63
+ stage: clip(decision.stage, MAX_STATUS_STAGE_CHARS),
50
64
  reason: clip(decision.reason),
65
+ durationMs: decision.durationMs,
66
+ at: decision.at,
67
+ ...(decision.errorKind === undefined ? {} : { errorKind: clip(decision.errorKind, MAX_STATUS_STAGE_CHARS) }),
51
68
  };
52
- recent = [...recent, entry].slice(-MAX_RECENT);
53
- return entry;
54
69
  },
55
70
  snapshot(input) {
56
71
  return {
57
72
  enabled: input.enabled,
73
+ mode: input.mode,
58
74
  ...(input.configError === undefined ? {} : { configError: clip(input.configError) }),
59
- classifiers: input.classifiers,
75
+ ...(input.errorKind === undefined ? {} : { errorKind: input.errorKind }),
60
76
  reviewTimeoutMs: input.reviewTimeoutMs,
61
77
  reviewing: [...reviewing.entries()]
62
78
  .filter(([, entry]) => input.sessionID === undefined || entry.sessionID === input.sessionID)
63
79
  .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),
80
+ counts: counts(input.counts),
81
+ ...(input.breaker === undefined ? {} : { breaker: input.breaker }),
65
82
  };
66
83
  },
67
- recent(limit, sessionID) {
68
- const matches = sessionID === undefined ? recent : recent.filter((entry) => entry.sessionID === sessionID);
69
- return matches.slice(-limit);
70
- },
71
84
  };
72
85
  }