pi-verdict 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,13 +60,24 @@ pi --extension ./extensions/auto-mode.ts
60
60
 
61
61
  - `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
62
62
  - matched against the **full command string** for bash, the **absolute path** for file tools (read/write/edit/grep/find/ls); other tools (MCP etc.) always go to the classifier
63
- - `builtinDenyFloor: false` turns the built-in danger/path floor off entirely (risk accepted by you; the classifier and your rules remain)
63
+ - `builtinDenyFloor: false` turns the built-in danger/path floor off entirely (risk accepted by you; the classifier and your rules remain — the self-protection layer below always stays on)
64
64
  - `classifierModel: "provider/model-id"` sets the classifier model (e.g. a fast flash-class model); precedence is flag > env > config > session model (self-reflection); an invalid value falls back to the session model with a one-time warning
65
65
  - the spec accepts pi's native `--model` thinking suffix: `"zai/glm-5.3-flash:low"` sets classifier thinking to effort low (default without suffix: thinking explicitly off — the [measured](research/thinking-param-blackhole.md) default)
66
66
  - first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`); changes apply to new sessions
67
67
 
68
68
  Why no built-in allowlist? A third-party security audit ([`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md)) showed that allowlist soundness requires shell AST analysis — every built-in "always allow" would be a security claim maintained by the author. The built-in layer only makes **deny** claims (the sound direction); allow claims are yours.
69
69
 
70
+ ### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
71
+
72
+ The gate's own files — `config/pi-verdict.json` and the installed extension copy under `<agentDir>/extensions/` (anchored at runtime via `import.meta.url`; covers both single-file and npm-dir installs) — are **user-editable only**:
73
+
74
+ - `write`/`edit` onto them → hard **deny** (realpath-normalized comparison, symlink indirection included); reads pass
75
+ - bash/powershell commands touching them → **deny** (substring regex over literal/`~`/`$HOME`/`$PI_CODING_AGENT_DIR` spellings — honestly obfuscatable, see the backstop below)
76
+ - **not disableable by any config**: `builtinDenyFloor: false` does not turn this off, and no user `allow` rule can override it. Rationale: user sovereignty is about the risk to *your system*, not about the integrity of the gate itself — a gate its own guarded object can switch off cannot honestly promise "risk accepted by you"
77
+ - **tamper detection** (defense in depth): watched files are snapshotted at `session_start` and re-verified before every verdict, with differential disposal — the installed **extension copy** being changed (or any change in a headless session) is **auto-restored** from the snapshot and the session goes **fail-closed** (all tools denied) until restart; only the **config** changing in an interactive session offers a two-way select whose options state the action themselves: *Accept the new version* (re-baseline and continue — your edit survives, applies next session as usual) / *Decline* (restore the session baseline — revert + fail-closed); dismissing the dialog counts as Decline (safe side). Unconditional auto-restore would mean you can never edit the config while pi runs; warn-only would let a missed warning hand the next session to a tampered config — the rare, stern confirm is the middle path
78
+
79
+ Since everything inside the gate is by definition agent-initiated, denying writes is exactly "only the user can modify" — your editor never passes through the gate. The sudoers/visudo precedent is the closest analogue.
80
+
70
81
  Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) sessions; in non-interactive modes `ask` degrades to `deny`.
71
82
 
72
83
  ## How it compares
@@ -80,14 +91,23 @@ Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) s
80
91
 
81
92
  Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · convergence analysis with the closest architectural relative: [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md).
82
93
 
83
- Honest framing: pi-automode and pi-verdict have **converged on the same architecture** (deny floor → user rules → classifier, fail-closed — see the convergence analysis). What remains distinct here: a classifier that can say `ask` (runtime human-in-the-loop, not just rule-declared), a built-in floor you can turn off (`builtinDenyFloor` — user sovereignty), a zero-dependency single file (~700 lines, deliberate), and the measurement habit — every design decision in this repo is backed by shipped research.
94
+ Honest framing: pi-automode and pi-verdict have **converged on the same architecture** (deny floor → user rules → classifier, fail-closed — see the convergence analysis). What remains distinct here: a classifier that can say `ask` (runtime human-in-the-loop, not just rule-declared), a built-in floor you can turn off (`builtinDenyFloor` — user sovereignty), a self-protection layer that no config can turn off ([ADR-0001](docs/adr/0001-self-protection-layer.md) — gate integrity), a zero-dependency single file (~900 lines, deliberate), and the measurement habit — every design decision in this repo is backed by shipped research.
84
95
 
85
- The single-file, zero-dependency shape is deliberate — the whole extension is one readable [~700-line file](extensions/auto-mode.ts).
96
+ The single-file, zero-dependency shape is deliberate — the whole extension is one readable [~900-line file](extensions/auto-mode.ts).
86
97
 
87
98
  ## Pipeline
88
99
 
89
100
  ```
90
101
  tool_call
102
+
103
+ ├─ 0. Self-protection layer (ADR-0001; not disableable by any config)
104
+ │ ├─ write/edit/bash touching the gate's own files
105
+ │ │ (config/pi-verdict.json + installed extension copy) → deny
106
+ │ │ reads pass; user edits outside pi never pass through the gate
107
+ │ └─ tamper detection: re-verify watched files before every verdict;
108
+ │ extension copy changed / headless → auto-restore from snapshot
109
+ │ + fail-closed (deny all) for the rest of the session;
110
+ │ config changed + interactive → one keep/restore confirm
91
111
 
92
112
  ├─ 1. Rule layer (deterministic, zero latency)
93
113
  │ ├─ built-in deny floor: bash danger regexes (full-string) +
@@ -134,6 +154,8 @@ Prototype quality — usable, not hardened:
134
154
  - parallel gray-zone calls are adjudicated serially
135
155
  - self-reflection means the session model adjudicates — point `--auto-mode-model` at a lighter model if verdict latency/cost matters (open question tracked in the issue tracker)
136
156
  - shadow cache is observe-only by decision; the serving switch is a one-line change once measured hit rates justify it
157
+ - self-protection bash matching is substring regex — obfuscatable; the tamper-detection backstop catches within-session bypasses, but a cross-session baseline (hash + change confirmation at startup, incl. upgrade UX) is phase 2 per [ADR-0001](docs/adr/0001-self-protection-layer.md)
158
+ - dev checkouts (running the extension from a repo, not `<agentDir>/extensions/`) are not self-protected — the installed copy the *next* normal session loads is only covered by its own sessions' gate
137
159
 
138
160
  **verdict is not a sandbox.** It runs inside the pi process and adjudicates tool calls; it does not contain malicious code, protect against a compromised process, or guard manual `!` shell escapes. For isolation, use an OS-level sandbox.
139
161
 
@@ -144,7 +166,7 @@ The name: the three-state **verdict** is the core concept. The UX keeps `/automo
144
166
  ```bash
145
167
  bun install
146
168
  bun run typecheck
147
- bun test # 42 offline stub tests: deny floor, user rules, audit regression, classifier retry, shadow cache, commands
169
+ bun test # 61 offline stub tests: self-protection, tamper detection, deny floor, user rules, audit regression, classifier retry, shadow cache, commands
148
170
  ```
149
171
 
150
172
  Issue tracker and decision records live in the GitHub issues ("map" issue #1 indexes them).
package/README.zh-CN.md CHANGED
@@ -60,13 +60,24 @@ pi --extension ./extensions/auto-mode.ts
60
60
 
61
61
  - `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
62
62
  - 匹配目标:bash = **完整命令串**;文件类工具(read/write/edit/grep/find/ls)= **绝对路径**;其余工具(MCP 等)恒走分类器
63
- - `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在)
63
+ - `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在——下方自保护层永远开启)
64
64
  - `classifierModel: "provider/model-id"` 指定分类器模型(如轻量 flash 类);优先级 flag > env > config > 自省;无效值回退会话模型并一次性警告
65
65
  - spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-5.3-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考,[实测](research/thinking-param-blackhole.md)背书的默认)
