@andares/pi-toolkits 0.11.0 → 0.11.1

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 CHANGED
@@ -116,7 +116,7 @@ Enter with `/ask` (press it again to exit). Meant for the case where you only wa
116
116
 
117
117
  Repeating the argument form with the modes already in force is a **no-op**: no second "ON" notice and no loss of the session grants collected so far. Changing any mode records the change and clears those grants, because they were made under the old policy.
118
118
 
119
- **Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
119
+ **Defaults**: in-project read/write allowed, outside read allowed, outside write asks — except writes under the OS temp root, which are scratch space and pass directly. An explicit `out write = deny` still denies them.
120
120
 
121
121
  | Scope | reads | writes / exec |
122
122
  | --- | --- | --- |
@@ -125,19 +125,22 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
125
125
 
126
126
  `exec` is folded into `write`: any command that changes state counts as a write.
127
127
 
128
+ **Temporary write zone**: a write outside the project but under the OS temp root (`os.tmpdir()`, `/var/tmp`, `/dev/shm`) is allowed without a dialog when `out write` is `ask` (the audit layer is `temp`). `/tmp` is shared scratch space, so this is a convenience rule, not an isolation boundary; deleting a temp root itself (`rm -rf /tmp`) still hits the destructive table.
129
+
128
130
  **What is inspected**
129
131
 
130
132
  - **bash** is decomposed into simple commands — `&&`, `||`, `;`, pipes, `$(…)`, backticks, `bash -c`, `eval`, the common indirection wrappers (`sudo`, `env`, `xargs`, `find -exec`, …), heredocs — and judged per command. `cd` folds into the commands after it; the strictest result wins, so `cd /tmp && rm x` cannot ride on the first segment.
131
133
  - **path-bearing tools** (`read` / `write` / `edit` / `grep` / `find` / `ls`) are judged by their own path; every other tool is judged by the working directory.
132
134
  - **unresolvable input** — a non-literal `cd` or `git -C`, an unknown shell variable in a path, an unterminated heredoc or substitution — is asked about regardless of mode (fail closed), never guessed.
133
135
  - **`git -C <dir>`** is resolved like a local `cd`: relative path arguments (and the repository) are judged against `<dir>`, not the working directory.
136
+ - **device sinks and data arguments** — `/dev/null`, `/dev/stdout` and `/dev/stderr` are never boundary targets, whether written as a redirect (`cmd > /dev/null`) or an operand (`curl -o /dev/null`); the arguments of `echo` / `printf` are data, not paths.
134
137
 
135
138
  **Answers**: `a` allow once · `s` allow this directory for the session · `d` deny · `f` deny this directory for the session. Session grants are kept in memory as `(read|write, directory)`, matched by containment, and cleared when the mode is turned off or the session ends. A session grant outranks the destructive table — once you have allowed an operation for the session, repeating it does not ask again. A command the parser cannot resolve is granted by its exact text instead, so repeating the same opaque command does not ask again.
136
139
 
137
140
  **Fixed safety tables** (independent of the four modes):
138
141
 
139
142
  - **credentials are denied in every mode** — `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.pi/agent/auth*.json`, `.env` files (templates excepted). Without this, `out read = allow` would hand out `~/.ssh/id_rsa`.
140
- - **destructive commands always ask** — `rm` of `/`, the home directory, or the working directory; `git reset --hard`; `git clean -f`; `git push --force`; package `publish`; `dd` to a raw device; `mkfs*`; shutdown/reboot; `chmod -R 777`; anything through `sudo`. Scope alone cannot express "destructive".
143
+ - **destructive commands always ask** — `rm` of `/`, the home directory, the working directory, or a temp root (`/tmp`, `/var/tmp`, `/dev/shm`); `git reset --hard`; `git clean -f`; `git push --force`; package `publish`; `dd` to a raw device; `mkfs*`; shutdown/reboot; `chmod -R 777`; anything through `sudo`. Scope alone cannot express "destructive".
141
144
 
142
145
  **Approval log** (write-only telemetry, on by default)
143
146
 
package/README.zh-CN.md CHANGED
@@ -116,7 +116,7 @@ pi -e ./src/index.ts
116
116
 
117
117
  参数形式重复执行、且四项模式与当前一致时是**空操作**:不会再出一条「ON」提示,也不会清掉已经攒下的会话授权。只有模式真的变了才会提示并清空授权——那些授权是按旧策略给的。
118
118
 
