@andares/pi-toolkits 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/README.zh-CN.md +8 -5
- package/package.json +4 -4
- package/src/features/ask/index.ts +2 -1
- package/src/features/ask/prompt.ts +2 -1
- package/src/features/ask/reasons.ts +1 -1
- package/src/features/autostart/autostart.ts +1 -1
- package/src/features/autostart/index.ts +1 -1
- package/src/features/compact/compact.test.ts +19 -0
- package/src/features/compact/index.ts +5 -3
- package/src/features/compact/prompt.ts +22 -11
- package/src/features/favorites/constants.ts +13 -8
- package/src/features/favorites/favorites.test.ts +42 -0
- package/src/features/favorites/patch-cycle.ts +5 -4
- package/src/features/favorites/patch-selector.ts +44 -8
- package/src/features/init/detect.test.ts +5 -2
- package/src/features/permission/audit.test.ts +10 -10
- package/src/features/permission/constants.test.ts +28 -2
- package/src/features/permission/constants.ts +28 -3
- package/src/features/permission/index.test.ts +76 -33
- package/src/features/permission/policy.test.ts +48 -11
- package/src/features/permission/policy.ts +16 -1
- package/src/features/permission/units.test.ts +25 -9
- package/src/features/permission/units.ts +49 -24
- package/src/features/stash/stash.ts +1 -1
- package/src/lib/gates.ts +2 -2
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +6 -5
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
* Fixed safety tables: credential paths are denied in every mode, destructive
|
|
3
3
|
* commands always ask.
|
|
4
4
|
*/
|
|
5
|
-
import { homedir } from "node:os";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
|
-
import {
|
|
8
|
+
import { canonicalize } from "./paths.js";
|
|
9
|
+
import {
|
|
10
|
+
dangerReason,
|
|
11
|
+
isTemporaryPath,
|
|
12
|
+
secretReason,
|
|
13
|
+
temporaryRoots,
|
|
14
|
+
} from "./constants.js";
|
|
9
15
|
|
|
10
16
|
const HOME = homedir();
|
|
11
17
|
|
|
@@ -37,11 +43,30 @@ describe("secrets table", () => {
|
|
|
37
43
|
});
|
|
38
44
|
});
|
|
39
45
|
|
|
46
|
+
describe("temporary write zone", () => {
|
|
47
|
+
it("covers the OS temp root and the common volatile directories", () => {
|
|
48
|
+
expect(temporaryRoots()).toContain(canonicalize(tmpdir()));
|
|
49
|
+
expect(temporaryRoots()).toContain(canonicalize("/var/tmp"));
|
|
50
|
+
expect(temporaryRoots()).toContain(canonicalize("/dev/shm"));
|
|
51
|
+
expect(isTemporaryPath(join(tmpdir(), "pi", "x"))).toBe(true);
|
|
52
|
+
expect(isTemporaryPath(join("/var/tmp", "x"))).toBe(true);
|
|
53
|
+
expect(isTemporaryPath(join("/dev/shm", "x"))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not over-match a sibling prefix or an ordinary path", () => {
|
|
57
|
+
expect(isTemporaryPath(`${tmpdir()}-evil/x`)).toBe(false);
|
|
58
|
+
expect(isTemporaryPath("/var/tmp-evil/x")).toBe(false);
|
|
59
|
+
expect(isTemporaryPath("/proj/x")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
40
63
|
describe("danger table", () => {
|
|
41
64
|
it("catches the destructive core", () => {
|
|
42
65
|
expect(dangerReason("rm", ["-rf", "/"], ["/"], "/proj")).toContain("recursive delete");
|
|
43
66
|
expect(dangerReason("rm", ["-rf", HOME], [HOME], "/proj")).toContain("recursive delete");
|
|
44
67
|
expect(dangerReason("rm", ["-rf", "/proj"], ["/proj"], "/proj")).toContain("recursive delete");
|
|
68
|
+
expect(dangerReason("rm", ["-rf", tmpdir()], [tmpdir()], "/proj")).toContain("recursive delete");
|
|
69
|
+
expect(dangerReason("rm", ["-rf", "/var/tmp"], ["/var/tmp"], "/proj")).toContain("recursive delete");
|
|
45
70
|
expect(dangerReason("git", ["push", "--force"], [], "/proj")).toBe("forced git push");
|
|
46
71
|
expect(dangerReason("git", ["push", "-f"], [], "/proj")).toBe("forced git push");
|
|
47
72
|
expect(dangerReason("git", ["reset", "--hard"], [], "/proj")).toBe("git reset --hard");
|
|
@@ -53,6 +78,7 @@ describe("danger table", () => {
|
|
|
53
78
|
|
|
54
79
|
it("does not flag ordinary commands", () => {
|
|
55
80
|
expect(dangerReason("rm", ["-rf", "/proj/build"], ["/proj/build"], "/proj")).toBeUndefined();
|
|
81
|
+
expect(dangerReason("rm", ["-rf", join(tmpdir(), "x")], [join(tmpdir(), "x")], "/proj")).toBeUndefined();
|
|
56
82
|
expect(dangerReason("git", ["push"], [], "/proj")).toBeUndefined();
|
|
57
83
|
expect(dangerReason("git", ["log"], [], "/proj")).toBeUndefined();
|
|
58
84
|
expect(dangerReason("npm", ["view", "react"], [], "/proj")).toBeUndefined();
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* when their scope says allow (`rm -rf .` inside the project is still a
|
|
14
14
|
* guarded operation). Scope alone cannot express "destructive".
|
|
15
15
|
*/
|
|
16
|
-
import { homedir } from "node:os";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
17
|
import { basename, join, sep } from "node:path";
|
|
18
|
+
import { canonicalize } from "./paths.js";
|
|
18
19
|
|
|
19
20
|
/** Three-way mode, used for outside-project operations. */
|
|
20
21
|
export type TriMode = "ask" | "allow" | "deny";
|
|
@@ -85,6 +86,29 @@ function isSecretEnvFile(pathValue: string): boolean {
|
|
|
85
86
|
return name === ".env" || name.startsWith(".env.");
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Ephemeral directories whose writes are built-in safe: a scratch artifact
|
|
91
|
+
* under the OS temp root is not a boundary crossing worth a dialog. Kept to
|
|
92
|
+
* genuinely volatile locations; `~/.cache` is deliberately not included (it
|
|
93
|
+
* is a home-directory state area, not a scratch area).
|
|
94
|
+
*
|
|
95
|
+
* Resolved once at module load so the comparisons use the same spelling as the
|
|
96
|
+
* policy's canonical paths (`/tmp` is a symlink to `/private/tmp` on macOS).
|
|
97
|
+
*/
|
|
98
|
+
const TEMPORARY_ROOTS: readonly string[] = [tmpdir(), "/var/tmp", "/dev/shm"].map(
|
|
99
|
+
(root) => canonicalize(root),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
export function temporaryRoots(): string[] {
|
|
103
|
+
return [...TEMPORARY_ROOTS];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `true` when `pathValue` is inside one of {@link temporaryRoots}. */
|
|
107
|
+
export function isTemporaryPath(pathValue: string): boolean {
|
|
108
|
+
const canonical = canonicalize(pathValue);
|
|
109
|
+
return TEMPORARY_ROOTS.some((root) => pathWithin(root, canonical));
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
/** Absolute secret prefixes, `~` expanded. */
|
|
89
113
|
export function secretRoots(): string[] {
|
|
90
114
|
return SECRET_ROOTS.map((root) =>
|
|
@@ -118,11 +142,12 @@ export function dangerReason(
|
|
|
118
142
|
const inDangerousRoot = (pathValue: string): boolean =>
|
|
119
143
|
pathValue === "/" ||
|
|
120
144
|
pathValue === home ||
|
|
121
|
-
(base !== null && pathValue === base)
|
|
145
|
+
(base !== null && pathValue === base) ||
|
|
146
|
+
TEMPORARY_ROOTS.includes(canonicalize(pathValue));
|
|
122
147
|
|
|
123
148
|
if (command === "sudo" || command === "doas") return "privileged command";
|
|
124
149
|
if (command === "rm" && targets.some(inDangerousRoot)) {
|
|
125
|
-
return "recursive delete of /, the home directory,
|
|
150
|
+
return "recursive delete of /, the home directory, the working directory, or a temp root";
|
|
126
151
|
}
|
|
127
152
|
if (command === "git") {
|
|
128
153
|
const [verb, ...rest] = args;
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
resetJailedTools,
|
|
23
23
|
} from "../../lib/tools.js";
|
|
24
24
|
import { markStartupCommand } from "../../lib/startup.js";
|
|
25
|
+
import { canonicalize } from "./paths.js";
|
|
25
26
|
import { registerPermission } from "./index.js";
|
|
26
27
|
|
|
27
28
|
function bashEvent(command: string): ToolCallEvent {
|
|
@@ -169,7 +170,7 @@ function asAsk(verdict: unknown): AskVerdict {
|
|
|
169
170
|
describe("/ask-perm", () => {
|
|
170
171
|
it("is off by default", async () => {
|
|
171
172
|
const h = makeHarness();
|
|
172
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
173
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).toBeUndefined();
|
|
173
174
|
});
|
|
174
175
|
|
|
175
176
|
it("enables from arguments, reports status, and asks for an outside write", async () => {
|
|
@@ -177,15 +178,53 @@ describe("/ask-perm", () => {
|
|
|
177
178
|
await h.command("allow allow allow ask");
|
|
178
179
|
expect(h.statuses.at(-1)?.[0]).toBe("perm-mode");
|
|
179
180
|
expect(h.statuses.at(-1)?.[1]).toContain("perm");
|
|
180
|
-
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
181
|
-
expect(verdict.request.details.join("\n")).toContain("/
|
|
182
|
-
expect(verdict.request.root).toBe("/
|
|
181
|
+
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside")));
|
|
182
|
+
expect(verdict.request.details.join("\n")).toContain("/var/pi-perm-outside");
|
|
183
|
+
expect(verdict.request.root).toBe(canonicalize("/var"));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not ask for a write under the OS temp root", async () => {
|
|
187
|
+
const h = makeHarness();
|
|
188
|
+
await h.command("allow allow allow ask");
|
|
189
|
+
expect(
|
|
190
|
+
await h.evaluate(bashEvent("echo hi > /tmp/pi-perm-temp.txt")),
|
|
191
|
+
).toBeUndefined();
|
|
192
|
+
// The same rule must cover the path-bearing write tool, not just bash.
|
|
193
|
+
expect(
|
|
194
|
+
await h.evaluate(pathEvent("write", "/tmp/pi-perm-temp.txt")),
|
|
195
|
+
).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does not ask for a /dev/null sink operand", async () => {
|
|
199
|
+
const h = makeHarness();
|
|
200
|
+
await h.command("allow allow allow ask");
|
|
201
|
+
expect(
|
|
202
|
+
await h.evaluate(
|
|
203
|
+
bashEvent("curl -s -o /dev/null -w '%{http_code}\\n' http://localhost:5173/"),
|
|
204
|
+
),
|
|
205
|
+
).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not ask for echo arguments that merely look like paths", async () => {
|
|
209
|
+
const h = makeHarness();
|
|
210
|
+
await h.command("allow allow allow ask");
|
|
211
|
+
expect(
|
|
212
|
+
await h.evaluate(
|
|
213
|
+
bashEvent('echo " $f 改动 $tot 行 / 忽略空白后 $w 行"'),
|
|
214
|
+
),
|
|
215
|
+
).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("still asks before deleting a temp root itself", async () => {
|
|
219
|
+
const h = makeHarness();
|
|
220
|
+
await h.command("allow allow allow ask");
|
|
221
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp"))).kind).toBe("ask");
|
|
183
222
|
});
|
|
184
223
|
|
|
185
224
|
it("blocks immediately when outside writes are denied", async () => {
|
|
186
225
|
const h = makeHarness();
|
|
187
226
|
await h.command("allow allow allow deny");
|
|
188
|
-
const verdict = (await h.evaluate(bashEvent("rm -rf /
|
|
227
|
+
const verdict = (await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))) as {
|
|
189
228
|
kind: "block";
|
|
190
229
|
reason: string;
|
|
191
230
|
};
|
|
@@ -197,17 +236,17 @@ describe("/ask-perm", () => {
|
|
|
197
236
|
it("records a session grant and applies it to later calls", async () => {
|
|
198
237
|
const h = makeHarness();
|
|
199
238
|
await h.command("allow allow allow ask");
|
|
200
|
-
const first = asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
239
|
+
const first = asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/x")));
|
|
201
240
|
first.request.apply("allow-session");
|
|
202
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
241
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/y"))).toBeUndefined();
|
|
203
242
|
});
|
|
204
243
|
|
|
205
244
|
it("records a session deny and blocks later calls", async () => {
|
|
206
245
|
const h = makeHarness();
|
|
207
246
|
await h.command("allow allow allow ask");
|
|
208
|
-
const first = asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
247
|
+
const first = asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/x")));
|
|
209
248
|
first.request.apply("deny-session");
|
|
210
|
-
const later = (await h.evaluate(bashEvent("rm -rf /
|
|
249
|
+
const later = (await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/y"))) as {
|
|
211
250
|
kind: "block";
|
|
212
251
|
reason: string;
|
|
213
252
|
};
|
|
@@ -220,7 +259,7 @@ describe("/ask-perm", () => {
|
|
|
220
259
|
await h.command("allow allow allow ask");
|
|
221
260
|
await h.command("");
|
|
222
261
|
expect(h.statuses.at(-1)?.[1]).toBeUndefined();
|
|
223
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
262
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).toBeUndefined();
|
|
224
263
|
});
|
|
225
264
|
|
|
226
265
|
it("writes a startup notice as an entry instead of a status line", async () => {
|
|
@@ -233,7 +272,7 @@ describe("/ask-perm", () => {
|
|
|
233
272
|
"Permission ON",
|
|
234
273
|
);
|
|
235
274
|
expect(h.statuses.at(-1)?.[1]).toContain("perm");
|
|
236
|
-
expect(asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
275
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).kind).toBe(
|
|
237
276
|
"ask",
|
|
238
277
|
);
|
|
239
278
|
});
|
|
@@ -250,14 +289,14 @@ describe("/ask-perm", () => {
|
|
|
250
289
|
const h = makeHarness();
|
|
251
290
|
await h.command("");
|
|
252
291
|
expect(h.notes.at(-1)?.message).toContain("cancelled");
|
|
253
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
292
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).toBeUndefined();
|
|
254
293
|
});
|
|
255
294
|
|
|
256
295
|
it("rejects invalid arguments without enabling", async () => {
|
|
257
296
|
const h = makeHarness();
|
|
258
297
|
await h.command("deny");
|
|
259
298
|
expect(h.notes.at(-1)?.kind).toBe("error");
|
|
260
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
299
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).toBeUndefined();
|
|
261
300
|
});
|
|
262
301
|
|
|
263
302
|
it("keeps the state when the same session emits session_start again", async () => {
|
|
@@ -266,7 +305,7 @@ describe("/ask-perm", () => {
|
|
|
266
305
|
await h.command("allow allow allow ask");
|
|
267
306
|
// A startup rebind reports the same session again; state must survive.
|
|
268
307
|
await h.sessionStart();
|
|
269
|
-
expect(asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
308
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).kind).toBe(
|
|
270
309
|
"ask",
|
|
271
310
|
);
|
|
272
311
|
});
|
|
@@ -276,28 +315,28 @@ describe("/ask-perm", () => {
|
|
|
276
315
|
await h.command("allow allow allow ask");
|
|
277
316
|
h.setSessionId("session-2");
|
|
278
317
|
await h.sessionStart();
|
|
279
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
318
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).toBeUndefined();
|
|
280
319
|
});
|
|
281
320
|
|
|
282
321
|
it("resets on session_start", async () => {
|
|
283
322
|
const h = makeHarness();
|
|
284
323
|
await h.command("allow allow allow ask");
|
|
285
324
|
await h.sessionStart();
|
|
286
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
325
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside"))).toBeUndefined();
|
|
287
326
|
expect(h.statuses.at(-1)?.[1]).toBeUndefined();
|
|
288
327
|
});
|
|
289
328
|
|
|
290
329
|
it("treats add-dir directories as inside the project", async () => {
|
|
291
330
|
const h = makeHarness();
|
|
292
331
|
await h.command("allow allow allow ask");
|
|
293
|
-
expect(asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
332
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-added/x"))).kind).toBe("ask");
|
|
294
333
|
|
|
295
334
|
h.branch.push({
|
|
296
335
|
type: "custom",
|
|
297
336
|
customType: "add-dir:state",
|
|
298
|
-
data: { dirs: [{ absolutePath: "/
|
|
337
|
+
data: { dirs: [{ absolutePath: "/var/pi-perm-added" }] },
|
|
299
338
|
});
|
|
300
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
339
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-added/x"))).toBeUndefined();
|
|
301
340
|
});
|
|
302
341
|
|
|
303
342
|
it("treats a redirect write to a credential path as a fixed deny, not a write ask", async () => {
|
|
@@ -313,15 +352,19 @@ describe("/ask-perm", () => {
|
|
|
313
352
|
it("judges git -C by the -C directory, not the working directory", async () => {
|
|
314
353
|
const h = makeHarness();
|
|
315
354
|
await h.command("allow allow allow ask");
|
|
316
|
-
expect(
|
|
317
|
-
|
|
355
|
+
expect(
|
|
356
|
+
await h.evaluate(bashEvent("git -C /var/pi-perm-outside log")),
|
|
357
|
+
).toBeUndefined();
|
|
358
|
+
expect(
|
|
359
|
+
asAsk(await h.evaluate(bashEvent("git -C /var/pi-perm-outside push"))).kind,
|
|
360
|
+
).toBe("ask");
|
|
318
361
|
});
|
|
319
362
|
|
|
320
363
|
it("answers path-bearing tools by their own path", async () => {
|
|
321
364
|
const h = makeHarness();
|
|
322
365
|
await h.command("allow allow allow ask");
|
|
323
366
|
expect(await h.evaluate(pathEvent("write", "notes.txt"))).toBeUndefined();
|
|
324
|
-
expect(asAsk(await h.evaluate(pathEvent("write", "/
|
|
367
|
+
expect(asAsk(await h.evaluate(pathEvent("write", "/var/pi-perm-outside.txt"))).kind).toBe("ask");
|
|
325
368
|
const denied = (await h.evaluate(pathEvent("read", "~/.ssh/id_rsa"))) as {
|
|
326
369
|
kind: "block";
|
|
327
370
|
};
|
|
@@ -339,26 +382,26 @@ describe("/ask-perm", () => {
|
|
|
339
382
|
await h.command("allow allow allow ask");
|
|
340
383
|
expect(h.notes).toHaveLength(1);
|
|
341
384
|
|
|
342
|
-
asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
385
|
+
asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/x"))).request.apply(
|
|
343
386
|
"allow-session",
|
|
344
387
|
);
|
|
345
388
|
await h.command("allow allow allow ask");
|
|
346
389
|
expect(h.notes).toHaveLength(1);
|
|
347
390
|
// The grant collected before the repeat is still in force.
|
|
348
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
391
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/y"))).toBeUndefined();
|
|
349
392
|
});
|
|
350
393
|
|
|
351
394
|
it("notifies and clears session grants when the modes actually change", async () => {
|
|
352
395
|
const h = makeHarness();
|
|
353
396
|
await h.command("allow allow allow ask");
|
|
354
|
-
asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
397
|
+
asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/x"))).request.apply(
|
|
355
398
|
"allow-session",
|
|
356
399
|
);
|
|
357
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
400
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/y"))).toBeUndefined();
|
|
358
401
|
|
|
359
402
|
await h.command("ask allow allow ask");
|
|
360
403
|
expect(h.notes).toHaveLength(2);
|
|
361
|
-
expect(asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
404
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/y"))).kind).toBe("ask");
|
|
362
405
|
});
|
|
363
406
|
});
|
|
364
407
|
|
|
@@ -366,7 +409,7 @@ describe("dialog contract", () => {
|
|
|
366
409
|
it("fails closed without a UI", async () => {
|
|
367
410
|
const h = makeHarness();
|
|
368
411
|
await h.command("allow allow allow ask");
|
|
369
|
-
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
412
|
+
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside")));
|
|
370
413
|
const noUi = { ...h.ctx, hasUI: false } as ExtensionContext;
|
|
371
414
|
await expect(verdict.request.present(noUi)).resolves.toBeUndefined();
|
|
372
415
|
});
|
|
@@ -374,7 +417,7 @@ describe("dialog contract", () => {
|
|
|
374
417
|
it("falls back to the host selector in non-TUI modes", async () => {
|
|
375
418
|
const h = makeHarness();
|
|
376
419
|
await h.command("allow allow allow ask");
|
|
377
|
-
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
420
|
+
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside")));
|
|
378
421
|
const rpc = {
|
|
379
422
|
cwd: h.cwd,
|
|
380
423
|
hasUI: true,
|
|
@@ -456,13 +499,13 @@ describe("audit log", () => {
|
|
|
456
499
|
const { audit } = makeAudit();
|
|
457
500
|
const h = makeHarness(audit);
|
|
458
501
|
await h.command("allow allow allow ask");
|
|
459
|
-
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
502
|
+
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/x")));
|
|
460
503
|
expect(verdict.request.audit).toMatchObject({
|
|
461
504
|
modes: { outWrite: "ask" },
|
|
462
505
|
verdict: {
|
|
463
506
|
action: "ask",
|
|
464
507
|
layer: "mode",
|
|
465
|
-
target: "/
|
|
508
|
+
target: "/var/pi-perm-outside/x",
|
|
466
509
|
scope: "out",
|
|
467
510
|
},
|
|
468
511
|
});
|
|
@@ -543,9 +586,9 @@ describe("audit log — session-grant allows", () => {
|
|
|
543
586
|
const { audit, records } = makeAudit();
|
|
544
587
|
const h = makeHarness(audit);
|
|
545
588
|
await h.command("allow allow allow ask");
|
|
546
|
-
const first = asAsk(await h.evaluate(bashEvent("rm -rf /
|
|
589
|
+
const first = asAsk(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/x")));
|
|
547
590
|
first.request.apply("allow-session");
|
|
548
|
-
expect(await h.evaluate(bashEvent("rm -rf /
|
|
591
|
+
expect(await h.evaluate(bashEvent("rm -rf /var/pi-perm-outside/y"))).toBeUndefined();
|
|
549
592
|
|
|
550
593
|
const allow = records.find((record) => record.event === "allow");
|
|
551
594
|
expect(allow).toMatchObject({
|
|
@@ -7,7 +7,7 @@ import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { describe, expect, it } from "vitest";
|
|
10
|
-
import { DEFAULT_MODES, type PermissionModes } from "./constants.js";
|
|
10
|
+
import { DEFAULT_MODES, temporaryRoots, type PermissionModes } from "./constants.js";
|
|
11
11
|
import { canonicalize } from "./paths.js";
|
|
12
12
|
import {
|
|
13
13
|
decideProgram,
|
|
@@ -46,14 +46,14 @@ describe("four-mode policy", () => {
|
|
|
46
46
|
it("asks for an outside write and allows an outside read by default", () => {
|
|
47
47
|
const cwd = project();
|
|
48
48
|
const ctx = makeContext(cwd);
|
|
49
|
-
expect(decide("rm -rf /
|
|
49
|
+
expect(decide("rm -rf /var/pi-perm-outside", ctx).action).toBe("ask");
|
|
50
50
|
expect(decide("cat /etc/hostname", ctx).action).toBe("allow");
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it("honours a deny mode for outside writes", () => {
|
|
54
54
|
const cwd = project();
|
|
55
55
|
const ctx = makeContext(cwd, { ...DEFAULT_MODES, outWrite: "deny" });
|
|
56
|
-
const decision = decide("rm -rf /
|
|
56
|
+
const decision = decide("rm -rf /var/pi-perm-outside", ctx);
|
|
57
57
|
expect(decision.action).toBe("deny");
|
|
58
58
|
expect(decision.reason).toContain("outside-project write");
|
|
59
59
|
});
|
|
@@ -65,6 +65,43 @@ describe("four-mode policy", () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
describe("temporary write zone", () => {
|
|
69
|
+
it("allows a temp write when the mode would otherwise ask", () => {
|
|
70
|
+
const cwd = project();
|
|
71
|
+
const ctx = makeContext(cwd);
|
|
72
|
+
for (const root of temporaryRoots()) {
|
|
73
|
+
const decision = decide(`echo hi > ${join(root, "pi-perm-temp.txt")}`, ctx);
|
|
74
|
+
expect(decision.action).toBe("allow");
|
|
75
|
+
expect(decision.layer).toBe("temp");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("still honours an explicit outside-write deny", () => {
|
|
80
|
+
const cwd = project();
|
|
81
|
+
const ctx = makeContext(cwd, { ...DEFAULT_MODES, outWrite: "deny" });
|
|
82
|
+
const decision = decide(`echo hi > ${join(tmpdir(), "pi-perm-temp.txt")}`, ctx);
|
|
83
|
+
expect(decision.action).toBe("deny");
|
|
84
|
+
expect(decision.layer).toBe("mode");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("still asks before deleting a temp root itself", () => {
|
|
88
|
+
const cwd = project();
|
|
89
|
+
const decision = decide(`rm -rf ${tmpdir()}`, makeContext(cwd));
|
|
90
|
+
expect(decision.action).toBe("ask");
|
|
91
|
+
expect(decision.layer).toBe("danger");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps the secrets table ahead of the temp zone", () => {
|
|
95
|
+
const cwd = project();
|
|
96
|
+
const decision = decide(
|
|
97
|
+
`echo x > ${join(tmpdir(), ".env")}`,
|
|
98
|
+
makeContext(cwd),
|
|
99
|
+
);
|
|
100
|
+
expect(decision.action).toBe("deny");
|
|
101
|
+
expect(decision.layer).toBe("secret");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
68
105
|
describe("fixed safety table", () => {
|
|
69
106
|
it("denies credential paths in every mode, even when reads are allowed", () => {
|
|
70
107
|
const cwd = project();
|
|
@@ -117,26 +154,26 @@ describe("session grants", () => {
|
|
|
117
154
|
it("allows a write under a granted directory", () => {
|
|
118
155
|
const cwd = project();
|
|
119
156
|
const rules = new SessionRules();
|
|
120
|
-
rules.allow({ effect: "write", root: "/
|
|
157
|
+
rules.allow({ effect: "write", root: "/var/pi-perm-granted" });
|
|
121
158
|
expect(
|
|
122
|
-
decide("rm -rf /
|
|
159
|
+
decide("rm -rf /var/pi-perm-granted/x", makeContext(cwd, DEFAULT_MODES, rules)).action,
|
|
123
160
|
).toBe("allow");
|
|
124
161
|
});
|
|
125
162
|
|
|
126
163
|
it("does not let a read grant cover a write", () => {
|
|
127
164
|
const cwd = project();
|
|
128
165
|
const rules = new SessionRules();
|
|
129
|
-
rules.allow({ effect: "read", root: "/
|
|
166
|
+
rules.allow({ effect: "read", root: "/var/pi-perm-granted" });
|
|
130
167
|
expect(
|
|
131
|
-
decide("rm -rf /
|
|
168
|
+
decide("rm -rf /var/pi-perm-granted/x", makeContext(cwd, DEFAULT_MODES, rules)).action,
|
|
132
169
|
).toBe("ask");
|
|
133
170
|
});
|
|
134
171
|
|
|
135
172
|
it("denies before allowing, even when the mode allows", () => {
|
|
136
173
|
const cwd = project();
|
|
137
174
|
const rules = new SessionRules();
|
|
138
|
-
rules.deny({ effect: "read", root: "/
|
|
139
|
-
const decision = decide("cat /
|
|
175
|
+
rules.deny({ effect: "read", root: "/var/pi-perm-granted" });
|
|
176
|
+
const decision = decide("cat /var/pi-perm-granted/x", makeContext(cwd, DEFAULT_MODES, rules));
|
|
140
177
|
expect(decision.action).toBe("deny");
|
|
141
178
|
expect(decision.reason).toContain("denied for this session");
|
|
142
179
|
});
|
|
@@ -155,13 +192,13 @@ describe("severity folding", () => {
|
|
|
155
192
|
it("takes the strictest unit of a compound command", () => {
|
|
156
193
|
const cwd = project();
|
|
157
194
|
const ctx = makeContext(cwd, { ...DEFAULT_MODES, outWrite: "deny" });
|
|
158
|
-
const decision = decide(`cd ${cwd} && rm -rf /
|
|
195
|
+
const decision = decide(`cd ${cwd} && rm -rf /var/pi-perm-outside`, ctx);
|
|
159
196
|
expect(decision.action).toBe("deny");
|
|
160
197
|
});
|
|
161
198
|
|
|
162
199
|
it("does not let an allowed first segment mask a later ask", () => {
|
|
163
200
|
const cwd = project();
|
|
164
|
-
const decision = decide("echo hi && rm -rf /
|
|
201
|
+
const decision = decide("echo hi && rm -rf /var/pi-perm-outside", makeContext(cwd));
|
|
165
202
|
expect(decision.action).toBe("ask");
|
|
166
203
|
});
|
|
167
204
|
});
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* `cd safe && rm -rf /` cannot ride on the first segment's verdict.
|
|
14
14
|
*/
|
|
15
15
|
import type { PermissionModes } from "./constants.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
dangerReason,
|
|
18
|
+
isTemporaryPath,
|
|
19
|
+
secretReason,
|
|
20
|
+
type Effect,
|
|
21
|
+
} from "./constants.js";
|
|
17
22
|
import { canonicalize, grantRoot, insideAnyRoot } from "./paths.js";
|
|
18
23
|
import type { SessionRules } from "./session-rules.js";
|
|
19
24
|
import type { CommandUnit } from "./units.js";
|
|
@@ -51,6 +56,7 @@ export type PolicyLayer =
|
|
|
51
56
|
| "session-command"
|
|
52
57
|
| "danger"
|
|
53
58
|
| "privileged"
|
|
59
|
+
| "temp"
|
|
54
60
|
| "opaque"
|
|
55
61
|
| "unresolved"
|
|
56
62
|
| "empty";
|
|
@@ -132,6 +138,15 @@ function decidePath(
|
|
|
132
138
|
reason: `${scope === "out" ? "outside-project" : "in-project"} ${effect} is set to deny`,
|
|
133
139
|
};
|
|
134
140
|
}
|
|
141
|
+
// A write under the OS temp root is the one outside write that is safe by
|
|
142
|
+
// default: it is scratch space, not persistent state. It only replaces the
|
|
143
|
+
// *ask* answer — an explicit `deny` still wins, and the temp root itself is
|
|
144
|
+
// protected by the danger table (`rm -rf /tmp` still asks). Only **outside**
|
|
145
|
+
// writes are affected: a project that happens to live under /tmp keeps its
|
|
146
|
+
// own in-project mode.
|
|
147
|
+
if (scope === "out" && effect === "write" && isTemporaryPath(pathValue)) {
|
|
148
|
+
return { action: "allow", layer: "temp", unit, target };
|
|
149
|
+
}
|
|
135
150
|
return {
|
|
136
151
|
action: "ask",
|
|
137
152
|
layer: "mode",
|
|
@@ -88,14 +88,21 @@ describe("wrappers and nested execution", () => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
describe("redirects and heredocs", () => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
it("classifies redirect targets and ignores /dev/null", () => {
|
|
92
|
+
expect(summary("ls 2>/dev/null")[0]?.targets).toEqual([]);
|
|
93
|
+
expect(summary("echo hi > /tmp/x")[0]?.targets).toEqual(["write:/tmp/x"]);
|
|
94
|
+
expect(summary("cat < input > output")[0]?.targets).toEqual([
|
|
95
|
+
"read:/proj/input",
|
|
96
|
+
"write:/proj/output",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ignores a device sink as a plain operand, not just a redirect", () => {
|
|
101
|
+
expect(summary("curl -s -o /dev/null https://example.com")[0]?.targets).toEqual([]);
|
|
102
|
+
expect(summary("sort -o /dev/null input")[0]?.targets).toEqual([]);
|
|
103
|
+
// A command that mutates the node itself still surfaces the target.
|
|
104
|
+
expect(summary("rm /dev/null")[0]?.targets).toEqual(["write:/dev/null"]);
|
|
105
|
+
});
|
|
99
106
|
|
|
100
107
|
it("treats a redirect to a credential path as a write to it", () => {
|
|
101
108
|
expect(summary("echo a > ~/.ssh/authorized_keys")[0]?.targets).toEqual([
|
|
@@ -170,6 +177,15 @@ describe("paths", () => {
|
|
|
170
177
|
]);
|
|
171
178
|
});
|
|
172
179
|
|
|
180
|
+
it("does not treat echo/printf arguments as paths", () => {
|
|
181
|
+
const echo = first('echo " $f 改动 $tot 行 / 忽略空白后 $w 行"');
|
|
182
|
+
expect(echo.opaque).toBe(false);
|
|
183
|
+
expect(echo.targets).toEqual([]);
|
|
184
|
+
const printf = first('printf "%s\\n" "$value"');
|
|
185
|
+
expect(printf.opaque).toBe(false);
|
|
186
|
+
expect(printf.targets).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
173
189
|
it("expands ~ while parsing", () => {
|
|
174
190
|
expect(summary("tee ~/x")[0]?.targets).toEqual([`write:${HOME}/x`]);
|
|
175
191
|
});
|
|
@@ -177,7 +193,7 @@ describe("paths", () => {
|
|
|
177
193
|
|
|
178
194
|
describe("fail closed", () => {
|
|
179
195
|
it("marks an unresolved variable in a path position opaque", () => {
|
|
180
|
-
expect(summary("
|
|
196
|
+
expect(summary("cat $UNKNOWN/x")).toEqual([
|
|
181
197
|
expect.objectContaining({ opaque: true, targets: [] }),
|
|
182
198
|
]);
|
|
183
199
|
});
|