@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.
@@ -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 { dangerReason, secretReason } from "./constants.js";
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, or the working 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 /tmp/outside"))).toBeUndefined();
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 /tmp/outside")));
181
- expect(verdict.request.details.join("\n")).toContain("/tmp/outside");
182
- expect(verdict.request.root).toBe("/tmp");
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 /tmp/outside"))) as {
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 /tmp/outside/x")));
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 /tmp/outside/y"))).toBeUndefined();
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 /tmp/outside/x")));
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 /tmp/outside/y"))) as {
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 /tmp/outside"))).toBeUndefined();
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 /tmp/outside"))).kind).toBe(
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 /tmp/outside"))).toBeUndefined();
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 /tmp/outside"))).toBeUndefined();
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 /tmp/outside"))).kind).toBe(
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 /tmp/outside"))).toBeUndefined();
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 /tmp/outside"))).toBeUndefined();
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 /tmp/added/x"))).kind).toBe("ask");
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: "/tmp/added" }] },
337
+ data: { dirs: [{ absolutePath: "/var/pi-perm-added" }] },
299
338
  });
300
- expect(await h.evaluate(bashEvent("rm -rf /tmp/added/x"))).toBeUndefined();
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(await h.evaluate(bashEvent("git -C ../outside log"))).toBeUndefined();
317
- expect(asAsk(await h.evaluate(bashEvent("git -C ../outside push"))).kind).toBe("ask");
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", "/tmp/outside.txt"))).kind).toBe("ask");
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 /tmp/outside/x"))).request.apply(
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 /tmp/outside/y"))).toBeUndefined();
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 /tmp/outside/x"))).request.apply(
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 /tmp/outside/y"))).toBeUndefined();
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 /tmp/outside/y"))).kind).toBe("ask");
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 /tmp/outside")));
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 /tmp/outside")));
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 /tmp/outside/x")));
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: "/tmp/outside/x",
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 /tmp/outside/x")));
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 /tmp/outside/y"))).toBeUndefined();
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 /tmp/outside", ctx).action).toBe("ask");
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 /tmp/outside", ctx);
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: "/tmp/granted" });
157
+ rules.allow({ effect: "write", root: "/var/pi-perm-granted" });
121
158
  expect(
122
- decide("rm -rf /tmp/granted/x", makeContext(cwd, DEFAULT_MODES, rules)).action,
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: "/tmp/granted" });
166
+ rules.allow({ effect: "read", root: "/var/pi-perm-granted" });
130
167
  expect(
131
- decide("rm -rf /tmp/granted/x", makeContext(cwd, DEFAULT_MODES, rules)).action,
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: "/tmp/granted" });
139
- const decision = decide("cat /tmp/granted/x", makeContext(cwd, DEFAULT_MODES, rules));
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 /tmp/outside`, ctx);
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 /tmp/outside", makeContext(cwd));
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 { dangerReason, secretReason, type Effect } from "./constants.js";
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
- 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
- });
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("echo $UNKNOWN/x")).toEqual([
196
+ expect(summary("cat $UNKNOWN/x")).toEqual([
181
197
  expect.objectContaining({ opaque: true, targets: [] }),
182
198
  ]);
183
199
  });