66
66
  - 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`);修改后新会话生效
67
67
 
68
68
  为什么没有内置白名单?第三方安全审计(见 [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健全性需要 shell AST 分析——每条内置「永远放行」都是作者维护的安全声明。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
69
69
 
70
+ ### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
71
+
72
+ 门禁自身的文件——`config/pi-verdict.json` 与 `<agentDir>/extensions/` 下的扩展安装副本(运行时经 `import.meta.url` 自锚定,覆盖单文件与 npm 目录两种安装形态)——**仅用户可改**:
73
+
74
+ - `write`/`edit` 触碰 → 硬 **deny**(realpath 归一化比对,含符号链接间接路径);读放行
75
+ - bash/powershell 命令串触碰 → **deny**(字面量/`~`/`$HOME`/`$PI_CODING_AGENT_DIR` 拼写的子串正则——诚实声明:可被混淆绕过,见下方兜底)
76
+ - **不可经任何配置关闭**:`builtinDenyFloor: false` 关不掉它,任何用户 `allow` 规则也越不过它。理由:用户主权豁免的对象是「我的系统的风险」,不是门禁自身的完整性——一个能被其守护对象关掉的门禁,无法诚实兑现「风险自担」的承诺
77
+ - **变更检测**(纵深兜底):受保护文件在 `session_start` 快照、每次裁决前复核,处置按文件差分——**扩展副本**被改(或 headless 会话中的任何变更)→ 从快照**自动还原** + 本会话 **fail-closed**(全量拦截)直至重启;交互会话中仅**配置文件**被改 → 弹一次双选,选项文案即动作本身:*接受新版本*(重建基线、会话照常——你的编辑得以保留,照旧下一会话生效)/*拒绝*(还原会话基线:回滚 + fail-closed);关闭对话框等同拒绝(安全侧)。无条件自动还原等于「pi 运行期间你永远改不了配置」;纯警告则可能让被忽略的警告把下一会话交给被篡改的配置——罕见而郑重的一次确认是中间道路
78
+
79
+ 门禁之内的一切写入按定义均由 agent 发发——deny 写入即等价于「仅用户可改」,你的编辑器修改不经门禁。最近的同构先例是 sudoers 必须经 visudo。
80
+
70
81
  需要 pi ≥ 0.84。交互与非交互(`-p`/json/rpc)会话均支持;非交互模式下 `ask` 降级为 `deny`。
71
82
 
72
83
  ## 与品类对比
@@ -80,14 +91,23 @@ pi --extension ./extensions/auto-mode.ts
80
91
 
81
92
  完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
82
93
 
83
- 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、零依赖单文件(~700 行,刻意为之)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
94
+ 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖单文件(~900 行,刻意为之)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
84
95
 
85
- 零依赖单文件形态是有意为之——整个扩展就是一个可通读的 [~700 行文件](extensions/auto-mode.ts)。
96
+ 零依赖单文件形态是有意为之——整个扩展就是一个可通读的 [~900 行文件](extensions/auto-mode.ts)。
86
97
 
87
98
  ## 管线
88
99
 
89
100
  ```