119
- **默认值**:项目内读/写全放行,项目外读放行,项目外写询问。
119
+ **默认值**:项目内读/写全放行,项目外读放行,项目外写询问——但 `os.tmpdir()`、`/var/tmp`、`/dev/shm` 下的临时写入直接放行。显式把 `out write` 设为 `deny` 时仍然拒绝。
120
120
 
121
121
  | 作用域 | 读 | 写 / 执行 |
122
122
  | --- | --- | --- |
@@ -125,19 +125,22 @@ pi -e ./src/index.ts
125
125
 
126
126
  `exec` 折进 `write`:任何产生变更的执行都算写。
127
127
 
128
+ **临时写入区**:项目外、但落在操作系统临时根(`os.tmpdir()`、`/var/tmp`、`/dev/shm`)的写入,在 `out write = ask` 时直接放行(审计层记 `temp`)。`/tmp` 是共享草稿空间,这条是便利规则,不是隔离边界;**删除临时根本身**(`rm -rf /tmp`)仍然走破坏性表。
129
+
128
130
  **检查方式**
129
131
 
130
132
  - **bash** 先拆成简单命令 —— `&&`、`||`、`;`、管道、`$(…)`、反引号、`bash -c`、`eval`、常见间接包装(`sudo`、`env`、`xargs`、`find -exec`…)、heredoc —— 再逐条判定;`cd` 向前折叠,取最严格的结果,所以 `cd /tmp && rm x` 不会靠第一段蒙混过关
131
133
  - **带路径的工具**(`read` / `write` / `edit` / `grep` / `find` / `ls`)按自己的路径判定;其它工具按工作目录判定
132
134
  - **无法解析的输入** —— 非字面量 `cd` 或 `git -C`、路径里的未知 shell 变量、未闭合的 heredoc/命令替换 —— 一律询问,不看模式(fail closed),绝不猜测
133
135
  - **`git -C <dir>`** 按局部 `cd` 处理:相对路径参数(和仓库本身)按 `<dir>` 判定,而不是工作目录
136
+ - **设备 sink 与数据参数** —— `/dev/null`、`/dev/stdout`、`/dev/stderr` 无论是重定向(`cmd > /dev/null`)还是普通操作数(`curl -o /dev/null`),都不算边界目标;`echo` / `printf` 的参数是数据,不按路径处理
134
137
 
135
138
  **回答**:`a` 本次允许 · `s` 本会话允许该目录 · `d` 拒绝 · `f` 本会话拒绝该目录。会话授权在内存中按 `(读|写, 目录)` 保存、按包含关系匹配,关闭模式或会话结束时清空;**会话授权优先于破坏性表**——你按了 `s` 之后,重复同一操作不会再弹;解析不了完整路径的命令则按**完整命令文本**授权,重复同一条命令不会再问。
136
139
 
137
140
  **固定安全表**(与四格模式无关):
138
141
 
139
142
  - **凭据路径任何模式都拒绝** —— `~/.ssh`、`~/.gnupg`、`~/.aws`、`~/.pi/agent/auth*.json`、`.env` 文件(模板除外)。没有这一条,默认的「项目外读放行」就等于把 `~/.ssh/id_rsa` 交出去
140
- - **破坏性命令永远询问** —— 对 `/`、家目录或工作目录的 `rm`;`git reset --hard`;`git clean -f`;`git push --force`;包 `publish`;`dd` 写裸设备;`mkfs*`;关机/重启;`chmod -R 777`;任何经 `sudo` 的命令。作用域本身表达不了「破坏性」
143
+ - **破坏性命令永远询问** —— 对 `/`、家目录、工作目录或临时根(`/tmp`、`/var/tmp`、`/dev/shm`)的 `rm`;`git reset --hard`;`git clean -f`;`git push --force`;包 `publish`;`dd` 写裸设备;`mkfs*`;关机/重启;`chmod -R 777`;任何经 `sudo` 的命令。作用域本身表达不了「破坏性」
141
144
 
142
145
  **审批日志**(只写遥测,默认开启)
143
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, a dedicated compaction model, and an agent memory framework (/ask-init)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * ask feature — read-only consult mode.
3
3
  *
4
- * Design notes (see PLAN.md and AGENTS.md):
4
+ * Design notes (see AGENTS.md §Feature: ask, plus the D1–D6 records in
5
+ * .agents/memory/ARCHITECTURE.md):
5
6
  * - Project writes stay impossible: write/edit/powershell are removed from the
6
7
  * active tool set via pi.setActiveTools(), and pi's runtime rejects calls to
7
8
  * non-active tools ("Tool not found"). A gate blocks them again as defence in
@@ -16,7 +16,8 @@
16
16
  * be ~300 tokens per request restating what the sandbox already enforces.
