@pi-archimedes/sudo 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/guard.ts ADDED
@@ -0,0 +1,759 @@
1
+ /**
2
+ * bash-sudo guard (plan 030, Task 2) — a pure scanner that vetoes interactive
3
+ * `sudo` issued through the built-in `bash` tool, so privileged execution is
4
+ * funneled through `sudo_exec` (masked prompt, `-S` stdin, in-memory cache).
5
+ * Heuristic per ADR 0010: best-effort heuristics, over-blocking is the safe
6
+ * direction, the tested no-prompt flag set is a stable contract.
7
+ *
8
+ * Strategy (tokenize + lookahead, NOT raw-string matching):
9
+ * 1. Strip Bash comments — a `#` at a word boundary starts a comment to end of
10
+ * line, unless it is inside quotes or a heredoc body. A naive
11
+ * `includes("sudo")` would false-positive on `sudo` in comments/strings
12
+ * and miss `FOO=sudo` env-prefixed tokens.
13
+ * 2. A small character-level state machine tracks single quotes, double
14
+ * quotes, `$(...)` substitutions, backticks, and heredoc bodies, and splits
15
+ * the command into segments on whitespace / `;` / `|` / `&` / newline / `(`.
16
+ * 3. Tokens keep their `$` / `${` / `$(...) ` prefixes: a `$NAME` or `${NAME}` token
17
+ * is a variable reference, distinguishable from the literal word `NAME`
18
+ * (a bare `$` no longer drops out of the token).
19
+ * 4. A token is the *command word* `sudo` (matched by basename, so
20
+ * `/usr/bin/sudo` counts too) only when it is the first non-env-assignment,
21
+ * non-interpolated token of a segment — so `FOO=sudo x`, `alias x='sudo'`,
22
+ * `echo 'sudo'`, and `$(sudo ...)` never match as literal `sudo`. A
23
+ * *pure variable reference* in command position whose name is `sudo`
24
+ * case-insensitively (`$SUDO apt`) is treated as an interactive sudo and
25
+ * gets the same no-prompt lookahead — a conservative over-block (an
26
+ * actually-empty expansion like an unset `$sudo` doesn't run sudo, but
27
+ * blocking is the safe direction).
28
+ * 5. For each command-word `sudo`, lookahead over the segment's remaining
29
+ * tokens: if any no-prompt flag appears (`-n`, `-l`, `-v`, `-K`, `-k`,
30
+ * `--non-interactive`, or a merged short flag composed solely of those)
31
+ * the segment cannot prompt and is allowed; otherwise, if any command
32
+ * operand applies, the segment is blocked.
33
+ * 6. Runner wrappers: when the command word is one of `env nohup nice
34
+ * ionice time timeout command builtin exec xargs`, skip the wrapper's
35
+ * own flags (and the single word such a flag consumes), `KEY=VALUE`
36
+ * assignments, and bare numeric parameters (a `timeout` duration, a
37
+ * `nice` number) to find the FIRST command-like remainder token, and
38
+ * dispatch THAT as a full command word: `sudo` (also via basename /
39
+ * variable reference) → the no-prompt lookahead; a shell → the `-c`
40
+ * recursion; `eval` → its recursion; another wrapper → one more level
41
+ * of dispatch. Everything after the dispatched token is consumed by its
42
+ * own dispatch — the remainder is not scanned further for `sudo` (the
43
+ * `sudo` in `command grep -r sudo .` is an operand of `grep`).
44
+ * 7. Nested shells / eval: when the command word is one of `sh bash dash zsh
45
+ * ksh su` and the remainder carries a `-c` flag (exact `-c`, or a merged
46
+ * short-flag bundle whose last character is `c`, e.g. `-xc`), the token
47
+ * immediately after the flag is the program string — the whole scanner is
48
+ * re-run on it recursively. `eval <rest>` likewise recursively scans its
49
+ * remaining tokens as a shell string. Recursion is capped at 3 levels;
50
+ * past the cap, the command is blocked conservatively (reason
51
+ * "nested shell depth limit"), since over-blocking is acceptable.
52
+ * 8. Compound-command keyword transparency: when the FIRST token of a segment
53
+ * is a bash keyword (`if then else elif while until for case do done fi
54
+ * esac ! { (`), the keyword tokens are skipped and the rest of the segment
55
+ * is evaluated — so `then sudo apt`, `do sudo apt`, `{ sudo apt`, and
56
+ * `! sudo apt` all reach the sudo command-word check.
57
+ * 9. Heredoc bodies are treated as command text (a `bash <<EOF` heredoc
58
+ * executes; over-blocking a `cat <<EOF` body is the safe direction).
59
+ *
60
+ * Known gaps — accepted residual bypasses (heuristic, per ADR 0010).
61
+ * Every example below is pinned in guard.test.ts (the "header known-gaps
62
+ * — documented behavior pinned" describe block, one assertion each,
63
+ * allow or block) — keep the two lists mirrored exactly, in both
64
+ * directions.
65
+ * (1) Arbitrary-name indirection requires cross-token assignment
66
+ * attribution — `S='sudo'; $S apt` is allowed: only the sudo-*named*
67
+ * variable reference case ($SUDO) is caught.
68
+ * (2) `$(...)`/backtick substitution: substitution-word erasure is
69
+ * BLOCKED (`$(true) sudo apt`, `` `x` sudo apt ``) — the broken
70
+ * substitution word is not command position, so a following literal
71
+ * `sudo` claims it and is caught. What remains plan-deferred is sudo
72
+ * *inside* interpolation that never surfaces as a word — never seen
73
+ * by the word-position model, so all allowed: `echo "$(sudo apt)"`,
74
+ * `bash -c "$(cat x)"`, `eval "$(echo 'sudo apt')"`; a
75
+ * substitution-*prefixed* assignment word (`x=$(echo 1) sudo apt`)
76
+ * likewise stays allowed.
77
+ * (3) Program source from files/redirects is unresolvable without reading
78
+ * files, so all allowed: `bash script.sh`, `bash < file`, `su -c
79
+ * file`.
80
+ * (4) Shell state persisting ACROSS separate bash-tool calls (assignments,
81
+ * aliases from earlier calls) is out of model.
82
+ * (5) A wrapper flag whose value the guard doesn't know consumes a
83
+ * following argument, so a `sudo` hidden in such a value position is
84
+ * not dispatched as the wrapped command. Covers both (a) long
85
+ * `--flag value` forms and any unlisted short-flag value (e.g.
86
+ * `timeout --signal SIGKILL sudo apt`), and (b) the
87
+ * `env -c 'sudo apt update'` quoted-command-string form — `-c` there
88
+ * consumes the following word as a VALUE, not a dispatched program
89
+ * (accepted allow; the no-flag quoted form `env '"sudo" apt'` is
90
+ * blocked and pinned).
91
+ * (6) Forwarding wrappers that spawn an arbitrary remote/local command
92
+ * with inherited stdin are not runner wrappers here (full
93
+ * forwarding-wrapper coverage is follow-up), so a `sudo` carried in
94
+ * their arguments never reaches a command position — intentionally
95
+ * allowed: `ssh user@host "sudo apt update"`, `chroot / sudo apt`,
96
+ * `perf stat sudo apt`.
97
+ * F-1 regression pins (not a numbered gap — fixed behaviors pinned to
98
+ * stop regression): six blocked value-taking short-flag forms (`exec
99
+ * -a foo sudo apt`, `time -f '%e' sudo apt`, `xargs -e STOP sudo
100
+ * cat`, `env -C /tmp sudo apt`, `env -u FOO sudo apt`, `env -f
101
+ * /tmp/x sudo apt`) and three allowed value-semantics forms (`env
102
+ * -u sudo apt`, `exec -a sudo apt`, `xargs -e sudo cat` — the
103
+ * flagged word is consumed as a VALUE, not dispatched as a command).
104
+ *
105
+ * The scanner never mutates anything and has no I/O — the guarantee is a
106
+ * tested function (ADR 0010).
107
+ */
108
+ import type { BashToolInput, ToolCallEvent, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
109
+
110
+ export interface GuardResult {
111
+ blocked: boolean;
112
+ reason?: string;
113
+ matchedSudoSegment?: string;
114
+ }
115
+
116
+ /** Exact `sudo` flags that cannot (effectively) run an elevated command. */
117
+ const ALLOWED_FLAGS = new Set(["-n", "-l", "-v", "-K", "-k", "--non-interactive"]);
118
+ /** Single characters of merged short-flag bundles that count as no-prompt. */
119
+ const ALLOWED_SHORT_FLAG_CHARS = new Set(["n", "l", "v", "K", "k"]);
120
+ /** Runner wrappers that can forward a real command after their own args. */
121
+ const RUNNER_WRAPPERS = new Set([
122
+ "env",
123
+ "nohup",
124
+ "nice",
125
+ "ionice",
126
+ "time",
127
+ "timeout",
128
+ "command",
129
+ "builtin",
130
+ "exec",
131
+ "xargs",
132
+ ]);
133
+ /** Shells that take a program string after their `-c` flag. */
134
+ const NESTED_SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "su"]);
135
+ /** Bash compound-command keyword tokens transparent to the command position. */
136
+ const KEYWORD_TOKENS = new Set([
137
+ "if",
138
+ "then",
139
+ "else",
140
+ "elif",
141
+ "while",
142
+ "until",
143
+ "for",
144
+ "case",
145
+ "do",
146
+ "done",
147
+ "fi",
148
+ "esac",
149
+ "!",
150
+ "{",
151
+ "(",
152
+ ]);
153
+ /** Maximum nested-shell/eval recursion before we block conservatively. */
154
+ const MAX_NEST_DEPTH = 3;
155
+ /**
156
+ * Reason for the depth-cap block — conservatively over-block a command when
157
+ * the nested-shell/eval depth exceeds the cap.
158
+ */
159
+ const DEPTH_LIMIT_CAUSE = "Nested shell/eval depth limit reached (blocked conservatively).";
160
+ /**
161
+ * Bare single-letter short wrapper flags that consume the next bare word
162
+ * as a value (`nice -n 10`, `xargs -I {}`, `timeout -k 5`, `ionice -c 2`,
163
+ * `exec -a NAME`, `env -u VAR`/`-f FILE`, `time -f FORMAT`, `xargs -e
164
+ * STOPWORD`). A `--flag=value` long form carries its value in the token and
165
+ * consumes no word; merged `-n0` forms likewise keep the value in the token.
166
+ * A letter here means the flagged word is consumed as a VALUE, not
167
+ * dispatched as the wrapped command (`env -u sudo apt` unsets a var named
168
+ * `sudo` and runs `apt` — allowed).
169
+ */
170
+ const VALUE_TAKING_FLAG_CHARS = new Set(["n", "I", "c", "k", "s", "o", "P", "a", "f", "e", "E", "C", "u", "d", "T", "y"]);
171
+ /** Bare numeric wrapper parameter — `timeout 5`, `timeout 500ms`, `nice 10`. */
172
+ const NUMERIC_PARAM = /^[-+]?\d+(\.\d+)?[smhd]?$/;
173
+
174
+ const BLOCK_REASON_PREFIX =
175
+ "Interactive sudo in the bash tool is blocked — it can hang on a password prompt or leak the password.\n" +
176
+ "Use the `sudo_exec` tool instead: it prompts for the password through the masked UI, passes it via `sudo -S` stdin, and never exposes it.\n" +
177
+ "Non-interactive sudo (`sudo -n` / `-l` / `-v` / `-K` / `-k`) still works.";
178
+
179
+ interface GuardToken {
180
+ /** Word text as it would appear to a command word (quotes dropped, escapes literalized). */
181
+ text: string;
182
+ /** True if any character was inside single or double quotes. */
183
+ quoted: boolean;
184
+ /** True if the word lives inside a `$(...)` substitution or backticks. */
185
+ substituted: boolean;
186
+ }
187
+
188
+ interface HeredocState {
189
+ delimiter: string;
190
+ /** `<<-` strips leading tabs from the body/terminator lines. */
191
+ stripTabs: boolean;
192
+ /** True while consuming body lines (vs. the `<<DELIM` line itself). */
193
+ active: boolean;
194
+ /** Characters accumulated on the current heritage line (excludes the newline). */
195
+ line: string;
196
+ }
197
+
198
+ /** `FOO=...` env-assignment word (never a command name). */
199
+ function isEnvAssignment(word: string): boolean {
200
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
201
+ }
202
+
203
+ function isAllowedFlag(word: string): boolean {
204
+ if (ALLOWED_FLAGS.has(word)) return true;
205
+ // Merged short flags (`-nK`): allowed only if every character is no-prompt.
206
+ if (word.length > 2 && word.startsWith("-") && !word.startsWith("--")) {
207
+ for (const ch of word.slice(1)) {
208
+ if (!ALLOWED_SHORT_FLAG_CHARS.has(ch)) return false;
209
+ }
210
+ return true;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ /** Last path component — `/usr/bin/sudo` is still the `sudo` command. */
216
+ function basename(word: string): string {
217
+ const i = word.lastIndexOf("/");
218
+ return i === -1 ? word : word.slice(i + 1);
219
+ }
220
+
221
+ /**
222
+ * Extract a variable's name from a *pure variable reference* word — `$NAME`
223
+ * or `${NAME}` covering the whole token — otherwise null. Words embedded in
224
+ * other characters (`$SUDO`, `x=\`${NAME}\``, shell names) return null.
225
+ */
226
+ function variableRefName(word: string): string | null {
227
+ const m = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(word);
228
+ if (m) return m[1] ?? null;
229
+ const b = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(word);
230
+ return b ? (b[1] ?? null) : null;
231
+ }
232
+
233
+ /** Word that becomes an interactive `sudo` command position (literal by basename, or via a sudo-named variable reference). */
234
+ function isSudoWordText(text: string): boolean {
235
+ if (basename(text) === "sudo") return true;
236
+ const name = variableRefName(text);
237
+ return name !== null && name.toLowerCase() === "sudo";
238
+ }
239
+
240
+ /** Token that becomes an interactive `sudo` command position (exact or via reference). */
241
+ function isSudoWord(t: GuardToken): boolean {
242
+ if (t.substituted) return false;
243
+ return isSudoWordText(t.text);
244
+ }
245
+
246
+ /**
247
+ * Strip surviving quote characters (at most one leading and one trailing)
248
+ * from a whitespace-split part of a token that survived re-tokenization.
249
+ * Re-tokenization can break in the middle of a quoted range (`"sudo apt`
250
+ * → parts `"sudo`, `apt`), so this is not a full quote-pair strip.
251
+ */
252
+ function unquotePart(part: string): string {
253
+ let s = part;
254
+ if (s.length > 0 && (s[0] === '"' || s[0] === "'")) s = s.slice(1);
255
+ if (s.length > 0 && (s[s.length - 1] === '"' || s[s.length - 1] === "'")) s = s.slice(0, -1);
256
+ return s;
257
+ }
258
+
259
+ /**
260
+ * Shell `-c` flag match: an exact `-c` or a merged short-flag bundle whose
261
+ * last character is `c` (e.g. `-xc`, `-lc` — `c` consumes the next word as
262
+ * the program string for all of the shells in NESTED_SHELLS). Long flags
263
+ * (`--c`) and bare words do not match.
264
+ */
265
+ function isShellCFlag(text: string): boolean {
266
+ return /^-[A-Za-z]*c$/.test(text);
267
+ }
268
+
269
+ /**
270
+ * No-prompt lookahead over the tokens after index `from` (the `sudo`
271
+ * occurrence itself). Returns whether the occurrence would prompt: allowed
272
+ * if any no-prompt flag, and a command operand applies.
273
+ */
274
+ function lookaheadBlocks(tokens: GuardToken[], from: number): boolean {
275
+ let allowed = false;
276
+ let commandApplies = false;
277
+ for (const t of tokens.slice(from + 1)) {
278
+ if (t.text.length > 0 && t.text.startsWith("-") && !t.substituted) {
279
+ if (isAllowedFlag(t.text)) allowed = true;
280
+ } else {
281
+ // A non-flag operand means a command is being elevated.
282
+ commandApplies = true;
283
+ }
284
+ }
285
+ return commandApplies && !allowed;
286
+ }
287
+
288
+ interface SegmentVerdict {
289
+ blocked: boolean;
290
+ segmentText: string;
291
+ cause?: string;
292
+ }
293
+
294
+ /**
295
+ * Classify one segment against the sudo rule. Returns null when the segment contains
296
+ * no blocking `sudo`.
297
+ */
298
+ function scanSegment(tokens: GuardToken[], depth: number): SegmentVerdict | null {
299
+ if (tokens.length === 0) return null;
300
+ const segmentText = tokens.map((t) => t.text).join(" ");
301
+ // No depth gate here: every caller (the initial call, `recurse` below,
302
+ // and the nested-wrapper dispatch) checks its depth against
303
+ // MAX_NEST_DEPTH before descending, so the cap lives there.
304
+
305
+ // G-3: skip leading compound-command keyword tokens — `then sudo apt`,
306
+ // `do sudo apt`, `{ sudo apt`, `! sudo apt` all reach the sudo check below.
307
+ let rest = tokens;
308
+ for (;;) {
309
+ const head = rest[0];
310
+ if (head === undefined || !KEYWORD_TOKENS.has(head.text)) break;
311
+ rest = rest.slice(1);
312
+ }
313
+ if (rest.length === 0) return null;
314
+
315
+ // Command word = first non-assignment, non-interpolated token.
316
+ let cmdIdx = -1;
317
+ for (let i = 0; i < rest.length; i++) {
318
+ const t = rest[i];
319
+ if (t === undefined) continue;
320
+ if (t.substituted) continue; // `$(...)` / backtick results are not command position
321
+ if (isEnvAssignment(t.text)) continue; // leading `FOO=...` assigns, doesn't invoke
322
+ cmdIdx = i;
323
+ break;
324
+ }
325
+ if (cmdIdx === -1) return null;
326
+ const cmd = rest[cmdIdx];
327
+ if (cmd === undefined) return null;
328
+
329
+ // (1) Literal `sudo` (by basename) or a pure variable reference named
330
+ // `sudo` case-insensitively → same no-prompt lookahead as a literal.
331
+ if (isSudoWord(cmd)) {
332
+ return lookaheadBlocks(rest, cmdIdx)
333
+ ? { blocked: true, segmentText }
334
+ : null;
335
+ }
336
+
337
+ // (2) Recursive commands: shell `-c 'program'` and `eval <rest>`.
338
+ const recurse = (program: string): SegmentVerdict | null => {
339
+ if (depth + 1 > MAX_NEST_DEPTH) {
340
+ return { blocked: true, segmentText, cause: DEPTH_LIMIT_CAUSE };
341
+ }
342
+ return scanSegments(tokenizeCommand(program), depth + 1);
343
+ };
344
+
345
+ // B-1: a quoted token containing whitespace can survive re-tokenization
346
+ // as a single word (`bash -c '"sudo apt"'` → one program token), hiding
347
+ // the `sudo` from the program's command position. Each quoted
348
+ // whitespace-bearing token in `remainder` yields a *mirror* program
349
+ // text — its quote-stripped, whitespace-split inner form — scanned
350
+ // through the same `recurse`, so a mirror that blocks is blocked, at
351
+ // the same depth cap.
352
+ const quoteLayerMirror = (remainder: GuardToken[]): SegmentVerdict | null => {
353
+ for (const q of remainder) {
354
+ if (q === undefined) continue;
355
+ if (!q.quoted || !/\s/.test(q.text)) continue;
356
+ const parts = q.text.split(/\s+/).filter((p) => p !== "");
357
+ if (parts.length < 2) continue;
358
+ const mirror = recurse(parts.map(unquotePart).join(" "));
359
+ if (mirror !== null) return mirror;
360
+ }
361
+ return null;
362
+ };
363
+
364
+ if (NESTED_SHELLS.has(basename(cmd.text))) {
365
+ for (let j = cmdIdx + 1; j < rest.length; j++) {
366
+ const t = rest[j];
367
+ if (t === undefined) continue;
368
+ if (t.substituted || !isShellCFlag(t.text)) continue;
369
+ // The next token is the program string (quotes already stripped by the tokenizer).
370
+ const after = rest.slice(j + 1);
371
+ const program = after.map((x) => x.text).join(" ");
372
+ const primary = recurse(program);
373
+ if (primary !== null) return primary;
374
+ return quoteLayerMirror(after);
375
+ }
376
+ return null; // `bash script.sh` / `bash < file` — known gap, no -c string to scan
377
+ }
378
+ if (basename(cmd.text) === "eval") {
379
+ const after = rest.slice(cmdIdx + 1);
380
+ const program = after.map((x) => x.text).join(" ");
381
+ const primary = recurse(program);
382
+ if (primary !== null) return primary;
383
+ return quoteLayerMirror(after);
384
+ }
385
+
386
+ // (3) Runner wrapper: skip the wrapper's own flags (and the single word
387
+ // a value-taking flag consumes), `KEY=VALUE` assignments, and bare
388
+ // numeric parameters (a `timeout` duration, a `nice` number) to find
389
+ // the FIRST command-like remainder token — the real command word the
390
+ // wrapper forwards — and dispatch it as a full command word.
391
+ // Everything after that dispatched token belongs to its own dispatch —
392
+ // the remainder is NOT scanned further for `sudo` (`command grep -r
393
+ // sudo .`: the `sudo` is an `grep` operand, not a command word).
394
+ if (RUNNER_WRAPPERS.has(basename(cmd.text))) {
395
+ const wrapperName = basename(cmd.text);
396
+ const dispatch = (i: number): SegmentVerdict | null => {
397
+ const t = rest[i];
398
+ if (t === undefined) return null;
399
+ // Literal `sudo` (basename) or a pure `$SUDO` reference → the
400
+ // same no-prompt lookahead as a top-level sudo word.
401
+ if (isSudoWord(t)) {
402
+ return lookaheadBlocks(rest, i)
403
+ ? { blocked: true, segmentText }
404
+ : null;
405
+ }
406
+ // B-1: a quoted token that survived re-tokenization can hide a
407
+ // multi-word command — `env '"sudo" apt'` collapses to one token
408
+ // containing whitespace. If it does, treat its whitespace-split
409
+ // first part as a command-word candidate; the remaining split
410
+ // parts plus the tokens after `i` are its operands (
411
+ // `env '"sudo" -n' true` still gets the no-prompt lookahead →
412
+ // allowed).
413
+ // `xargs` is exempt: its positional command legitimately spans
414
+ // quoted whitespace (per-item executor, no lookahead there).
415
+ if (t.quoted && wrapperName !== "xargs") {
416
+ const parts = t.text.split(/\s+/).filter((p) => p !== "");
417
+ if (parts.length > 1) {
418
+ const head = unquotePart(parts[0] ?? "");
419
+ if (isSudoWordText(head)) {
420
+ const mirror: GuardToken[] = [
421
+ ...parts
422
+ .slice(1)
423
+ .map((p) => ({ text: unquotePart(p), quoted: false, substituted: false })),
424
+ ...rest.slice(i + 1),
425
+ ];
426
+ return lookaheadBlocks(mirror, -1)
427
+ ? { blocked: true, segmentText }
428
+ : null;
429
+ }
430
+ if (NESTED_SHELLS.has(head) || head === "eval") {
431
+ const program = [
432
+ ...parts.slice(1).map(unquotePart),
433
+ ...rest.slice(i + 1).map((x) => x.text),
434
+ ].join(" ");
435
+ return recurse(program);
436
+ }
437
+ }
438
+ }
439
+ // Shell with `-c` → program-string recursion (same rule as (2)).
440
+ if (NESTED_SHELLS.has(basename(t.text))) {
441
+ for (let k = i + 1; k < rest.length; k++) {
442
+ const u = rest[k];
443
+ if (u === undefined) continue;
444
+ if (u.substituted || !isShellCFlag(u.text)) continue;
445
+ return recurse(rest.slice(k + 1).map((x) => x.text).join(" "));
446
+ }
447
+ return null; // program from file/redirect — known gap (3)
448
+ }
449
+ // `eval` → recurse on its remaining tokens (same rule as (2)).
450
+ if (basename(t.text) === "eval") {
451
+ return recurse(rest.slice(i + 1).map((x) => x.text).join(" "));
452
+ }
453
+ // Another wrapper → one more dispatch level under the same
454
+ // depth cap (`env env sudo apt`).
455
+ if (RUNNER_WRAPPERS.has(basename(t.text))) {
456
+ if (depth + 1 > MAX_NEST_DEPTH) {
457
+ return { blocked: true, segmentText, cause: DEPTH_LIMIT_CAUSE };
458
+ }
459
+ return scanSegments([rest.slice(i)], depth + 1);
460
+ }
461
+ // Some other command — its dispatch consumes everything after it;
462
+ // no sudo to evaluate.
463
+ return null;
464
+ };
465
+ let skipValue = false; // previous wrapper flag consumes the next bare word as its value
466
+ for (let j = cmdIdx + 1; j < rest.length; j++) {
467
+ const t = rest[j];
468
+ if (t === undefined || t.substituted) continue;
469
+ const text = t.text;
470
+ if (text.startsWith("-")) {
471
+ // A wrapper flag. Only a bare `-x` short flag consumes the
472
+ // next bare token as its value (`-n 10`, `-I {}`); `--flag=value`
473
+ // carries its value in the token and does not consume a word.
474
+ const short = /^-([A-Za-z])$/.exec(text);
475
+ skipValue = short !== null && VALUE_TAKING_FLAG_CHARS.has(short[1] ?? "");
476
+ continue;
477
+ }
478
+ if (skipValue) {
479
+ skipValue = false;
480
+ continue;
481
+ }
482
+ if (isEnvAssignment(text)) continue;
483
+ if (NUMERIC_PARAM.test(text)) continue; // `timeout 5`, `nice 10`
484
+ return dispatch(j); // the first command-like token IS the command word — dispatch it and stop
485
+ }
486
+ }
487
+ return null;
488
+ }
489
+
490
+ /** Scan every segment of a (re-tokenized) program string at the given depth. */
491
+ function scanSegments(segments: GuardToken[][], depth: number): SegmentVerdict | null {
492
+ for (const tokens of segments) {
493
+ const verdict = scanSegment(tokens, depth);
494
+ if (verdict?.blocked) return verdict;
495
+ }
496
+ return null;
497
+ }
498
+
499
+ /**
500
+ * Split a Bash command string into segments (arrays of tokens).
501
+ * Pure: no I/O, shadows nothing, only reads `command`.
502
+ */
503
+ export function tokenizeCommand(command: string): GuardToken[][] {
504
+ const n = command.length;
505
+ const segments: GuardToken[][] = [[]];
506
+ const pushSegmentBoundary = (): void => {
507
+ segments.push([]);
508
+ };
509
+
510
+ let current = "";
511
+ let quoted = false;
512
+ let substituted = false;
513
+ let haveWord = false;
514
+
515
+ let inSingle = false;
516
+ let inDouble = false;
517
+ let commentToEol = false;
518
+ let subDepth = 0;
519
+ let inBacktick = false;
520
+ let heredoc: HeredocState | null = null;
521
+
522
+ const flushWord = (): void => {
523
+ const seg = segments[segments.length - 1];
524
+ if (!seg || !haveWord) return;
525
+ if (subDepth > 0 || inBacktick) substituted = true;
526
+ seg.push({ text: current, quoted, substituted });
527
+ current = "";
528
+ quoted = false;
529
+ substituted = false;
530
+ haveWord = false;
531
+ };
532
+
533
+ const pushChar = (ch: string): void => {
534
+ current += ch;
535
+ haveWord = true;
536
+ };
537
+
538
+ let i = 0;
539
+ while (i < n) {
540
+ const c = command.charAt(i);
541
+ const next = i + 1 < n ? command.charAt(i + 1) : "";
542
+
543
+ if (heredoc?.active) {
544
+ // Heredoc body: raw data, but each body line is parsed as command text
545
+ // (a `bash <<EOF` heredoc executes; blocking is the safe direction).
546
+ // `#` in a body is literal data, not a comment — a `#`-prefixed line's
547
+ // first token is `#` (not `sudo`), so such lines are not blocked, matching
548
+ // how bash would treat them in an executing here-doc.
549
+ if (c === "\n") {
550
+ const check = heredoc.stripTabs ? heredoc.line.replace(/^\t+/, "") : heredoc.line;
551
+ if (check === heredoc.delimiter) {
552
+ heredoc.active = false;
553
+ heredoc = null;
554
+ flushWord();
555
+ pushSegmentBoundary();
556
+ } else {
557
+ flushWord();
558
+ pushSegmentBoundary();
559
+ heredoc.line = "";
560
+ }
561
+ } else if (c === "\t" && heredoc.stripTabs) {
562
+ if (heredoc.line === "") continue; // leading tabs are stripped, not body content
563
+ heredoc.line += c;
564
+ current += c;
565
+ haveWord = true;
566
+ } else {
567
+ heredoc.line += c;
568
+ if (c === " " || c === "\t") flushWord();
569
+ else pushChar(c);
570
+ }
571
+ i += 1;
572
+ continue;
573
+ }
574
+
575
+ if (commentToEol) {
576
+ if (c === "\n") {
577
+ commentToEol = false;
578
+ flushWord();
579
+ pushSegmentBoundary();
580
+ }
581
+ i += 1;
582
+ continue;
583
+ }
584
+
585
+ if (inSingle) {
586
+ if (c === "'") inSingle = false;
587
+ else pushChar(c);
588
+ i += 1;
589
+ continue;
590
+ }
591
+
592
+ if (inDouble) {
593
+ if (c === "\\") {
594
+ // `\"` and `\\` are escapes; everything else is literal.
595
+ pushChar(next ?? "");
596
+ i += 2;
597
+ continue;
598
+ }
599
+ if (c === '"') inDouble = false;
600
+ else pushChar(c);
601
+ i += 1;
602
+ continue;
603
+ }
604
+
605
+ switch (c) {
606
+ case "'":
607
+ inSingle = true;
608
+ quoted = true;
609
+ break;
610
+ case '"':
611
+ inDouble = true;
612
+ quoted = true;
613
+ break;
614
+ case "\\":
615
+ pushChar(next ?? "");
616
+ i += 2;
617
+ continue;
618
+ case "$":
619
+ if (next === "(") {
620
+ subDepth += 1;
621
+ substituted = true;
622
+ pushChar("$");
623
+ i += 1; // consume `$`; `(` falls back to the `(` case above
624
+ continue;
625
+ }
626
+ // G-4a: keep `$` in the token — a `$NAME`/`${NAME}` reference must
627
+ // be distinguishable from the literal word `NAME`.
628
+ pushChar("$");
629
+ break;
630
+ case "(":
631
+ // A top-level `(` opens a subshell — a fresh command position.
632
+ // Inside an active `$(...)` the `(` is word text (e.g. `$( sudo)`),
633
+ // not a segment boundary — substitution contents stay interpolated.
634
+ if (subDepth === 0) {
635
+ flushWord();
636
+ pushSegmentBoundary();
637
+ }
638
+ break;
639
+ case ")":
640
+ if (subDepth > 0) subDepth -= 1;
641
+ pushChar(c);
642
+ break;
643
+ case "`":
644
+ inBacktick = !inBacktick;
645
+ substituted = true;
646
+ break;
647
+ case "#": {
648
+ const atWordBoundary =
649
+ !haveWord && (i === 0 || " \t\n;|&(".includes(command.charAt(i - 1)));
650
+ if (atWordBoundary) {
651
+ flushWord();
652
+ commentToEol = true;
653
+ } else {
654
+ pushChar(c);
655
+ }
656
+ break;
657
+ }
658
+ case "<":
659
+ if (next === "<") {
660
+ // Heredoc: `cat <<EOF`, `<<-TABEOF`, `<<'QEOF'`, etc.
661
+ let d = i + 2;
662
+ let stripTabs = false;
663
+ if (command.charAt(d) === "-") {
664
+ stripTabs = true;
665
+ d += 1;
666
+ }
667
+ let stop: string | null = null;
668
+ if (command.charAt(d) === "'" || command.charAt(d) === '"') {
669
+ stop = command.charAt(d);
670
+ d += 1;
671
+ } else if (command.charAt(d) === "\\") {
672
+ d += 1;
673
+ }
674
+ const start = d;
675
+ while (d < n) {
676
+ const dc = command.charAt(d);
677
+ if (dc === stop || /[\s;|&]/.test(dc)) break;
678
+ d += 1;
679
+ }
680
+ const delimiter = command.slice(start, d);
681
+ heredoc = { delimiter, stripTabs, active: false, line: "" };
682
+ pushChar("<");
683
+ i = d; // skip past the delimiter word just consumed
684
+ continue;
685
+ }
686
+ pushChar(c);
687
+ break;
688
+ case "\n":
689
+ flushWord();
690
+ if (heredoc && !heredoc.active && heredoc.delimiter !== "") {
691
+ heredoc.active = true;
692
+ heredoc.line = "";
693
+ }
694
+ pushSegmentBoundary();
695
+ break;
696
+ case " ":
697
+ case "\t":
698
+ case "\r":
699
+ flushWord();
700
+ break;
701
+ case ";":
702
+ case "|":
703
+ case "&":
704
+ flushWord();
705
+ pushSegmentBoundary();
706
+ break;
707
+ default:
708
+ pushChar(c);
709
+ }
710
+ i += 1;
711
+ }
712
+
713
+ flushWord();
714
+ if (heredoc?.active && heredoc.line !== "") {
715
+ flushWord();
716
+ }
717
+ return segments;
718
+ }
719
+
720
+ /**
721
+ * Scan a bash command for an interactive `sudo` that can prompt for a
722
+ * password. Pure — returns a structured verdict, never throws on input.
723
+ */
724
+ export function isInteractiveSudoAttempt(command: string): GuardResult {
725
+ const segments = tokenizeCommand(command);
726
+ const verdict = scanSegments(segments, 0);
727
+ if (verdict?.blocked) {
728
+ return {
729
+ blocked: true,
730
+ reason:
731
+ `${BLOCK_REASON_PREFIX}${verdict.cause ? `\n${verdict.cause}` : ""}\n` +
732
+ `Matched segment: ${verdict.segmentText}`,
733
+ matchedSudoSegment: verdict.segmentText,
734
+ };
735
+ }
736
+ return { blocked: false };
737
+ }
738
+
739
+ /**
740
+ * `pi.on("tool_call")` veto handler for the built-in `bash` tool (plan 030,
741
+ * Task 2; ADR 0010). Returning `{ block: true }` makes pi produce an
742
+ * immediate error tool result — the bash tool's `execute()` and the shell
743
+ * spawn never run. `event.input` is NOT mutated. Exported as a named
744
+ * function for direct integration testing.
745
+ */
746
+ export function handleBashToolCall(event: ToolCallEvent): ToolCallEventResult | undefined {
747
+ if (event.toolName !== "bash") return undefined;
748
+ const command = (event.input as BashToolInput).command ?? "";
749
+ const verdict = isInteractiveSudoAttempt(command);
750
+ if (verdict.blocked) {
751
+ return {
752
+ block: true,
753
+ reason:
754
+ verdict.reason ??
755
+ "Interactive sudo in the bash tool is blocked. Use sudo_exec instead — it prompts for the password through the masked UI and never exposes it. Non-interactive sudo -n / -l / -v / -K / -k still work.",
756
+ };
757
+ }
758
+ return undefined;
759
+ }