90
101
  tool_call
102
+
103
+ ├─ 0. 自保护层(ADR-0001;不可经任何配置关闭)
104
+ │ ├─ write/edit/bash 触碰门禁自身文件
105
+ │ │ (config/pi-verdict.json + 扩展安装副本)→ deny
106
+ │ │ 读放行;用户在 pi 之外的编辑不经门禁
107
+ │ └─ 变更检测:每次裁决前复核受保护文件;
108
+ │ 扩展副本被改 / headless → 从会话快照自动还原
109
+ │ + 本会话 fail-closed(全量拦截);
110
+ │ 仅配置被改且有 UI → 一次保留/还原确认
91
111
 
92
112
  ├─ 1. 规则层(确定性,零延迟)
93
113
  │ ├─ 内置 deny floor:bash 危险正则(完整命令串)+ 路径敏感度 S0–S5
@@ -133,6 +153,8 @@ tool_call
133
153
  - 并行灰区调用串行裁决
134
154
  - 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
135
155
  - 影子缓存按决议仅观察不生效;实测命中率达标后,生效开关是一行改动
156
+ - 自保护 bash 匹配是子串正则——可被混淆绕过;变更检测兜底覆盖会话内绕过,跨会话基线(启动时哈希比对与变更确认,含升级 UX)按 ADR-0001 为二期
157
+ - dev checkout(从仓库而非 `<agentDir>/extensions/` 运行扩展)不受自保护——下一个正常会话加载的安装副本只在其自身会话的门禁内受保护
136
158
 
137
159
  **verdict 不是沙箱。** 它在 pi 进程内裁决工具调用;不能遏制恶意代码、不能防护被攻陷的进程、不守护手工 `!` shell 逃逸。需要隔离请用操作系统级沙箱。
138
160
 
@@ -143,7 +165,7 @@ tool_call
143
165
  ```bash
144
166
  bun install
145
167
  bun run typecheck
146
- bun test # 42 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
168
+ bun test # 61 个离线桩测试:自保护 / 变更检测 / deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
147
169
  ```
148
170
 