17
17
  *
18
18
  * Do NOT use the removed `systemPromptAppend` field or wholesale systemPrompt
19
- * replacement (see PLAN.md).
19
+ * replacement (see `AGENTS.md` §Feature: ask and the D5 record in
20
+ * `.agents/memory/ARCHITECTURE.md`).
20
21
  */
21
22
 
22
23
  export interface AskBannerOptions {
@@ -45,7 +45,7 @@ function audit(command: string, ctx: PolicyContext, prompt?: readonly string[])
45
45
  describe("permission audit payload", () => {
46
46
  it("records the decomposition, target scope and deciding layer", () => {
47
47
  const cwd = project();
48
- const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd));
48
+ const payload = audit("rm -rf /var/pi-perm-outside/x", makeContext(cwd));
49
49
 
50
50
  expect(payload.modes).toEqual({
51
51
  inRead: "allow",
@@ -58,7 +58,7 @@ describe("permission audit payload", () => {
58
58
  expect.objectContaining({
59
59
  head: "rm",
60
60
  command: "rm",
61
- args: ["-rf", "/tmp/outside/x"],
61
+ args: ["-rf", "/var/pi-perm-outside/x"],
62
62
  base: canonicalize(cwd),
63
63
  effect: "write",
64
64
  opaque: false,
@@ -66,8 +66,8 @@ describe("permission audit payload", () => {
66
66
  layer: "mode",
67
67
  targets: [
68
68
  {
69
- path: "/tmp/outside/x",
70
- canonical: "/tmp/outside/x",
69
+ path: "/var/pi-perm-outside/x",
70
+ canonical: canonicalize("/var/pi-perm-outside/x"),
71
71
  effect: "write",
72
72
  source: "operand",
73
73
  scope: "out",
@@ -79,11 +79,11 @@ describe("permission audit payload", () => {
79
79
  action: "ask",
80
80
  layer: "mode",
81
81
  unit: 0,
82
- target: "/tmp/outside/x",
82
+ target: canonicalize("/var/pi-perm-outside/x"),
83
83
  effect: "write",
84
84
  scope: "out",
85
85
  grantScope: "root",
86
- grantRoot: "/tmp/outside",
86
+ grantRoot: canonicalize("/var/pi-perm-outside"),
87
87
  });
88
88
  });
89
89
 
@@ -137,20 +137,20 @@ describe("permission audit payload", () => {
137
137
 
138
138
  it("carries the exact prompt lines when asked", () => {
139
139
  const cwd = project();
140
- const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd), [
140
+ const payload = audit("rm -rf /var/pi-perm-outside/x", makeContext(cwd), [
141
141
  "Permission required",
142
- "Command: rm -rf /tmp/outside/x",
142
+ "Command: rm -rf /var/pi-perm-outside/x",
143
143
  ]);
144
144
  expect(payload.prompt).toEqual([
145
145
  "Permission required",
146
- "Command: rm -rf /tmp/outside/x",
146
+ "Command: rm -rf /var/pi-perm-outside/x",
147
147
  ]);
148
148
  });
149
149
 
150
150
  it("clips long arguments but keeps the target", () => {
151
151
  const cwd = project();
152
152
  const longArg = `--flag=${"x".repeat(2000)}`;
153
- const payload = audit(`rm -rf /tmp/outside/x ${longArg}`, makeContext(cwd));
153
+ const payload = audit(`rm -rf /var/pi-perm-outside/x ${longArg}`, makeContext(cwd));
154
154
  const unit = (payload.units as Array<Record<string, unknown>>)[0];
155
155
  const args = unit.args as string[];
156
156
  expect(args[2]).toContain("[+");
@@ -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
  });
@@ -57,6 +57,23 @@ export interface CommandUnit {
57
57
 
58
58
  const SENTINEL = "\u0000";
59
59
 
60
+ /**
61
+ * Device sinks: writing to them is a no-op, not a boundary decision. They are
62
+ * excluded whenever they appear as a plain operand (`curl -o /dev/null`),
63
+ * not just as a redirect target (`cmd > /dev/null`). Commands that mutate the
64
+ * node itself (`rm /dev/null`, `mv /dev/null x`) still collect the target —
65
+ * the write-path branch below keeps them visible to the policy.
66
+ */
67
+ const DEVICE_SINKS = new Set(["/dev/null", "/dev/stdout", "/dev/stderr"]);
68
+
69
+ /**
70
+ * Commands whose ordinary arguments are never filesystem paths. `echo` /
71
+ * `printf` can only write through a redirect, which is collected separately,
72
+ * so skipping their operands cannot hide a write and avoids treating a
73
+ * message containing `/` plus an unresolved variable as an opaque path.
74
+ */
75
+ const NO_PATH_OPERANDS = new Set(["echo", "printf"]);
76
+
60
77
  /* -------------------------------------------------------------------------- */
61
78
  /* Read/write classification */
62
79
  /* -------------------------------------------------------------------------- */
@@ -768,9 +785,7 @@ function analyzeSegment(
768
785
  for (const redirect of redirects) {
769
786
  if (redirect.target === undefined) continue;
770
787
  if (redirect.op === "<<<") continue; // herestring: data, not a path
771
- if (redirect.target === "/dev/null" || redirect.target === "/dev/stdout" || redirect.target === "/dev/stderr") {
772
- continue;
773
- }
788
+ if (DEVICE_SINKS.has(redirect.target)) continue;
774
789
  const descriptor =
775
790
  (redirect.op === ">&" || redirect.op === "<&" || redirect.op === "&>") &&
776
791
  /^\d+$/.test(redirect.target);
@@ -839,9 +854,12 @@ function analyzeSegment(
839
854
  }
840
855
  targets.push({ path: pathValue, effect: "write", source: "operand" });
841
856
  }
842
- } else if (command === "dd") { for (const arg of args) {
857
+ } else if (command === "dd") {
858
+ for (const arg of args) {
843
859
  if (arg.startsWith("of=")) {
844
- const pathValue = resolveTargetPath(base, arg.slice(3));
860
+ const value = arg.slice(3);
861
+ if (DEVICE_SINKS.has(value)) continue;
862
+ const pathValue = resolveTargetPath(base, value);
845
863
  if (pathValue === undefined) {
846
864
  return {
847
865
  units: [opaqueUnit(raw, headWord, command, base, "unresolved dd target")],
@@ -858,28 +876,35 @@ function analyzeSegment(
858
876
  }
859
877
  }
860
878
 
861
- // Path-like arguments are targets for every command.
862
- for (const arg of args) {
863
- if (!looksLikePath(arg)) continue;
864
- // `sed -i` operands were already classified above; its script is not a path.
865
- if (command === "sed" && effect === "write") continue;
866
- const pathValue = resolveTargetPath(commandBase, arg);
867
- if (pathValue === undefined) {
868
- return {
869
- units: [opaqueUnit(raw, headWord, command, base, "unresolved path argument")],
870
- nextBase: base,
871
- };
879
+ // Path-like arguments are targets for every command, except for commands
880
+ // whose arguments are data (`echo`, `printf`) and device sinks.
881
+ if (!NO_PATH_OPERANDS.has(command)) {
882
+ for (const arg of args) {
883
+ if (DEVICE_SINKS.has(arg)) continue;
884
+ if (!looksLikePath(arg)) continue;
885
+ // `sed -i` operands were already classified above; its script is not a path.
886
+ if (command === "sed" && effect === "write") continue;
887
+ const pathValue = resolveTargetPath(commandBase, arg);
888
+ if (pathValue === undefined) {
889
+ return {
890
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved path argument")],
891
+ nextBase: base,
892
+ };
893
+ }
894
+ targets.push({ path: pathValue, effect, source: "operand" });
872
895
  }
873
- targets.push({ path: pathValue, effect, source: "operand" });
874
896
  }
875
897
 
876
- // `-o FILE` / `--output FILE` writes whatever the command is.
877
- for (let i = 0; i < args.length; i++) {
878
- if (args[i] !== "-o" && args[i] !== "--output") continue;
879
- const value = args[i + 1];
880
- if (value === undefined) continue;
881
- const pathValue = resolveTargetPath(commandBase, value);
882
- if (pathValue) targets.push({ path: pathValue, effect: "write", source: "operand" });
898
+ // `-o FILE` / `--output FILE` writes whatever the command is. `echo` and
899
+ // `printf` do not have an output option, so their `-o` is data too.
900
+ if (!NO_PATH_OPERANDS.has(command)) {
901
+ for (let i = 0; i < args.length; i++) {
902
+ if (args[i] !== "-o" && args[i] !== "--output") continue;
903
+ const value = args[i + 1];
904
+ if (value === undefined || DEVICE_SINKS.has(value)) continue;
905
+ const pathValue = resolveTargetPath(commandBase, value);
906
+ if (pathValue) targets.push({ path: pathValue, effect: "write", source: "operand" });
907
+ }
883
908
  }
884
909
 
885
910
  units.push({