pi-verdict 0.5.2 → 0.6.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
@@ -8,7 +8,7 @@
8
8
 
9
9
  **pi-verdict is a minimal permission gate for [pi](https://pi.dev) in the style of Claude Code's auto mode: every tool call gets checked before it runs — allow, deny, or ask you first.**
10
10
 
11
- - Minimal — just a few hundred lines of code
11
+ - Minimal — just 1k+ lines of code
12
12
  - Built-in danger rules and your own allow/deny rules settle the clear cases first, at zero latency
13
13
  - Everything else goes to a model classifier that sees the conversation context
14
14
  - Any uncertainty or failure fails closed; nothing ever runs silently
@@ -22,24 +22,53 @@ pi-verdict adds the missing gate: a model decides whether each call should run,
22
22
 
23
23
  ## Why three states
24
24
 
25
- **verdict is an adjudication, not a switch.** Most classifiers in this space output a binary allow/block. Three states matter: `ask` routes genuinely ambiguous actions to a human (and degrades to `deny` in non-interactive sessions), so "not sure" never silently becomes "go ahead".
25
+ **verdict is an adjudication, not a switch.** Most classifiers in this space output a binary allow/block. Three states matter: `ask` routes genuinely ambiguous actions to a human (and degrades to `deny` in non-interactive sessions), so "not sure" never silently becomes "go ahead" — the goal is safe automation, not maximum automation: both approval fatigue and silent unsafe execution lose.
26
+
27
+ ## Design principles
28
+
29
+ - **Fail closed** — uncertainty produces friction, never permission.
30
+ - **Deterministic floor before AI** — hard denies are never overridden by the classifier or user allow rules.
31
+ - **Semantics over syntax** — the classifier judges what an action *does*, not how long it is.
32
+ - **Judgments, not proofs** — a classifier `allow` is an informed opinion; the floor exists because that is all it is.
33
+ - **Minimal trusted input** — no tool results in the transcript (#22), zero path plaintext to the classifier (ADR-0002).
34
+ - **Canonical identity** — lexical + realpath dual-form matching; a workspace-*looking* path is not trusted as one (#20/#21).
35
+ - **The gate guards itself** — self-protection that no configuration can disable (ADR-0001).
36
+ - **A permission gate, not a sandbox** — stack OS isolation on top; this gate never replaces it.
37
+
38
+ Full statement in [docs/security-principles.md](docs/security-principles.md).
26
39
 
27
40
  ## Screenshots
28
41
 
42
+ ![Demo: protected-path ask declined](docs/demo.gif)
43
+
29
44
  ![Automode Status](docs/images/status.png)
30
45
  ![Ask Permission](docs/images/asked.png)
31
46
 
32
47
  ## Quick start
33
48
 
34
49
  ```bash
35
- # install from npm:
50
+ # install from npm (pi):
36
51
  pi install npm:pi-verdict
37
52
 
53
+ # install from npm (oh-my-pi / omp):
54
+ omp plugin install npm:pi-verdict
55
+
38
56
  # or directly from git — try it once
39
57
  pi --extension ./extensions/auto-mode.ts
40
58
 
41
59
  ```
42
60
 
61
+ ### Hosts
62
+
63
+ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi](https://github.com/can1357/oh-my-pi) (omp) — it self-anchors to whichever agent tree it is installed in, and follows the extension copy's own location on dual-install machines. On omp 18 the classifier's completion call falls back to the pi-ai compat API (still fail-closed). Details: [docs/configuration.md](docs/configuration.md#host-notes-pi-and-oh-my-pi).
64
+
65
+ | | pi | omp |
66
+ |---|---|---|
67
+ | install | `pi install npm:pi-verdict` | `omp plugin install npm:pi-verdict` |
68
+ | extension copy | `~/.pi/agent/extensions/` | `~/.omp/plugins/node_modules/pi-verdict/` (omp 18.1+; ≤18.0: under `agent/`) |
69
+ | user rules | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
70
+ | credential file (S0 hard deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
71
+
43
72
  - `/automode` — show current status: on/off + shadow-cache stats for the session
44
73
  - `/automode on`
45
74
  - `/automode off`
@@ -68,26 +97,18 @@ pi --extension ./extensions/auto-mode.ts
68
97
  ```
69
98
 
70
99
  - `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
71
- - `denyPaths` are plain paths (not regexes) you declare **protected**: any tool call touching them file tools via their path, bash via path tokens extracted from the command string — triggers a **terminal ask** you adjudicate (non-interactive sessions degrade to deny). Not affected by `builtinDenyFloor: false`.
72
- The classifier only ever learns that protected paths *exist*; the paths themselves never leave your machine, and a matched path shows **only** in the local confirm dialog.
73
- - `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)
74
- - `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
75
- - 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)
76
- - `toggleShortcut` rebinds the master-switch toggle key (`null` or empty disables it, not persisted)
77
- - first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`)
100
+ - `denyPaths` are plain paths you declare **protected**touches trigger a terminal ask you adjudicate (non-interactive deny); the classifier never learns the paths themselves, only that they exist
101
+ - `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
102
+ - `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
78
103
 
79
- **Why no built-in allowlist?** Bypass testing of the rule layer ([writeup](research/rule-layer-security-audit.md)) showed that allowlist robustness is very limited. The built-in layer only makes **deny** claims (the sound direction); allow claims are yours.
104
+ No built-in allowlist every "always allow" claim is yours ([why](docs/configuration.md#why-no-built-in-allowlist)). Full reference: [docs/configuration.md](docs/configuration.md).
80
105
 
81
106
  ### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
82
107
 
83
- 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**:
108
+ The gate's own files — the config and the installed extension copy are **user-editable only**: writes from inside the gate hard-deny (reads pass); your editor never passes through the gate, the sudoers/visudo precedent.
84
109
 
85
- - `write`/`edit` onto them hard **deny** (realpath-normalized comparison, symlink indirection included); reads pass
86
- - bash/powershell commands touching them **deny** (substring regex over literal/`~`/`$HOME`/`$PI_CODING_AGENT_DIR` spellingshonestly obfuscatable, see the backstop below)
87
- - **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"
88
- - **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
89
-
90
- 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.
110
+ - **Not disableable by any config** `builtinDenyFloor: false` and user `allow` rules cannot touch this layer
111
+ - **Tamper detection** as the backstop: watched files are snapshotted at `session_start` and re-verified before every verdict a changed extension copy is auto-restored and the session goes fail-closed; a changed config gets one explicit keep/restore confirm ([ADR-0001](docs/adr/0001-self-protection-layer.md) for the differential-disposal rationale)
91
112
 
92
113
  Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) sessions; in non-interactive modes `ask` degrades to `deny`.
93
114
 
@@ -102,9 +123,7 @@ Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) s
102
123
 
103
124
  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).
104
125
 
105
- 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 (~1.2k lines and growing by features, still one file on purpose), and the measurement habit — every design decision in this repo is backed by shipped research.
106
-
107
- The single-file, zero-dependency shape is deliberate — the whole extension is one readable [file](extensions/auto-mode.ts), ~1.2k lines and growing with features.
126
+ 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 ([one readable file](extensions/auto-mode.ts), still one file on purpose), and the measurement habit — every design decision in this repo is backed by shipped research.
108
127
 
109
128
  ## Pipeline
110
129
 
@@ -112,42 +131,28 @@ The single-file, zero-dependency shape is deliberate — the whole extension is
112
131
  tool_call
113
132
 
114
133
  ├─ 0. Self-protection layer (ADR-0001; not disableable by any config)
115
- │ ├─ write/edit/bash touching the gate's own files
116
- │ (config/pi-verdict.json + installed extension copy) deny
117
- │ reads pass; user edits outside pi never pass through the gate
118
- │ └─ tamper detection: re-verify watched files before every verdict;
119
- │ extension copy changed / headless → auto-restore from snapshot
120
- │ + fail-closed (deny all) for the rest of the session;
121
- │ config changed + interactive → one keep/restore confirm
134
+ │ ├─ write/edit/bash touching the gate's own files → deny; reads pass
135
+ └─ tamper detection: re-verify before every verdict
136
+ auto-restore + fail-closed, or one keep/restore confirm
122
137
 
123
138
  ├─ 1. Rule layer (deterministic, zero latency)
124
- │ ├─ built-in deny floor: bash danger regexes (full-string, capped at 8192 chars) +
125
- │ path sensitivity S0–S5 (secrets/system/.git meta deny;
126
- │ dual-form matching lexical + realpath, symlink aliases resolve)
127
- │ ├─ your rules: user deny beats user allow (regex, see below)
128
- │ ├─ denyPaths (ADR-0002): user-declared protected paths, tool-owned
129
- │ │ normalization (~, $HOME, relative, .., symlink, case on
130
- │ │ macOS/Windows) → terminal ask,
139
+ │ ├─ built-in deny floor: bash danger regexes + path sensitivity S0–S5
140
+ ├─ your rules: user deny beats user allow
141
+ ├─ denyPaths (ADR-0002): protected paths terminal ask,
131
142
  │ │ before user allow; classifier sees an existence hint only
132
143
  │ └─ no built-in allowlist — every "always allow" claim is yours to make
133
144
 
134
145
  ├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
135
- │ ├─ input: CC-style <transcript> (last 5 user messages + last 10 tool calls,
136
- │ │ action under review always last) — user intent is evidence
137
- ├─ output contract: <verdict>allow|ask|deny</verdict> prefix-anchored
138
- │ ├─ existence hint when denyPaths are configured: the classifier knows
139
- │ │ protected paths exist (never what they are) and judges
140
- │ │ copy-then-read/archiving/indirection strictly
141
- │ ├─ thinking explicitly disabled (thinkingEnabled: false) + retry 512→1024
142
- │ └─ configurable via --auto-mode-model
146
+ │ ├─ input: CC-style <transcript> recent user intent + tool calls,
147
+ │ │ action under review always last
148
+ └─ output contract: <verdict>allow|ask|deny</verdict> prefix-anchored
143
149
 
144
150
  └─ 3. Three-state adjudication
145
151
  ├─ allow → pass
146
152
  ├─ deny → block, reason returned to the agent
147
- └─ ask → human confirm (ctx.ui.confirm); non-interactive modes degrade to deny
153
+ └─ ask → human confirm; non-interactive modes degrade to deny
148
154
 
149
- [shadow cache] (observe-only, runs alongside 2/3, never changes a verdict)
150
- replays a double-key LRU(128) to measure would-be hit rate
155
+ [shadow cache] observe-only telemetry alongside 2/3, never changes a verdict
151
156
  ```
152
157
 
153
158
  **fail-closed**: classifier exception / timeout (25s) / contract violation → deny. Never silently allow.
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **pi-verdict 是 [pi](https://pi.dev) 的 Claude Code 风格的 Auto mode 式的极简权限门禁:每次工具调用执行前先过检查——放行、拦截,或先问你。**
10
10
 
11
- - 只有几百行的极简代码
11
+ - 只有1k行左右的极简代码
12
12
  - 内置危险规则与你的 allow/deny 规则以零延迟先行裁决明确情形
13
13
  - 其余交给携带会话上下文的模型分类器
14
14
  - 任何不确定或失败一律 fail-closed, 绝不静默放行
@@ -22,24 +22,54 @@ pi-verdict 补上这道缺失的门禁, 由模型基于上下文和你的意图
22
22
 
23
23
  ## 为什么是三态
24
24
 
25
- **verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」。
25
+ **verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」——目标是安全的自动化而非最大的自动化:审批疲劳与静默危险执行都是危险。
26
+
27
+ ## 设计原则
28
+
29
+ - **Fail closed**——不确定产生摩擦,绝不产生许可。
30
+ - **确定性 floor 先于 AI**——硬 deny 永不被分类器或用户 allow 规则覆盖。
31
+ - **语义优先于语法**——分类器判定的是动作**做什么可能会产生什么安全影响**,而不是命令有多长。
32
+ - **是判断,不是证明**——分类器的 `allow` 是有依据的判断;floor 的存在正因为它仅此而已。
33
+ - **最小化可信输入**——transcript 不含工具结果(#22),分类器零路径明文(ADR-0002)。
34
+ - **规范化身份**——词法 + realpath 双形匹配;「看起来在项目内」的路径不因此被信任(#20/#21)。
35
+ - **门禁守护自身**——任何配置都关不掉的自保护层(ADR-0001)。
36
+ - **是权限门禁,不是沙箱**——请在上面叠加 OS 级隔离;本门禁不替代它。
37
+
38
+ 完整表述见 [docs/security-principles.md](docs/security-principles.md):
39
+
26
40
 
27
41
  ## 截图
28
42
 
43
+ ![演示:受保护路径 ask 被拒绝](docs/demo.gif)
44
+
29
45
  ![Automode Status](docs/images/status.png)
30
46
  ![Ask Permission](docs/images/asked.png)
31
47
 
32
48
  ## 快速开始
33
49
 
34
50
  ```bash
35
- # 从 npm 安装:
51
+ # 从 npm 安装(pi):
36
52
  pi install npm:pi-verdict
37
53
 
54
+ # 从 npm 安装(oh-my-pi / omp):
55
+ omp plugin install npm:pi-verdict
56
+
38
57
  # 或直接从源码 —— 试用一次
39
58
  pi --extension ./extensions/auto-mode.ts
40
59
 
41
60
  ```
42
61
 
62
+ ### 宿主
63
+
64
+ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi](https://github.com/can1357/oh-my-pi)(omp)——扩展按自身安装位置自锚定到所在宿主的目录树,双宿主并存的机器上跟随扩展副本自身的位置。omp 18 下分类器的模型调用经 pi-ai compat API 降级(仍然 fail-closed)。细节见 [docs/configuration.md](docs/configuration.md#host-notes-pi-and-oh-my-pi)。
65
+
66
+ | | pi | omp |
67
+ |---|---|---|
68
+ | 安装 | `pi install npm:pi-verdict` | `omp plugin install npm:pi-verdict` |
69
+ | 扩展副本 | `~/.pi/agent/extensions/` | `~/.omp/plugins/node_modules/pi-verdict/`(omp 18.1+;≤18.0 在 `agent/` 下) |
70
+ | 用户规则 | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
71
+ | 凭据文件(S0 硬 deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
72
+
43
73
  - `/automode` —— 显示当前状态:开/关 + 本会话影子缓存统计
44
74
  - `/automode on`
45
75
  - `/automode off`
@@ -68,26 +98,18 @@ pi --extension ./extensions/auto-mode.ts
68
98
  ```
69
99
 
70
100
  - `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
71
- - `denyPaths` 是你声明**受保护**的普通路径列表(非正则):任何触碰它们的工具调用——文件类工具取其路径、bash 从命令串提取路径 token——触发**终局 ask**,由你裁决(非交互会话降级 deny)。不受 `builtinDenyFloor: false` 影响
72
- 分类器只被告知受保护路径**存在**,路径明文永不出本机, 命中的路径**只**出现在本地确认弹窗。
73
- - `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在——下方自保护层永远开启)
74
- - `classifierModel: "provider/model-id"` 指定分类器模型(如轻量 flash 类);优先级 flag > env > config > 自省;无效值回退会话模型并一次性警告
75
- - spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-5.3-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考)
76
- - `toggleShortcut` 重绑主开关快捷键(`null` 或空串禁用, 非持久化)
77
- - 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`)
101
+ - `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机
102
+ - `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
103
+ - `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
78
104
 
79
- **为什么没有内置白名单?**对规则层的绕过测试([`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健壮性非常有限。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
105
+ 没有内置白名单——每一条「永远放行」声明都归你([为什么](docs/configuration.md#why-no-built-in-allowlist))。完整参考:[docs/configuration.md](docs/configuration.md)
80
106
 
81
107
  ### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
82
108
 
83
- 门禁自身的文件——`config/pi-verdict.json` 与 `<agentDir>/extensions/` 下的扩展安装副本(运行时经 `import.meta.url` 自锚定,覆盖单文件与 npm 目录两种安装形态)——**仅用户可改**:
84
-
85
- - `write`/`edit` 触碰 → 硬 **deny**(realpath 归一化比对,含符号链接间接路径);读放行
86
- - bash/powershell 命令串触碰 → **deny**(字面量/`~`/`$HOME`/`$PI_CODING_AGENT_DIR` 拼写的子串正则——诚实声明:可被混淆绕过,见下方兜底)
87
- - **不可经任何配置关闭**:`builtinDenyFloor: false` 关不掉它,任何用户 `allow` 规则也越不过它。理由:用户主权豁免的对象是「我的系统的风险」,不是门禁自身的完整性——一个能被其守护对象关掉的门禁,无法诚实兑现「风险自担」的承诺
88
- - **变更检测**(纵深兜底):受保护文件在 `session_start` 快照、每次裁决前复核,处置按文件差分——**扩展副本**被改(或 headless 会话中的任何变更)→ 从快照**自动还原** + 本会话 **fail-closed**(全量拦截)直至重启;交互会话中仅**配置文件**被改 → 弹一次双选,选项文案即动作本身:*接受新版本*(重建基线、会话照常——你的编辑得以保留,照旧下一会话生效)/*拒绝*(还原会话基线:回滚 + fail-closed);关闭对话框等同拒绝(安全侧)。无条件自动还原等于「pi 运行期间你永远改不了配置」;纯警告则可能让被忽略的警告把下一会话交给被篡改的配置——罕见而郑重的一次确认是中间道路
109
+ 门禁自身的文件——配置与扩展安装副本——**仅用户可改**:门禁之内的写入一律硬 deny(读放行);你的编辑器修改不经门禁,最近的同构先例是 sudoers 必须经 visudo。
89
110
 
90
- 门禁之内的一切写入按定义均由 agent 发发——deny 写入即等价于「仅用户可改」,你的编辑器修改不经门禁。最近的同构先例是 sudoers 必须经 visudo。
111
+ - **不可经任何配置关闭**——`builtinDenyFloor: false` 与用户 `allow` 规则都动不了这一层
112
+ - **变更检测**作纵深兜底:受保护文件在 `session_start` 快照、每次裁决前复核——扩展副本被改 → 自动还原 + 本会话 fail-closed;配置被改 → 一次明确的双选确认(差分处置的完整语义见 [ADR-0001](docs/adr/0001-self-protection-layer.md))
91
113
 
92
114
  需要 pi ≥ 0.84。交互与非交互(`-p`/json/rpc)会话均支持;非交互模式下 `ask` 降级为 `deny`。
93
115
 
@@ -102,9 +124,7 @@ pi --extension ./extensions/auto-mode.ts
102
124
 
103
125
  完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
104
126
 
105
- 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖单文件(~1.2k 行,随功能增长,仍刻意单文件)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
106
-
107
- 零依赖单文件形态是有意为之——整个扩展就是一个可通读的[单文件](extensions/auto-mode.ts),~1.2k 行,随功能增长。
127
+ 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖的[可通读单文件](extensions/auto-mode.ts)(仍刻意单文件)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
108
128
 
109
129
  ## 管线
110
130
 
@@ -112,39 +132,28 @@ pi --extension ./extensions/auto-mode.ts
112
132
  tool_call
113
133
 
114
134
  ├─ 0. 自保护层(ADR-0001;不可经任何配置关闭)
115
- │ ├─ write/edit/bash 触碰门禁自身文件
116
- │ (config/pi-verdict.json + 扩展安装副本) deny
117
- │ 读放行;用户在 pi 之外的编辑不经门禁
118
- │ └─ 变更检测:每次裁决前复核受保护文件;
119
- │ 扩展副本被改 / headless → 从会话快照自动还原
120
- │ + 本会话 fail-closed(全量拦截);
121
- │ 仅配置被改且有 UI → 一次保留/还原确认
135
+ │ ├─ write/edit/bash 触碰门禁自身文件 → deny;读放行
136
+ └─ 变更检测:每次裁决前复核
137
+ 自动还原 + fail-closed,或一次保留/还原确认
122
138
 
123
139
  ├─ 1. 规则层(确定性,零延迟)
124
- │ ├─ 内置 deny floor:bash 危险正则(完整命令串,截断上限 8192 字符)+ 路径敏感度 S0–S5
125
- │ (双形匹配 —— 词法 + realpath,符号链接别名会被解析)
126
- │ ├─ 用户规则:deny 优先于 allow(正则,见下)
127
- │ ├─ denyPaths(ADR-0002):用户声明的受保护路径,工具负责归一化
128
- │ │ (~、$HOME、相对、..、symlink、macOS/Windows 大小写)→ 终局 ask,先于用户 allow;
140
+ │ ├─ 内置 deny floor:bash 危险正则 + 路径敏感度 S0–S5
141
+ ├─ 用户规则:deny 优先于 allow
142
+ │ ├─ denyPaths(ADR-0002):受保护路径 终局 ask,先于用户 allow;
129
143
  │ │ 分类器只见存在性话术
130
144
  │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
131
145
 
132
146
  ├─ 2. 灰区 → 模型分类器(默认继承会话模型 —— "自省")
133
- │ ├─ 输入:CC 风格 <transcript>(最近 5 条用户消息 + 最近 10 次工具调用,
134
- │ │ 待审动作固定在末尾)—— 用户意图是证据
135
- ├─ 输出契约:<verdict>allow|ask|deny</verdict> 前缀锚定
136
- │ ├─ 配置了 denyPaths 时注入存在性话术:分类器知道受保护路径存在
137
- │ │ (永不知其内容),对先拷贝再读取/打包/间接引用从紧裁决
138
- │ ├─ 显式关思考(thinkingEnabled: false)+ 两档重试 512→1024
139
- │ └─ 可用 --auto-mode-model 配置
147
+ │ ├─ 输入:CC 风格 <transcript> —— 近期用户意图 + 工具调用,
148
+ │ │ 待审动作固定在末尾
149
+ └─ 输出契约:<verdict>allow|ask|deny</verdict> 前缀锚定
140
150
 
141
151
  └─ 3. 三态裁决
142
152
  ├─ allow → 放行
143
153
  ├─ deny → 拦截,理由回传 agent
144
- └─ ask → 人工确认(ctx.ui.confirm);非交互模式降级为 deny
154
+ └─ ask → 人工确认;非交互模式降级为 deny
145
155
 
146
- [影子缓存](observe-only,与 2/3 并行,永不改变裁决)
147
- 回放双键 LRU(128)测量 would-be 命中率
156
+ [影子缓存] observe-only 遥测,与 2/3 并行,永不改变裁决
148
157
  ```
149
158
 
150
159
  **fail-closed**:分类器异常 / 超时(25s)/ 输出违反契约 → 拦截,绝不静默放行。
@@ -204,8 +204,46 @@ interface UserRules {
204
204
 
205
205
  const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT };
206
206
 
207
+ /** This module's own file location (import.meta.url resolved; null = unresolvable). */
208
+ const OWN_FILE_PATH: string | null = (() => {
209
+ try {
210
+ return fileURLToPath(import.meta.url);
211
+ } catch {
212
+ return null;
213
+ }
214
+ })();
215
+
216
+ /**
217
+ * Resolve the agent directory the gate is anchored to (#35, dual-host):
218
+ * 1. PI_CODING_AGENT_DIR — explicit user override, always wins.
219
+ * 2. Self-anchoring from the extension's own installed path: a copy at
220
+ * <home>/<dot-dir>/(agent/)?(plugins/node_modules/<pkg>/)?extensions/…
221
+ * anchors to <home>/<dot-dir>/agent. Covers the pi forms
222
+ * (~/.pi/agent/extensions[/pkg]/…) and the two omp npm layouts:
223
+ * under the agent dir (~/.omp/agent/plugins/node_modules/<pkg>/…) and,
224
+ * since omp 18.1, next to it (~/.omp/plugins/node_modules/<pkg>/…) —
225
+ * omp keeps its config tree under <dot-dir>/agent in both layouts.
226
+ * Deliberately NO host-tree existence probing: on a dual-install machine
227
+ * running under pi, a present ~/.omp must not misroute the gate.
228
+ * 3. Fallback: today's default (~/.pi/agent) — dev checkouts and any
229
+ * unanchored location.
230
+ * Both the lexical and the realpath form of ownFile are tried (symlinked
231
+ * agent trees, macOS firmlink homes).
232
+ */
233
+ export function resolveAgentDir(ownFile: string | null, home: string, envAgentDir: string | undefined): string {
234
+ if (envAgentDir) return envAgentDir;
235
+ if (ownFile) {
236
+ const anchor = new RegExp(`^${escapeRegExp(home)}(/(\\.[^/]+)/(?:agent/)?(?:plugins/node_modules/(?:@[^/]+/)?[^/]+/)?extensions/)`);
237
+ for (const f of [ownFile, tryRealpath(ownFile)]) {
238
+ const m = f.match(anchor);
239
+ if (m) return path.join(home, m[2], "agent");
240
+ }
241
+ }
242
+ return path.join(home, ".pi", "agent");
243
+ }
244
+
207
245
  function agentDirPath(): string {
208
- return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
246
+ return resolveAgentDir(OWN_FILE_PATH, os.homedir(), process.env.PI_CODING_AGENT_DIR);
209
247
  }
210
248
 
211
249
  function userConfigPath(): string {
@@ -298,7 +336,7 @@ function expandHome(p: string): string {
298
336
  // the occasional false positive fails toward deny (safe direction).
299
337
  const S0_SECRET = [
300
338
  /\.ssh(\/|$)/i, /\.aws(\/|$)/i, /\.gnupg(\/|$)/i, /(^|\/)\.env(\.|$)/i, /credentials?(\.|\/|$)/i,
301
- /(^|\/)id_rsa/i, /\.pem$/i, /_history$/i, /\.config\/gh(\/|$)/i, /\.pi\/agent\/auth\.json$/i,
339
+ /(^|\/)id_rsa/i, /\.pem$/i, /_history$/i, /\.config\/gh(\/|$)/i, /\.(?:pi|omp)\/agent\/auth\.json$/i,
302
340
  // V8(安全审计):常见明文凭证文件补全
303
341
  /(^|\/)\.netrc$/i, /(^|\/)\.npmrc$/i, /(^|\/)\.pypirc$/i, /(^|\/)\.envrc$/i, /(^|\/)\.vault-token$/i,
304
342
  /\.kube(\/|$)/i, /\.docker\/config\.json$/i, /\.gem\/credentials$/i,
@@ -469,8 +507,9 @@ function hitDenyPaths(toolName: string, input: Record<string, unknown>, cwd: str
469
507
  // 门禁自身的完整性不受任何配置豁免:builtinDenyFloor:false 只关危险正则与路径
470
508
  // 敏感度,关不掉本层;用户 allow 规则亦不可越过。保护对象:
471
509
  // - <agentDir>/config/pi-verdict.json(用户规则 = 门禁的判定输入)
472
- // - 本扩展的安装副本(<agentDir>/extensions/ 下;自锚定 import.meta.url,
473
- // 覆盖单文件与 npm 包目录两种安装形态;dev checkout 不在此列)
510
+ // - 本扩展的安装副本(pi under <agentDir>/extensions/, omp under
511
+ // plugins/node_modules/<pkg>/ in its config root — install forms listed
512
+ // with resolveAgentDir; dev checkouts are not in scope)
474
513
  // 语义:门禁内一切写入按定义均由 agent 发起 → 恒 deny(reason 指引手工编辑);
475
514
  // 读放行(读门禁文件无害);用户经编辑器的修改不经门禁,不受影响。
476
515
  // bash 侧:命令串正则覆盖字面量/~/\$HOME/\$PI_CODING_AGENT_DIR 变体,可被混淆
@@ -517,8 +556,10 @@ function escapeRegExp(s: string): string {
517
556
  /**
518
557
  * 构建受保护集合。
519
558
  * ownFile:本模块文件路径(import.meta.url 解析;null = 不可解析,仅保护配置)。
520
- * 仅当 ownFile 位于 <agentDir>/extensions/ 之下才视为安装副本加以保护:
521
- * dev checkout(cwd 内源码)不保护——项目内开发写入是合法日常(ADR-0001)。
559
+ * The installed copy is protected only when ownFile sits under one of the
560
+ * install roots (forms listed with resolveAgentDir; #35). Dev checkouts
561
+ * (source inside the cwd) are NOT protected — in-project development writes
562
+ * are legitimate daily work (ADR-0001).
522
563
  */
523
564
  export function buildProtectedSet(agentDir: string, ownFile: string | null): ProtectedSet {
524
565
  const exact = new Set<string>();
@@ -572,14 +613,32 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
572
613
  watchBases.push({ file: ownFile, kind: "extension" });
573
614
  const seenWatch = new Set<string>([ownFile]);
574
615
  let pkgRoot: string | null = null;
575
- const extRoots = new Set([path.join(agentDir, "extensions"), tryRealpath(path.join(agentDir, "extensions"))]);
616
+ // Install roots, lexical + realpath forms (#35): <agentDir>/extensions
617
+ // (pi) and plugins/node_modules under agentDir or its parent dir (the
618
+ // two omp layouts — see resolveAgentDir for the layout history). The
619
+ // path segments under the matched root name the install target: a file
620
+ // → exact, a package dir (`@scope/pkg` or `pkg`) → prefix, so every
621
+ // npm form gets whole-package-dir protection (#26).
622
+ const extRoots = new Set<string>();
623
+ const agentBases = new Set([agentDir, tryRealpath(agentDir)]);
624
+ const configRootBases = new Set([...agentBases].map((b) => path.dirname(b)));
625
+ for (const seg of [["extensions"], ["plugins", "node_modules"]]) {
626
+ const bases = seg.length === 2 ? new Set([...agentBases, ...configRootBases]) : agentBases;
627
+ for (const base of bases) {
628
+ const root = path.join(base, ...seg);
629
+ extRoots.add(root);
630
+ extRoots.add(tryRealpath(root));
631
+ }
632
+ }
576
633
  const ownForms = new Set([ownFile, tryRealpath(ownFile)]);
577
634
  for (const extRoot of extRoots) {
578
635
  for (const own of ownForms) {
579
636
  if (!own.startsWith(extRoot + path.sep)) continue;
580
- const rel = path.relative(extRoot, own);
581
- const singleFile = !rel.includes(path.sep);
582
- const target = singleFile ? own : path.join(extRoot, rel.split(path.sep)[0]);
637
+ const segs = path.relative(extRoot, own).split(path.sep);
638
+ const singleFile = segs.length === 1;
639
+ // npm scopes are two-segment dirs (@scope/pkg): the install
640
+ // target is the package, not the whole scope dir
641
+ const target = singleFile ? own : path.join(extRoot, ...segs.slice(0, segs[0].startsWith("@") ? 2 : 1));
583
642
  for (const f of pathForms(target)) {
584
643
  (singleFile ? exact : prefixes).add(f);
585
644
  extTargets.add(f);
@@ -840,9 +899,60 @@ const CLASSIFIER_TIMEOUT_MS = 25_000; // 本网关 CC 分类器分布 p90=19.8s(
840
899
  const CLASSIFIER_MAX_TOKENS = 512;
841
900
  const CLASSIFIER_RETRY_MAX_TOKENS = 1024; // 防御重试档:覆盖无视 reasoning:off 或轻思考仍超预算的模型
842
901
 
902
+ /**
903
+ * Minimal structural shape of a completion call (#35). pi exposes it as
904
+ * ModelRegistry.complete; omp 18 does not, but the pi-ai compat module exports
905
+ * a functionally identical `complete`. Options pass through verbatim on both
906
+ * hosts (thinkingEnabled/effort/cacheRetention included — see
907
+ * research/thinking-param-blackhole.md for why API-native fields matter).
908
+ */
909
+ export type CompletionFn = (
910
+ model: NonNullable<ExtensionContext["model"]>,
911
+ context: { systemPrompt?: string; messages: unknown[] },
912
+ options?: Record<string, unknown>,
913
+ ) => Promise<{ content: Array<{ type: string; text: string }>; stopReason?: string }>;
914
+
915
+ type CompatLoader = () => Promise<{ complete: CompletionFn }>;
916
+
917
+ /**
918
+ * Bind the host runtime's completion capability (#35): registry.complete when
919
+ * present (pi), else the pi-ai compat module (omp 18). The literal dynamic
920
+ * import specifier must stay inline — omp's legacy compat rewrites exactly
921
+ * this literal to its bundled pi-ai; the ./compat subpath also exists on pi,
922
+ * so resolution is safe on both hosts. The loader promise is cached; any
923
+ * rejection propagates to the caller (the classifier's fail-closed path owns it).
924
+ */
925
+ export function bindCompletion(
926
+ registry: { complete?: unknown },
927
+ compatLoader: CompatLoader = () => import("@earendil-works/pi-ai/compat") as Promise<{ complete: CompletionFn }>,
928
+ ): CompletionFn {
929
+ if (typeof registry.complete === "function") {
930
+ const complete = registry.complete as CompletionFn;
931
+ return (m, c, o) => complete.call(registry, m, c, o);
932
+ }
933
+ let compat: Promise<{ complete: CompletionFn }> | undefined;
934
+ return async (m, c, o) => {
935
+ compat ??= compatLoader();
936
+ const { complete } = await compat;
937
+ return complete(m, c, o);
938
+ };
939
+ }
940
+
941
+ // Session-lifetime cache keyed by registry instance: resolve once per registry.
942
+ const completionCache = new WeakMap<object, CompletionFn>();
943
+ function completionFor(registry: { complete?: unknown }, compatLoader?: CompatLoader): CompletionFn {
944
+ let fn = completionCache.get(registry);
945
+ if (!fn) {
946
+ fn = bindCompletion(registry, compatLoader);
947
+ completionCache.set(registry, fn);
948
+ }
949
+ return fn;
950
+ }
951
+
843
952
  /** 单次分类器调用:显式 reasoning:"off"(见下方注释),失败返回错误串而非抛出 */
844
953
  async function callClassifierOnce(
845
954
  ctx: ExtensionContext,
955
+ complete: CompletionFn,
846
956
  model: NonNullable<ExtensionContext["model"]>,
847
957
  userMessage: string,
848
958
  maxTokens: number,
@@ -852,7 +962,7 @@ async function callClassifierOnce(
852
962
  const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
853
963
  if (ctx.signal) signals.push(ctx.signal);
854
964
  try {
855
- const response = await ctx.modelRegistry.complete(
965
+ const response = await complete(
856
966
  model,
857
967
  {
858
968
  systemPrompt,
@@ -862,14 +972,25 @@ async function callClassifierOnce(
862
972
  signal: AbortSignal.any(signals),
863
973
  maxTokens,
864
974
  temperature: 0,
865
- // 思考参数必须用 API 原生字段(thinkingEnabled/effort),而非 reasoning
866
- // (API complete() 无此字段,宽类型索引签名静默放行后运行时丢弃——见
867
- // research/thinking-param-blackhole.md)。
868
- // 缺省 off = 显式关思考(实证送达 thinking:{"type":"disabled"},GLM 降为
869
- // effort low 轻思考);后缀级别经 adaptive effort 送达(minimal→low 映射)。
975
+ // Thinking params go out in both hosts' native dialects (#35):
976
+ // pi's registry.complete consumes thinkingEnabled/effort (the
977
+ // API-native fields, per the blackhole findings in
978
+ // research/thinking-param-blackhole.md); omp's compat complete
979
+ // consumes reasoning/disableReasoning. Both sides ignore unknown
980
+ // option fields, so dual-send lets each host pick its own.
981
+ // pi off = explicitly disabled (verified to send
982
+ // thinking:{"type":"disabled"}; GLM downgrades to effort-low light
983
+ // thinking); suffix levels arrive via adaptive effort (minimal→low).
984
+ // omp off = disableReasoning (without it, an absent `reasoning`
985
+ // leaves the model default undefined); level vocabularies share the
986
+ // ThinkingLevel word list, reasoning passes through as-is.
870
987
  ...(thinking === "off"
871
- ? { thinkingEnabled: false }
872
- : { thinkingEnabled: true, effort: thinking === "minimal" ? ("low" as const) : thinking }),
988
+ ? { thinkingEnabled: false, disableReasoning: true }
989
+ : {
990
+ thinkingEnabled: true,
991
+ effort: thinking === "minimal" ? ("low" as const) : thinking,
992
+ reasoning: thinking === "minimal" ? ("low" as const) : thinking,
993
+ }),
873
994
  cacheRetention: "short",
874
995
  sessionId: ctx.sessionManager.getSessionId(),
875
996
  },
@@ -878,7 +999,7 @@ async function callClassifierOnce(
878
999
  .filter((b) => b.type === "text")
879
1000
  .map((b) => b.text)
880
1001
  .join("");
881
- return { ok: true, text, stopReason: response.stopReason };
1002
+ return { ok: true, text, stopReason: response.stopReason ?? "unknown" };
882
1003
  } catch (err) {
883
1004
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
884
1005
  }
@@ -892,6 +1013,7 @@ async function callClassifierOnce(
892
1013
  */
893
1014
  async function classifyWithModel(
894
1015
  ctx: ExtensionContext,
1016
+ complete: CompletionFn,
895
1017
  model: NonNullable<ExtensionContext["model"]>,
896
1018
  actionLine: string,
897
1019
  thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
@@ -904,7 +1026,7 @@ async function classifyWithModel(
904
1026
  const failures: string[] = [];
905
1027
  for (const [n, maxTokens] of attempts) {
906
1028
  if (ctx.signal?.aborted) break; // 用户已取消,不再重试
907
- const r = await callClassifierOnce(ctx, model, userMessage, maxTokens, thinking, systemPrompt);
1029
+ const r = await callClassifierOnce(ctx, complete, model, userMessage, maxTokens, thinking, systemPrompt);
908
1030
  if (r.ok) {
909
1031
  const diag = `stopReason=${r.stopReason}, model=${model.id}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
910
1032
  if (r.stopReason !== "error" && r.stopReason !== "aborted") {
@@ -1044,7 +1166,12 @@ function shadowTag(probe: ShadowProbe): string {
1044
1166
  // 扩展主体
1045
1167
  // ============================================================================
1046
1168
 
1047
- export default function autoMode(pi: ExtensionAPI) {
1169
+ /** Optional dependency injection for tests (#35): fake the compat fallback loader. */
1170
+ export interface AutoModeDeps {
1171
+ compatLoader?: CompatLoader;
1172
+ }
1173
+
1174
+ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1048
1175
  pi.registerFlag("auto-mode", { description: "Enable Auto Mode (rules + model classifier gating for tool calls)", type: "boolean", default: true });
1049
1176
  pi.registerFlag("auto-mode-model", { description: "Classifier model as provider/id[:thinking] (pi --model syntax; default: inherit session model)", type: "string" });
1050
1177
  pi.registerFlag("auto-mode-debug", { description: "Notify every verdict incl. allows, with shadow-cache annotation", type: "boolean", default: false });
@@ -1063,15 +1190,9 @@ export default function autoMode(pi: ExtensionAPI) {
1063
1190
  return denyPathBases;
1064
1191
  };
1065
1192
 
1066
- // 自保护层(ADR-0001):受保护集合自锚定 + 变更检测基线(会话内存态)
1067
- const ownFilePath = (() => {
1068
- try {
1069
- return fileURLToPath(import.meta.url);
1070
- } catch {
1071
- return null;
1072
- }
1073
- })();
1074
- const prot = buildProtectedSet(agentDirPath(), ownFilePath);
1193
+ // Self-protection layer (ADR-0001): self-anchored protected set + tamper
1194
+ // baseline (in-memory, per session)
1195
+ const prot = buildProtectedSet(agentDirPath(), OWN_FILE_PATH);
1075
1196
  let snapshots = takeSnapshots(prot.watchBases);
1076
1197
  let tampered = false;
1077
1198
 
@@ -1303,7 +1424,7 @@ export default function autoMode(pi: ExtensionAPI) {
1303
1424
  const ctxKey = shadowContextKey(ctx);
1304
1425
  const probe = shadow.probe(cmdKey, ctxKey);
1305
1426
 
1306
- const outcome = await classifyWithModel(ctx, model, action, classifierThinking, userRules.denyPaths.length > 0);
1427
+ const outcome = await classifyWithModel(ctx, completionFor(ctx.modelRegistry, deps.compatLoader), model, action, classifierThinking, userRules.denyPaths.length > 0);
1307
1428
 
1308
1429
  // 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
1309
1430
  // 命中且本次为可缓存裁决时,对比反事实一致性
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "A minimal permission gate for Pi in the style of Claude Code's auto mode",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",