@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
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import type { ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { handleBashToolCall, isInteractiveSudoAttempt } from "./guard.js";
|
|
5
|
+
|
|
6
|
+
// ── plan-030 Task 2: the 12 enumerated detection cases ─────────────────────
|
|
7
|
+
|
|
8
|
+
describe("isInteractiveSudoAttempt — plan-030 Task 2 cases", () => {
|
|
9
|
+
it("1. `sudo apt update` → blocked", () => {
|
|
10
|
+
const r = isInteractiveSudoAttempt("sudo apt update");
|
|
11
|
+
expect(r.blocked).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("2. `sudo -n true` → NOT blocked (sudo -n can't prompt)", () => {
|
|
15
|
+
const r = isInteractiveSudoAttempt("sudo -n true");
|
|
16
|
+
expect(r.blocked).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("3. `sudo -n true; sudo apt update` → blocked (second segment)", () => {
|
|
20
|
+
const r = isInteractiveSudoAttempt("sudo -n true; sudo apt update");
|
|
21
|
+
expect(r.blocked).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("4. `FOO=sudo bar; sudo rm -rf /` → blocked (only the second sudo is a real call)", () => {
|
|
25
|
+
const r = isInteractiveSudoAttempt("FOO=sudo bar; sudo rm -rf /");
|
|
26
|
+
expect(r.blocked).toBe(true);
|
|
27
|
+
expect(r.matchedSudoSegment).toContain("sudo rm -rf /");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("5. `echo 'sudo apt update'` → NOT blocked (inner command is echo)", () => {
|
|
31
|
+
const r = isInteractiveSudoAttempt("echo 'sudo apt update'");
|
|
32
|
+
expect(r.blocked).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("6. heredoc body containing `sudo apt` → blocked", () => {
|
|
36
|
+
const r = isInteractiveSudoAttempt("cat <<EOF\nsudo apt\nEOF");
|
|
37
|
+
expect(r.blocked).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("7. `FOO=sudo; sudo -n true` → NOT blocked (only the -n form is present)", () => {
|
|
41
|
+
const r = isInteractiveSudoAttempt("FOO=sudo; sudo -n true");
|
|
42
|
+
expect(r.blocked).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("8. `sudo ls; echo hi` → blocked", () => {
|
|
46
|
+
const r = isInteractiveSudoAttempt("sudo ls; echo hi");
|
|
47
|
+
expect(r.blocked).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("9. `alias x='sudo'; echo hi` → NOT blocked (alias string, no real sudo segment)", () => {
|
|
51
|
+
const r = isInteractiveSudoAttempt("alias x='sudo'; echo hi");
|
|
52
|
+
expect(r.blocked).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("10. `sudo -l` → NOT blocked (list allowed)", () => {
|
|
56
|
+
const r = isInteractiveSudoAttempt("sudo -l");
|
|
57
|
+
expect(r.blocked).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("11. `FOO=sudo echo hi` → NOT blocked (env-assignment value, not command word)", () => {
|
|
61
|
+
const r = isInteractiveSudoAttempt("FOO=sudo echo hi");
|
|
62
|
+
expect(r.blocked).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns a structured result: reason only when blocked, matched segment when blocked", () => {
|
|
66
|
+
const blocked = isInteractiveSudoAttempt("sudo apt update");
|
|
67
|
+
expect(blocked.blocked).toBe(true);
|
|
68
|
+
expect(blocked.reason).toBeTruthy();
|
|
69
|
+
expect(blocked.matchedSudoSegment).toBe("sudo apt update");
|
|
70
|
+
|
|
71
|
+
const allowed = isInteractiveSudoAttempt("sudo -n true");
|
|
72
|
+
expect(allowed.blocked).toBe(false);
|
|
73
|
+
expect(allowed.reason).toBeUndefined();
|
|
74
|
+
expect(allowed.matchedSudoSegment).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── lookahead / tokenization hardening (beyond the enumerated 12) ──────────
|
|
79
|
+
|
|
80
|
+
describe("isInteractiveSudoAttempt — tokenizer + lookahead hardening", () => {
|
|
81
|
+
it("allows the full no-prompt flag set, one assertion per flag in flag-only form", () => {
|
|
82
|
+
expect(isInteractiveSudoAttempt("sudo").blocked).toBe(false); // bare `sudo` (session-escalation form; no command to elevate here)
|
|
83
|
+
expect(isInteractiveSudoAttempt("sudo -n").blocked).toBe(false);
|
|
84
|
+
expect(isInteractiveSudoAttempt("sudo -l").blocked).toBe(false);
|
|
85
|
+
expect(isInteractiveSudoAttempt("sudo -v").blocked).toBe(false);
|
|
86
|
+
expect(isInteractiveSudoAttempt("sudo -K").blocked).toBe(false); // flags only, no command to elevate
|
|
87
|
+
expect(isInteractiveSudoAttempt("sudo -k").blocked).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("blocks `sudo -u root apt update` (disallowed flag + command)", () => {
|
|
91
|
+
const r = isInteractiveSudoAttempt("sudo -u root apt update");
|
|
92
|
+
expect(r.blocked).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("blocks merged short flags that are not purely no-prompt: `sudo -ABC true`", () => {
|
|
96
|
+
expect(isInteractiveSudoAttempt("sudo -ABC true").blocked).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("allows merged short flags that are purely no-prompt: `sudo -nk true`", () => {
|
|
100
|
+
expect(isInteractiveSudoAttempt("sudo -nk true").blocked).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("allows the `--non-interactive` long form", () => {
|
|
104
|
+
expect(isInteractiveSudoAttempt("sudo --non-interactive true").blocked).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("blocks `sudo` inside command substitution chains' real command word: `sleep 1 && sudo apt`", () => {
|
|
108
|
+
expect(isInteractiveSudoAttempt("sleep 1 && sudo apt install x").blocked).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not treat sudo inside $() or backticks as a command word (plan: interpolation is not command position)", () => {
|
|
112
|
+
expect(isInteractiveSudoAttempt("echo $(sudo -l)").blocked).toBe(false);
|
|
113
|
+
expect(isInteractiveSudoAttempt("cd $(dirname sudo)").blocked).toBe(false);
|
|
114
|
+
expect(isInteractiveSudoAttempt("X=$( sudo ls )").blocked).toBe(false);
|
|
115
|
+
expect(isInteractiveSudoAttempt("ls $(sudo apt update)").blocked).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("ignores sudo in comments (word-boundary #), including `sudo -n` in a comment", () => {
|
|
119
|
+
expect(isInteractiveSudoAttempt("# sudo rm -rf /").blocked).toBe(false);
|
|
120
|
+
expect(isInteractiveSudoAttempt("echo hi # sudo apt update").blocked).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not false-positive on words that merely contain sudo", () => {
|
|
124
|
+
expect(isInteractiveSudoAttempt("find . -name 'sudo'").blocked).toBe(false);
|
|
125
|
+
expect(isInteractiveSudoAttempt("ls ./sudoku").blocked).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("blocks a `); sudo apt` tail after substitution on the same line", () => {
|
|
129
|
+
expect(isInteractiveSudoAttempt("$(kill 1); sudo apt update").blocked).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("treats an `&&`-chained second segment without -n as blocked", () => {
|
|
133
|
+
expect(isInteractiveSudoAttempt("sudo -n true && sudo apt update").blocked).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── review G-4a: `$`-preservation in the tokenizer (no `$`-dropping) ────────
|
|
138
|
+
|
|
139
|
+
describe("isInteractiveSudoAttempt — G-4a `$`-preservation stays identical", () => {
|
|
140
|
+
it("does not treat a `$`-prefixed echo operand as a command word", () => {
|
|
141
|
+
expect(isInteractiveSudoAttempt("echo $SUDO").blocked).toBe(false);
|
|
142
|
+
expect(isInteractiveSudoAttempt("echo ${SUDO}").blocked).toBe(false);
|
|
143
|
+
expect(isInteractiveSudoAttempt("for i in 1; do echo $i; done").blocked).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("keeps substitution/interpolation cases unchanged (no false `$` detection)", () => {
|
|
147
|
+
expect(isInteractiveSudoAttempt("X=$( sudo apt)").blocked).toBe(false);
|
|
148
|
+
expect(isInteractiveSudoAttempt("echo $(sudo -l)").blocked).toBe(false);
|
|
149
|
+
expect(isInteractiveSudoAttempt("git log -S sudo").blocked).toBe(false);
|
|
150
|
+
expect(isInteractiveSudoAttempt("ps aux | grep sudo").blocked).toBe(false);
|
|
151
|
+
expect(isInteractiveSudoAttempt("find . -name sudo").blocked).toBe(false);
|
|
152
|
+
expect(isInteractiveSudoAttempt("echo use sudo -n carefully").blocked).toBe(false);
|
|
153
|
+
expect(isInteractiveSudoAttempt("$(kill 1); sudo apt update").blocked).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── review G-4b: sudo-named variable reference in command position ──────────
|
|
158
|
+
|
|
159
|
+
describe("isInteractiveSudoAttempt — G-4b variable-ref command word", () => {
|
|
160
|
+
it("blocks `$SUDO apt` when `SUDO` is a variable reference (cross-token attribution missed — conservative)", () => {
|
|
161
|
+
const r = isInteractiveSudoAttempt("SUDO=sudo; $SUDO apt");
|
|
162
|
+
expect(r.blocked).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("blocks lowercase `$sudo apt` (over-block, safe direction — `sudo` unset expands to empty)", () => {
|
|
166
|
+
expect(isInteractiveSudoAttempt("$sudo apt").blocked).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("blocks braced `${SUDO} apt"
|
|
170
|
+
, () => {
|
|
171
|
+
expect(isInteractiveSudoAttempt("${SUDO} apt").blocked).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("allows `$SUDO -n true` (same no-prompt lookahead as a literal sudo)", () => {
|
|
175
|
+
expect(isInteractiveSudoAttempt("$SUDO -n true").blocked).toBe(false);
|
|
176
|
+
expect(isInteractiveSudoAttempt("$SUDO -l").blocked).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("allows non-sudo-named pure variable references and non-command-position refs", () => {
|
|
180
|
+
expect(isInteractiveSudoAttempt("$SUDOTO apt").blocked).toBe(false);
|
|
181
|
+
expect(isInteractiveSudoAttempt("echo $SUDO").blocked).toBe(false);
|
|
182
|
+
expect(isInteractiveSudoAttempt("$SUDO echo hi").blocked).toBe(true); // `$SUDO` as the command with an operand
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── review G-1: runner-wrapper pass-through (env / nohup / nice / ...) ──────
|
|
187
|
+
|
|
188
|
+
describe("isInteractiveSudoAttempt — G-1 runner wrappers", () => {
|
|
189
|
+
it.each([
|
|
190
|
+
"env sudo apt update",
|
|
191
|
+
"nice -n 10 sudo apt update",
|
|
192
|
+
"nohup sudo apt >/dev/null",
|
|
193
|
+
"time sudo apt",
|
|
194
|
+
"ionice -c2 -n0 sudo apt",
|
|
195
|
+
"timeout 5 sudo apt update",
|
|
196
|
+
"command sudo apt update",
|
|
197
|
+
"exec sudo apt",
|
|
198
|
+
"xargs -I {} sudo {} < list",
|
|
199
|
+
"env FOO=bar sudo apt",
|
|
200
|
+
])("blocks `%s`", (command) => {
|
|
201
|
+
expect(isInteractiveSudoAttempt(command).blocked).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("allows a no-prompt sudo inside a runner wrapper", () => {
|
|
205
|
+
expect(isInteractiveSudoAttempt("env sudo -n true").blocked).toBe(false);
|
|
206
|
+
expect(isInteractiveSudoAttempt("timeout 5 sudo -n ls").blocked).toBe(false);
|
|
207
|
+
expect(isInteractiveSudoAttempt("nice -n 10 sudo -l").blocked).toBe(false);
|
|
208
|
+
expect(isInteractiveSudoAttempt("command -v sudo").blocked).toBe(false);
|
|
209
|
+
expect(isInteractiveSudoAttempt("which sudo").blocked).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("allows a bare runner with `sudo` and no command operand (cannot prompt)", () => {
|
|
213
|
+
expect(isInteractiveSudoAttempt("env sudo").blocked).toBe(false);
|
|
214
|
+
expect(isInteractiveSudoAttempt("sudo -n ls && timeout 10 sudo").blocked).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("blocks a sudo-named variable reference in the runner remainder (over-block, safe direction)", () => {
|
|
218
|
+
expect(isInteractiveSudoAttempt("env $SUDO apt").blocked).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it.each([
|
|
222
|
+
"command grep -r sudo .",
|
|
223
|
+
"timeout 5 grep -c sudo big.log",
|
|
224
|
+
"env LC_ALL=C grep sudo file",
|
|
225
|
+
"env echo a sudo b",
|
|
226
|
+
])("allows `%s` (sudo is an operand of the wrapped command, not its command word — B-2)", (command) => {
|
|
227
|
+
expect(isInteractiveSudoAttempt(command).blocked).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("keeps `xargs '\"sudo\" cat' < list` allowed (no command-word sudo survives the quotes — B-1 no-collateral)", () => {
|
|
231
|
+
expect(isInteractiveSudoAttempt("xargs '\"sudo\" cat' < list").blocked).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── review G-2: nested-shell / eval recursion ───────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe("isInteractiveSudoAttempt — G-2 nested shells and eval", () => {
|
|
238
|
+
/** Nest a program string in N `bash -c "..."` levels (inner quotes escaped). */
|
|
239
|
+
function nestBashC(program: string, levels: number): string {
|
|
240
|
+
let s = program;
|
|
241
|
+
for (let i = 0; i < levels; i++) {
|
|
242
|
+
s = `bash -c "${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
243
|
+
}
|
|
244
|
+
return s;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
it.each([
|
|
248
|
+
"sh -c 'sudo apt update'",
|
|
249
|
+
'bash -c "sudo apt update"',
|
|
250
|
+
"bash -c 'sudo apt update'",
|
|
251
|
+
"dash -xc 'sudo apt update'",
|
|
252
|
+
"su -c \"sudo apt\"",
|
|
253
|
+
"ksh -c sudo apt",
|
|
254
|
+
"eval 'sudo apt update'",
|
|
255
|
+
"eval sudo apt",
|
|
256
|
+
"zsh -c 'sudo apt update'",
|
|
257
|
+
])("blocks `%s`", (command) => {
|
|
258
|
+
expect(isInteractiveSudoAttempt(command).blocked).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("allows a no-prompt or non-sudo program string", () => {
|
|
262
|
+
expect(isInteractiveSudoAttempt("bash -c 'sudo -n true'").blocked).toBe(false);
|
|
263
|
+
expect(isInteractiveSudoAttempt("sh -c 'echo sudo'").blocked).toBe(false);
|
|
264
|
+
expect(isInteractiveSudoAttempt("bash -c 'apt update'").blocked).toBe(false);
|
|
265
|
+
expect(isInteractiveSudoAttempt("su -c \"sudo -n true\"").blocked).toBe(false);
|
|
266
|
+
expect(isInteractiveSudoAttempt("eval 'sudo -n ls'").blocked).toBe(false);
|
|
267
|
+
expect(isInteractiveSudoAttempt("bash script.sh").blocked).toBe(false); // known gap: program from file
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("blocks a deeply-quoted `bash -c` fixture that exceeds the depth cap", () => {
|
|
271
|
+
// fixture: `bash -c 'bash -c "bash -c \\(escaped inner levels) sudo apt''`
|
|
272
|
+
const command = `bash -c 'bash -c "bash -c \\"bash -c \\\\\\\"sudo apt\\\\\\\"\\""''`;
|
|
273
|
+
const r = isInteractiveSudoAttempt(command);
|
|
274
|
+
expect(r.blocked).toBe(true);
|
|
275
|
+
expect(r.reason).toContain("depth");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("blocks a 3-level nested `bash -c` chain reaching a real interactive sudo (not the depth cap)", () => {
|
|
279
|
+
const r = isInteractiveSudoAttempt(nestBashC("sudo apt", 3));
|
|
280
|
+
expect(r.blocked).toBe(true);
|
|
281
|
+
expect(r.reason).not.toContain("depth");
|
|
282
|
+
expect(isInteractiveSudoAttempt(nestBashC("sudo -n true", 3)).blocked).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("blocks beyond the nesting depth cap (4 levels): reason names the depth limit", () => {
|
|
286
|
+
const r = isInteractiveSudoAttempt(nestBashC("sudo apt", 4));
|
|
287
|
+
expect(r.blocked).toBe(true);
|
|
288
|
+
expect(r.reason).toContain("depth");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it.each([
|
|
292
|
+
"sh -c '\"sudo apt\"'",
|
|
293
|
+
"bash -c \"'sudo apt'\"",
|
|
294
|
+
"bash -c '\"sudo apt update\"'",
|
|
295
|
+
"eval '\"sudo apt\"'",
|
|
296
|
+
"eval \"'sudo apt'\"",
|
|
297
|
+
"env '\"sudo\" apt'",
|
|
298
|
+
"nohup '\"sudo\" apt'",
|
|
299
|
+
"timeout 5 '\"sudo\" apt'",
|
|
300
|
+
"command '\"sudo\" apt'",
|
|
301
|
+
])("blocks `%s` (quoted sudo survives re-tokenization as one word, hiding from the command word — B-1)", (command) => {
|
|
302
|
+
expect(isInteractiveSudoAttempt(command).blocked).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── review G-3: compound-command keyword transparency ───────────────────────
|
|
307
|
+
|
|
308
|
+
describe("isInteractiveSudoAttempt — G-3 compound-command keywords", () => {
|
|
309
|
+
it.each([
|
|
310
|
+
"if true; then sudo apt update; fi",
|
|
311
|
+
"while :; do sudo apt update; done",
|
|
312
|
+
"for i in 1; do sudo apt update; done",
|
|
313
|
+
"{ sudo apt update; }",
|
|
314
|
+
"! sudo apt update",
|
|
315
|
+
"( sudo apt update )",
|
|
316
|
+
])("blocks `%s`", (command) => {
|
|
317
|
+
expect(isInteractiveSudoAttempt(command).blocked).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("allows a no-prompt sudo inside a compound body", () => {
|
|
321
|
+
expect(isInteractiveSudoAttempt("if true; then sudo -n true; fi").blocked).toBe(false);
|
|
322
|
+
expect(isInteractiveSudoAttempt("while :; do sudo -n true; done").blocked).toBe(false);
|
|
323
|
+
expect(isInteractiveSudoAttempt("for i in 1; do echo $i; done").blocked).toBe(false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("treats bare keyword-only segments as having no command word", () => {
|
|
327
|
+
expect(isInteractiveSudoAttempt("fi").blocked).toBe(false);
|
|
328
|
+
expect(isInteractiveSudoAttempt("done").blocked).toBe(false);
|
|
329
|
+
expect(isInteractiveSudoAttempt("else").blocked).toBe(false);
|
|
330
|
+
expect(isInteractiveSudoAttempt("}").blocked).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ── integration: handleBashToolCall (called directly, as the tool_call veto) ─
|
|
335
|
+
|
|
336
|
+
describe("handleBashToolCall — tool_call veto integration", () => {
|
|
337
|
+
function bashEvent(command: string): ToolCallEvent {
|
|
338
|
+
return { type: "tool_call", toolCallId: "t-1", toolName: "bash", input: { command, timeout: 30 } };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
it("blocks a bash event whose command is interactive sudo", () => {
|
|
342
|
+
const result = handleBashToolCall(bashEvent("sudo apt update"));
|
|
343
|
+
expect(result).toEqual(
|
|
344
|
+
expect.objectContaining({
|
|
345
|
+
block: true,
|
|
346
|
+
reason: expect.stringContaining("sudo_exec"),
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("returns undefined for a bash event that is `sudo -n`", () => {
|
|
352
|
+
expect(handleBashToolCall(bashEvent("sudo -n true"))).toBeUndefined();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("returns undefined for non-bash tools", () => {
|
|
356
|
+
const result = handleBashToolCall({
|
|
357
|
+
type: "tool_call",
|
|
358
|
+
toolCallId: "t-2",
|
|
359
|
+
toolName: "read",
|
|
360
|
+
input: { path: "src/index.ts" },
|
|
361
|
+
});
|
|
362
|
+
expect(result).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("returns undefined for a bash event with an empty command", () => {
|
|
366
|
+
expect(handleBashToolCall(bashEvent(""))).toBeUndefined();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ── header known-gaps — documented behavior pinned ─────────────────────────
|
|
371
|
+
// One assertion per example in guard.ts's "Known gaps" header (drift-proof in
|
|
372
|
+
// both directions: header example ⇄ pin), plus the F-1 value-taking flag
|
|
373
|
+
// letter regressions (fixed, pinned to stop re-regressing).
|
|
374
|
+
|
|
375
|
+
describe("isInteractiveSudoAttempt — header known-gaps, documented behavior pinned", () => {
|
|
376
|
+
it("gap (1): allows `S='sudo'; $S apt` (cross-token assignment attribution is out of model)", () => {
|
|
377
|
+
expect(isInteractiveSudoAttempt("S='sudo'; $S apt").blocked).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("gap (2): allows sudo inside interpolation that never surfaces as a word", () => {
|
|
381
|
+
expect(isInteractiveSudoAttempt("echo \"$(sudo apt)\"").blocked).toBe(false);
|
|
382
|
+
expect(isInteractiveSudoAttempt("bash -c \"$(cat x)\"").blocked).toBe(false);
|
|
383
|
+
expect(isInteractiveSudoAttempt("eval \"$(echo 'sudo apt')\"").blocked).toBe(false);
|
|
384
|
+
expect(isInteractiveSudoAttempt("x=$(echo 1) sudo apt").blocked).toBe(false); // substitution-prefixed assignment word
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("gap (2): blocks substitution-word erasure (a following literal `sudo` claims command position)", () => {
|
|
388
|
+
expect(isInteractiveSudoAttempt("$(true) sudo apt").blocked).toBe(true);
|
|
389
|
+
expect(isInteractiveSudoAttempt("`x` sudo apt").blocked).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("gap (3): allows program source from files/redirects (unresolvable without reading files)", () => {
|
|
393
|
+
expect(isInteractiveSudoAttempt("bash script.sh").blocked).toBe(false);
|
|
394
|
+
expect(isInteractiveSudoAttempt("bash < file").blocked).toBe(false);
|
|
395
|
+
expect(isInteractiveSudoAttempt("su -c file").blocked).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("gap (5): allows an unknown wrapper-flag value hiding sudo (`timeout --signal SIGKILL sudo apt`)", () => {
|
|
399
|
+
expect(isInteractiveSudoAttempt("timeout --signal SIGKILL sudo apt").blocked).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("gap (5)(b): allows the `env -c 'sudo apt update'` quoted-command-string form (-c consumes the word as a VALUE)", () => {
|
|
403
|
+
expect(isInteractiveSudoAttempt("env -c 'sudo apt update'").blocked).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("gap (6): intentionally allows forwarding wrappers carrying sudo in their arguments", () => {
|
|
407
|
+
expect(isInteractiveSudoAttempt("ssh user@host \"sudo apt update\"").blocked).toBe(false);
|
|
408
|
+
expect(isInteractiveSudoAttempt("chroot / sudo apt").blocked).toBe(false);
|
|
409
|
+
expect(isInteractiveSudoAttempt("perf stat sudo apt").blocked).toBe(false);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("F-1 pins: blocks short wrapper flags whose next word is the flag's VALUE (regression from 467b757)", () => {
|
|
413
|
+
expect(isInteractiveSudoAttempt("exec -a foo sudo apt").blocked).toBe(true); // exec -a NAME
|
|
414
|
+
expect(isInteractiveSudoAttempt("time -f '%e' sudo apt").blocked).toBe(true); // time -f FORMAT
|
|
415
|
+
expect(isInteractiveSudoAttempt("xargs -e STOP sudo cat").blocked).toBe(true); // xargs -e STOPWORD
|
|
416
|
+
expect(isInteractiveSudoAttempt("env -C /tmp sudo apt").blocked).toBe(true); // env -C DIR
|
|
417
|
+
expect(isInteractiveSudoAttempt("env -u FOO sudo apt").blocked).toBe(true); // env -u VAR
|
|
418
|
+
expect(isInteractiveSudoAttempt("env -f /tmp/x sudo apt").blocked).toBe(true); // env -f FILE
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("F-1 pins: allows value-semantics forms (the flagged word is a VALUE, not the wrapped command)", () => {
|
|
422
|
+
expect(isInteractiveSudoAttempt("env -u sudo apt").blocked).toBe(false); // unsets a var named `sudo`; runs `apt`
|
|
423
|
+
expect(isInteractiveSudoAttempt("exec -a sudo apt").blocked).toBe(false); // argv0 swap; runs `apt`
|
|
424
|
+
expect(isInteractiveSudoAttempt("xargs -e sudo cat").blocked).toBe(false); // the stopword IS `sudo`; runs `cat`
|
|
425
|
+
expect(isInteractiveSudoAttempt("env '\"sudo\" -n' true").blocked).toBe(false); // allow-direction of the quoted-cluster no-prompt lookahead (guard.ts ~411): `env '"sudo" apt'` is pinned blocked, this pins the allow side
|
|
426
|
+
});
|
|
427
|
+
});
|