149
171
  Issue tracker 与决策记录在 GitHub issues(「地图」issue #1 为索引)。
@@ -5,6 +5,11 @@
5
5
  * 语义对齐 Claude Code Auto Mode,但方向相反:Pi 默认放行 → 本扩展自动拦截。
6
6
  *
7
7
  * 管线(tool_call 钩子):
8
+ * 0. 自保护层(ADR-0001,不可豁免):write/edit/bash 触碰门禁自身文件
9
+ * (pi-verdict.json + 本扩展安装副本)→ 硬 deny,读放行;builtinDenyFloor:false
10
+ * 亦不能关,用户 allow 亦不可越过。变更检测兜底:每次裁决前复核受保护文件,
11
+ * 被绕过修改 → 差分处置:扩展副本被改/无 UI → 自动还原 + 本会话 fail-closed;
12
+ * 仅 config 被改且有 UI → 确认式(保留=重建基线照常,还原=回滚+fail-closed)。
8
13
  * 1. 规则层(内置 deny floor + 用户规则):
9
14
  * - 内置 floor:bash 危险正则 + 路径敏感度分级(S0-S5)→ 硬 deny
10
15
  * (默认开;配置 builtinDenyFloor:false 可整体关闭,风险自担)
@@ -30,7 +35,8 @@
30
35
  * PI_AUTO_MODE_DEBUG=1 同上的环境变量形式(兼容保留)
31
36
  * <agentDir>/config/pi-verdict.json 用户规则:{ allow: [regex], deny: [regex],
32
37
  * builtinDenyFloor, classifierModel }
33
- * 匹配:bash=完整命令串 / 文件工具=绝对路径;新会话生效
38
+ * 匹配:bash=完整命令串 / 文件工具=绝对路径;新会话生效;
39
+ * 受自保护层保护(agent 不可改,仅用户手工编辑)
34
40
  *
35
41
  * 已知原型简化(见 README「已知限制」):
36
42
  * - 无内置 bash 白名单;危险识别依赖正则 floor(无 AST 解析)——未知形态交分类器
@@ -45,6 +51,7 @@
45
51
  import * as fs from "node:fs";
46
52
  import * as os from "node:os";
47
53
  import * as path from "node:path";
54
+ import { fileURLToPath } from "node:url";
48
55
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
49
56
 
50
57
  // ============================================================================
@@ -109,13 +116,16 @@ interface UserRules {
109
116
 
110
117
  const EMPTY_RULES: UserRules = { allow: [], deny: [], builtinDenyFloor: true, classifierModel: null };
111
118
 
119
+ function agentDirPath(): string {
120
+ return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
121
+ }
122
+
112
123
  function userConfigPath(): string {
113
- const agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
114
- return path.join(agentDir, "config", "pi-verdict.json");
124
+ return path.join(agentDirPath(), "config", "pi-verdict.json");
115
125
  }
116
126
 
117
127
  const USER_CONFIG_TEMPLATE = `${JSON.stringify({
118
- _hint: "pi-verdict user rules. allow/deny are JS regex arrays; deny wins over allow. Match target: bash = full command string, file tools = absolute path. builtinDenyFloor=false disables the built-in danger/path floor (at your own risk). classifierModel persistently sets the classifier model (provider/id, e.g. zai/glm-5.3-flash; accepts a pi-native thinking suffix, e.g. zai/glm-5.3-flash:low; empty = self-reflection, inherit session model). Changes apply to new sessions.",
128
+ _hint: "pi-verdict user rules. allow/deny are JS regex arrays; deny wins over allow. Match target: bash = full command string, file tools = absolute path. builtinDenyFloor=false disables the built-in danger/path floor (at your own risk; the self-protection layer always stays on and cannot be turned off by any config). classifierModel persistently sets the classifier model (provider/id, e.g. zai/glm-5.3-flash; accepts a pi-native thinking suffix, e.g. zai/glm-5.3-flash:low; empty = self-reflection, inherit session model). This file is part of the permission gate itself: pi-verdict denies any agent-side modification of it — edit it manually outside pi. Changes apply to new sessions.",
119
129
  allow: ["^ls\\b"],
120
130
  deny: [],
121
131
  builtinDenyFloor: true,
@@ -221,14 +231,188 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
221
231
  }
222
232
  }
223
233
 
234
+ // ============================================================================
235
+ // 自保护层(self-protection layer,ADR-0001)
236
+ //
237
+ // 门禁自身的完整性不受任何配置豁免:builtinDenyFloor:false 只关危险正则与路径
238
+ // 敏感度,关不掉本层;用户 allow 规则亦不可越过。保护对象:
239
+ // - <agentDir>/config/pi-verdict.json(用户规则 = 门禁的判定输入)
240
+ // - 本扩展的安装副本(<agentDir>/extensions/ 下;自锚定 import.meta.url,
241
+ // 覆盖单文件与 npm 包目录两种安装形态;dev checkout 不在此列)
242
+ // 语义:门禁内一切写入按定义均由 agent 发起 → 恒 deny(reason 指引手工编辑);
243
+ // 读放行(读门禁文件无害);用户经编辑器的修改不经门禁,不受影响。
244
+ // bash 侧:命令串正则覆盖字面量/~/\$HOME/\$PI_CODING_AGENT_DIR 变体,可被混淆
245
+ // 绕过(诚实声明,ADR-0001)——由扩展主体的变更检测兜底。
246
+ // ============================================================================
247
+
248
+ interface ProtectedSet {
249
+ /** 精确受保护文件(词法绝对路径 + realpath 双形) */
250
+ exact: string[];
251
+ /** 受保护目录前缀(npm 包安装形态:整个包目录) */
252
+ prefixes: string[];
253
+ /** bash/powershell 命令串危险特征(子串匹配,可绕——变更检测兜底) */
254
+ bashPatterns: RegExp[];
255
+ /** 变更检测基线(词法路径 + 类别;session_start 时快照全文) */
256
+ watchBases: Array<{ file: string; kind: WatchKind }>;
257
+ }
258
+
259
+ type WatchKind = "config" | "extension";
260
+
261
+ function tryRealpath(p: string): string {
262
+ try {
263
+ return fs.realpathSync(p);
264
+ } catch {
265
+ return p;
266
+ }
267
+ }
268
+
269
+ /** 路径的全部规范形:词法绝对 + realpath(存在且不同时追加) */
270
+ function pathForms(p: string): string[] {
271
+ const out = [p];
272
+ try {
273
+ const r = fs.realpathSync(p);
274
+ if (r !== p) out.push(r);
275
+ } catch {
276
+ /* 不存在:仅词法形 */
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function escapeRegExp(s: string): string {
282
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
283
+ }
284
+
224
285
  /**
225
- * 工具调用 → 规则层裁决。裁决序(#12):
286
+ * 构建受保护集合。
287
+ * ownFile:本模块文件路径(import.meta.url 解析;null = 不可解析,仅保护配置)。
288
+ * 仅当 ownFile 位于 <agentDir>/extensions/ 之下才视为安装副本加以保护:
289
+ * dev checkout(cwd 内源码)不保护——项目内开发写入是合法日常(ADR-0001)。
290
+ */
291
+ export function buildProtectedSet(agentDir: string, ownFile: string | null): ProtectedSet {
292
+ const exact = new Set<string>();
293
+ const prefixes = new Set<string>();
294
+ const configPath = path.join(agentDir, "config", "pi-verdict.json");
295
+ const watchBases: Array<{ file: string; kind: WatchKind }> = [{ file: configPath, kind: "config" }];
296
+ for (const f of pathForms(configPath)) exact.add(f);
297
+
298
+ // 安装副本目标:单文件形态 → 文件本体(exact);npm 目录形态 → 包根目录(prefix)。
299
+ // extRoot 与 ownFile 各取词法/realpath 双形交叉判定,集合同样双形收录——
300
+ // 避免符号链接目录(如 macOS /var → /private/var)导致传入词法路径与集合错位。
301
+ const extTargets = new Set<string>();
302
+ if (ownFile) {
303
+ watchBases.push({ file: ownFile, kind: "extension" });
304
+ const extRoots = new Set([path.join(agentDir, "extensions"), tryRealpath(path.join(agentDir, "extensions"))]);
305
+ const ownForms = new Set([ownFile, tryRealpath(ownFile)]);
306
+ for (const extRoot of extRoots) {
307
+ for (const own of ownForms) {
308
+ if (!own.startsWith(extRoot + path.sep)) continue;
309
+ const rel = path.relative(extRoot, own);
310
+ const singleFile = !rel.includes(path.sep);
311
+ const target = singleFile ? own : path.join(extRoot, rel.split(path.sep)[0]);
312
+ for (const f of pathForms(target)) {
313
+ (singleFile ? exact : prefixes).add(f);
314
+ extTargets.add(f);
315
+ }
316
+ }
317
+ }
318
+ }
319
+ const extForms = [...extTargets];
320
+
321
+ // bash 命令串特征:文件名字面量(任何拼写变体都含它)+ 安装副本路径变体
322
+ const bashPatterns: RegExp[] = [/pi-verdict\.json/];
323
+ if (extForms.length > 0) {
324
+ const home = os.homedir();
325
+ const alts = new Set<string>(extForms.map(escapeRegExp));
326
+ for (const f of extForms) {
327
+ if (f.startsWith(home + path.sep)) {
328
+ const rel = f.slice(home.length + 1);
329
+ alts.add(escapeRegExp("~/" + rel));
330
+ alts.add("\\$HOME/" + escapeRegExp(rel));
331
+ }
332
+ // $PI_CODING_AGENT_DIR 变体:词法与 realpath 两种基名列举(符号链接目录容忍)
333
+ for (const base of new Set([agentDir, tryRealpath(agentDir)])) {
334
+ if (f.startsWith(base + path.sep)) {
335
+ alts.add("\\$PI_CODING_AGENT_DIR/" + escapeRegExp(f.slice(base.length + 1)));
336
+ }
337
+ }
338
+ }
339
+ bashPatterns.push(new RegExp(`(?:${[...alts].join("|")})`));
340
+ }
341
+
342
+ return { exact: [...exact], prefixes: [...prefixes], bashPatterns, watchBases };
343
+ }
344
+
345
+ /** 解析后的写入路径是否命中受保护集合(经 realpath 防 symlink 旁路) */
346
+ export function isProtectedWritePath(rawPath: string, cwd: string, prot: ProtectedSet): boolean {
347
+ if (!rawPath) return false;
348
+ for (const c of pathForms(path.resolve(cwd, expandHome(rawPath)))) {
349
+ if (prot.exact.includes(c)) return true;
350
+ for (const p of prot.prefixes) {
351
+ if (c === p || c.startsWith(p + path.sep)) return true;
352
+ }
353
+ }
354
+ return false;
355
+ }
356
+
357
+ /** 自保护层裁决(第 0 层,先于一切):触碰门禁自身文件 → 不可豁免的 deny;其余 null 交后续层 */
358
+ export function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
359
+ switch (toolName) {
360
+ case "write":
361
+ case "edit":
362
+ if (isProtectedWritePath(String(input.path ?? ""), cwd, prot)) {
363
+ return { verdict: "deny", reason: `self-protection layer (ADR-0001): ${input.path} is part of the permission gate itself; agent-side modification is denied — edit it manually outside pi if intended` };
364
+ }
365
+ return null;
366
+ case "bash":
367
+ case "powershell": {
368
+ const cmd = String(input.command ?? "");
369
+ if (prot.bashPatterns.some((re) => re.test(cmd))) {
370
+ return { verdict: "deny", reason: `self-protection layer (ADR-0001): command touches the permission gate's own files — user-editable only` };
371
+ }
372
+ return null;
373
+ }
374
+ default:
375
+ return null; // MCP/自定义工具不经规则层(ADR-0001:由变更检测兜底)
376
+ }
377
+ }
378
+
379
+ /** 变更检测双选文案(ADR-0001:选项即动作,消除 Yes/No 映射歧义;按钮惯例用动词原形) */
380
+ const CONFIG_ACCEPT_CHOICE = "Accept the new version — re-baseline and continue (applies to new sessions as usual)";
381
+ const CONFIG_DECLINE_CHOICE = "Decline — restore the session baseline (revert + fail-closed for the rest of this session)";
382
+
383
+ /** 变更检测基线快照(ADR-0001 一期):全文读入内存;不存在/不可读 → content=null */
384
+ function takeSnapshots(bases: Array<{ file: string; kind: WatchKind }>): Array<{ file: string; kind: WatchKind; content: Buffer | null }> {
385
+ const out: Array<{ file: string; kind: WatchKind; content: Buffer | null }> = [];
386
+ const seen = new Set<string>();
387
+ for (const b of bases) {
388
+ for (const f of pathForms(b.file)) {
389
+ if (seen.has(f)) continue;
390
+ seen.add(f);
391
+ let content: Buffer | null = null;
392
+ try {
393
+ content = fs.readFileSync(f);
394
+ } catch {
395
+ /* 不存在/不可读:仍占位(出现即篡改信号) */
396
+ }
397
+ out.push({ file: f, kind: b.kind, content });
398
+ }
399
+ }
400
+ return out;
401
+ }
402
+
403
+ /**
404
+ * 工具调用 → 规则层裁决。裁决序(#12,ADR-0001 增第 0 层):
405
+ * 0. 自保护层——deny 即终局(不可经任何配置豁免,builtinDenyFloor 亦不能关)
226
406
  * 1. 内置 base(bash 危险正则 floor / 路径敏感度分级)——deny 即终局(floor 可经 builtinDenyFloor 关闭)
227
407
  * 2. 用户黑名单 → deny(优先于白名单)
228
408
  * 3. 用户白名单 → allow
229
409
  * 4. base(路径类工具的默认 allow/gray;其余 gray)→ 交分类器
230
410
  */
