@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 +5 -2
- package/README.zh-CN.md +5 -2
- package/package.json +1 -1
- package/src/features/ask/index.ts +2 -1
- package/src/features/ask/prompt.ts +2 -1
- 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/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,
|
|
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
|
-
- **破坏性命令永远询问** —— 对
|
|
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.
|
|
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
|
|
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
|
|
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 /
|
|
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", "/
|
|
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: "/
|
|
70
|
-
canonical: "/
|
|
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: "/
|
|
82
|
+
target: canonicalize("/var/pi-perm-outside/x"),
|
|
83
83
|
effect: "write",
|
|
84
84
|
scope: "out",
|
|
85
85
|
grantScope: "root",
|
|
86
|
-
grantRoot: "/
|
|
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 /
|
|
140
|
+
const payload = audit("rm -rf /var/pi-perm-outside/x", makeContext(cwd), [
|
|
141
141
|
"Permission required",
|
|
142
|
-
"Command: rm -rf /
|
|
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 /
|
|
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 /
|
|
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 {
|
|
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
|
});
|
|
@@ -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 (
|
|
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") {
|
|
857
|
+
} else if (command === "dd") {
|
|
858
|
+
for (const arg of args) {
|
|
843
859
|
if (arg.startsWith("of=")) {
|
|
844
|
-
const
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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({
|