@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/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +36 -0
- package/src/argv-split.test.ts +59 -0
- package/src/argv-split.ts +82 -0
- package/src/cache.test.ts +91 -0
- package/src/cache.ts +58 -0
- package/src/config.test.ts +47 -0
- package/src/config.ts +27 -0
- package/src/guard.test.ts +427 -0
- package/src/guard.ts +759 -0
- package/src/index.ts +81 -0
- package/src/lifecycle.test.ts +165 -0
- package/src/prompt.test.ts +149 -0
- package/src/prompt.ts +99 -0
- package/src/tool.test.ts +1364 -0
- package/src/tool.ts +587 -0
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
|
+
}
|