231
- function classifyByRules(toolName: string, input: Record<string, unknown>, cwd: string, user: UserRules): RuleResult {
411
+ function classifyByRules(toolName: string, input: Record<string, unknown>, cwd: string, user: UserRules, prot: ProtectedSet): RuleResult {
412
+ // 第 0 层:自保护层(ADR-0001)——先于一切,不可经任何配置豁免
413
+ const sp = selfProtectCheck(toolName, input, cwd, prot);
414
+ if (sp) return sp;
415
+
232
416
  let base: RuleResult;
233
417
  switch (toolName) {
234
418
  case "bash":
@@ -563,16 +747,65 @@ export default function autoMode(pi: ExtensionAPI) {
563
747
  const shadow = new ShadowCache();
564
748
  let userRules: UserRules = loadUserRules().rules;
565
749
 
750
+ // 自保护层(ADR-0001):受保护集合自锚定 + 变更检测基线(会话内存态)
751
+ const ownFilePath = (() => {
752
+ try {
753
+ return fileURLToPath(import.meta.url);
754
+ } catch {
755
+ return null;
756
+ }
757
+ })();
758
+ const prot = buildProtectedSet(agentDirPath(), ownFilePath);
759
+ let snapshots = takeSnapshots(prot.watchBases);
760
+ let tampered = false;
761
+
762
+ /** 复核受保护文件,返回变化清单(不还原——处置按 kind 差分,ADR-0001 定稿 D) */
763
+ function detectTamper(): Array<{ file: string; kind: WatchKind }> {
764
+ const hit: Array<{ file: string; kind: WatchKind }> = [];
765
+ for (const s of snapshots) {
766
+ let current: Buffer | null = null;
767
+ try {
768
+ current = fs.readFileSync(s.file);
769
+ } catch {
770
+ /* 不存在 */
771
+ }
772
+ const same = (a: Buffer | null, b: Buffer | null): boolean => (a === null || b === null ? a === b : a.equals(b));
773
+ if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
774
+ }
775
+ return hit;
776
+ }
777
+
778
+ /** 从快照回写变化文件(扩展进程自身执行,不经门禁)+ fail-closed,返回 block 载荷 */
779
+ function restoreAndFailClose(changed: Array<{ file: string }>, ctx: ExtensionContext, cause: string): { block: true; reason: string } {
780
+ for (const c of changed) {
781
+ const s = snapshots.find((x) => x.file === c.file);
782
+ if (s && s.content !== null) {
783
+ try {
784
+ fs.writeFileSync(s.file, s.content);
785
+ } catch {
786
+ /* 还原失败:仍 fail-closed */
787
+ }
788
+ }
789
+ }
790
+ tampered = true;
791
+ const files = [...new Set(changed.map((c) => c.file))].join(", ");
792
+ ctx.ui.notify(`🛡️ pi-verdict TAMPER DETECTED${cause ? ` (${cause})` : ""}: ${files} modified bypassing the gate; restored from session snapshot where possible. Fail-closed for the rest of this session — review the file(s) and restart the session.`, "warning");
793
+ return { block: true, reason: `[auto-mode] self-protection: tamper detected${cause ? ` (${cause})` : ""} and restored (${files}); fail-closed until restart` };
794
+ }
795
+
566
796
  function refreshStatus(ctx: ExtensionContext) {
567
797
  // 双态恒显:on 高亮 / off 暗色(原来 off 直接隐藏,状态不可见)
568
798
  ctx.ui.setStatus("auto-mode", ctx.ui.theme.fg(enabled ? "accent" : "dim", enabled ? "auto mode on" : "auto mode off"));
569
799
  }
570
800
 
571
801
  // session_start:重置影子缓存(会话内存态,#5 定案)+ 重载用户规则(配置改动新会话生效)
802
+ // + 重建自保护基线(ADR-0001:受保护文件的会话启动快照)
572
803
  pi.on("session_start", async (_event, ctx) => {
573
804
  shadow.reset();
805
+ tampered = false;
574
806
  const loaded = loadUserRules();
575
807
  userRules = loaded.rules;
808
+ snapshots = takeSnapshots(prot.watchBases);
576
809
  if (loaded.skipped.length > 0) {
577
810
  ctx.ui.notify(`pi-verdict: skipped ${loaded.skipped.length} invalid regex(es) in config (${userConfigPath()})`, "warning");
578
811
  }
@@ -657,8 +890,38 @@ export default function autoMode(pi: ExtensionAPI) {
657
890
  const input = event.input as Record<string, unknown>;
658
891
  const action = describeAction(event.toolName, input);
659
892
 
893
+ // 第 0 层前置:变更检测(ADR-0001)——篡改后本会话恒 deny(fail-closed)
894
+ if (tampered) {
895
+ ctx.ui.notify(`🛡️ Auto Mode blocked: self-protection fail-closed (tamper detected this session; restart to reset)\n ${action}`, "warning");
896
+ return { block: true, reason: "[auto-mode] self-protection: fail-closed until session restart (protected file was tampered with)" };
897
+ }
898
+ const changed = detectTamper();
899
+ if (changed.length > 0) {
900
+ // 差分处置(ADR-0001 定稿 D):仅 config 变化且有 UI → select 双选(选项即动作);
901
+ // 扩展副本被改 / 无 UI → 一律还原 + fail-closed。
902
+ // 用户合法的会话中手工编辑经「保留」一次确认即重建基线、会话照常
903
+ // (新配置照旧下一会话生效);无条件自动还原会把长驻会话变成
904
+ // 「用户永远无法修改配置」,与「仅用户可改」的设计初衷相悖。
905
+ if (ctx.hasUI && changed.every((c) => c.kind === "config")) {
906
+ // select 双选:选项文案即按钮(避免 confirm 固定 Yes/No 的映射歧义);
907
+ // 关闭对话框(Esc → undefined)无人背书,取安全侧同 Decline
908
+ const choice = await ctx.ui.select(
909
+ "🛡️ pi-verdict: PROTECTED CONFIG CHANGED",
910
+ [CONFIG_ACCEPT_CHOICE, CONFIG_DECLINE_CHOICE],
911
+ );
912
+ if (choice === CONFIG_ACCEPT_CHOICE) {
913
+ snapshots = takeSnapshots(prot.watchBases); // 重建基线
914
+ ctx.ui.notify("pi-verdict: config change accepted — new baseline taken; applies to new sessions as usual", "info");
915
+ } else {
916
+ return restoreAndFailClose(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
917
+ }
918
+ } else {
919
+ return restoreAndFailClose(changed, ctx, "");
920
+ }
921
+ }
922
+
660
923
  // 第 1 层:规则
661
- const rule = classifyByRules(event.toolName, input, ctx.cwd, userRules);
924
+ const rule = classifyByRules(event.toolName, input, ctx.cwd, userRules, prot);
662
925
  if (rule.verdict === "allow") {
663
926
  if (debug) ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
664
927
  return undefined;
package/package.json CHANGED
@@ -1,55 +1,57 @@
1
1
  {
2
- "name": "pi-verdict",
3
- "version": "0.2.4",
4
- "description": "A permission gate for Pi in the style of Claude Code's auto mode",
5
- "author": "Jesset (https://github.com/jesset)",
6
- "type": "module",
7
- "main": "extensions/auto-mode.ts",
8
- "files": [
9
- "extensions/auto-mode.ts",
10
- "README.md",
11
- "README.zh-CN.md",
12
- "LICENSE"
13
- ],
14
- "keywords": [
15
- "pi-package",
16
- "pi",
17
- "pi-extension",
18
- "extension",
19
- "pi-coding-agent",
20
- "auto-mode",
21
- "automode",
22
- "permissions",
23
- "security",
24
- "tool-call",
25
- "classifier",
26
- "ai-agent"
27
- ],
28
- "repository": {
29
- "type": "git",
30
- "url": "git+https://github.com/jesset/pi-verdict.git"
31
- },
32
- "homepage": "https://github.com/jesset/pi-verdict#readme",
33
- "bugs": "https://github.com/jesset/pi-verdict/issues",
34
- "license": "MIT",
35
- "pi": {
36
- "extensions": ["./extensions"]
37
- },
38
- "scripts": {
39
- "typecheck": "tsc --noEmit -p tsconfig.json",
40
- "test": "bun test"
41
- },
42
- "peerDependencies": {
43
- "@earendil-works/pi-coding-agent": ">=0.84.0"
44
- },
45
- "peerDependenciesMeta": {
46
- "@earendil-works/pi-coding-agent": {
47
- "optional": true
48
- }
49
- },
50
- "devDependencies": {
51
- "@earendil-works/pi-coding-agent": "0.84.3",
52
- "@types/node": "^26.3.0",
53
- "typescript": "^7.0.2"
54
- }
2
+ "name": "pi-verdict",
3
+ "version": "0.3.0",
4
+ "description": "A permission gate for Pi in the style of Claude Code's auto mode",
5
+ "author": "Jesset (https://github.com/jesset)",
6
+ "type": "module",
7
+ "main": "extensions/auto-mode.ts",
8
+ "files": [
9
+ "extensions/auto-mode.ts",
10
+ "README.md",
11
+ "README.zh-CN.md",
12
+ "LICENSE"
13
+ ],
14
+ "keywords": [
15
+ "pi-package",
16
+ "pi",
17
+ "pi-extension",
18
+ "extension",
19
+ "pi-coding-agent",
20
+ "auto-mode",
21
+ "automode",
22
+ "permissions",
23
+ "security",
24
+ "tool-call",
25
+ "classifier",
26
+ "ai-agent"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/jesset/pi-verdict.git"
31
+ },
32
+ "homepage": "https://github.com/jesset/pi-verdict#readme",
33
+ "bugs": "https://github.com/jesset/pi-verdict/issues",
34
+ "license": "MIT",
35
+ "pi": {
36
+ "extensions": [
37
+ "./extensions"
38
+ ]
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit -p tsconfig.json",
42
+ "test": "bun test"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": ">=0.84.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@earendil-works/pi-coding-agent": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "@earendil-works/pi-coding-agent": "0.84.3",
54
+ "@types/node": "^26.3.0",
55
+ "typescript": "^7.0.2"
56
+ }
55
57
  }