pi-verdict 0.5.2 → 0.6.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
@@ -22,7 +22,20 @@ 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
+ A small set of security principles shapes the whole gate — the full statement, with the honest edges, lives in [docs/security-principles.md](docs/security-principles.md):
30
+
31
+ - **Fail closed** — uncertainty produces friction, never permission.
32
+ - **Deterministic floor before AI** — hard denies are never overridden by the classifier or user allow rules.
33
+ - **Semantics over syntax** — a long read-only pipeline may auto-allow while a short destructive one still denies; the classifier judges what an action *does*, not how long it is.
34
+ - **Judgments, not proofs** — a classifier `allow` is an informed opinion; the floor exists because that is all it is.
35
+ - **Minimal trusted input** — no tool results in the transcript (#22), zero path plaintext to the classifier (ADR-0002).
36
+ - **Canonical identity** — lexical + realpath dual-form matching; a workspace-*looking* path is not trusted as one (#20/#21).
37
+ - **The gate guards itself** — self-protection that no configuration can disable (ADR-0001).
38
+ - **A permission gate, not a sandbox** — stack OS isolation on top; this gate never replaces it.
26
39
 
27
40
  ## Screenshots
28
41
 
@@ -32,14 +45,28 @@ pi-verdict adds the missing gate: a model decides whether each call should run,
32
45
  ## Quick start
33
46
 
34
47
  ```bash
35
- # install from npm:
48
+ # install from npm (pi):
36
49
  pi install npm:pi-verdict
37
50
 
51
+ # install from npm (oh-my-pi / omp):
52
+ omp plugin install npm:pi-verdict
53
+
38
54
  # or directly from git — try it once
39
55
  pi --extension ./extensions/auto-mode.ts
40
56
 
41
57
  ```
42
58
 
59
+ ### Hosts
60
+
61
+ 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).
62
+
63
+ | | pi | omp |
64
+ |---|---|---|
65
+ | install | `pi install npm:pi-verdict` | `omp plugin install npm:pi-verdict` |
66
+ | extension copy | `~/.pi/agent/extensions/` | `~/.omp/agent/plugins/node_modules/pi-verdict/` |
67
+ | user rules | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
68
+ | credential file (S0 hard deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
69
+
43
70
  - `/automode` — show current status: on/off + shadow-cache stats for the session
44
71
  - `/automode on`
45
72
  - `/automode off`
@@ -68,26 +95,18 @@ pi --extension ./extensions/auto-mode.ts
68
95
  ```
69
96
 
70
97
  - `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`)
98
+ - `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
99
+ - `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
100
+ - `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
78
101
 
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.
102
+ 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
103
 
81
104
  ### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
82
105
 
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**:
106
+ 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
107
 
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.
108
+ - **Not disableable by any config** `builtinDenyFloor: false` and user `allow` rules cannot touch this layer
109
+ - **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
110
 
92
111
  Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) sessions; in non-interactive modes `ask` degrades to `deny`.
93
112
 
@@ -102,9 +121,7 @@ Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) s
102
121
 
103
122
  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
123
 
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.
124
+ 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
125
 
109
126
  ## Pipeline
110
127
 
@@ -112,42 +129,28 @@ The single-file, zero-dependency shape is deliberate — the whole extension is
112
129
  tool_call
113
130
 
114
131
  ├─ 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
132
+ │ ├─ write/edit/bash touching the gate's own files → deny; reads pass
133
+ └─ tamper detection: re-verify before every verdict
134
+ auto-restore + fail-closed, or one keep/restore confirm
122
135
 
123
136
  ├─ 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,
137
+ │ ├─ built-in deny floor: bash danger regexes + path sensitivity S0–S5
138
+ ├─ your rules: user deny beats user allow
139
+ ├─ denyPaths (ADR-0002): protected paths terminal ask,
131
140
  │ │ before user allow; classifier sees an existence hint only
132
141
  │ └─ no built-in allowlist — every "always allow" claim is yours to make
133
142
 
134
143
  ├─ 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
144
+ │ ├─ input: CC-style <transcript> recent user intent + tool calls,
145
+ │ │ action under review always last
146
+ └─ output contract: <verdict>allow|ask|deny</verdict> prefix-anchored
143
147
 
144
148
  └─ 3. Three-state adjudication
145
149
  ├─ allow → pass
146
150
  ├─ deny → block, reason returned to the agent
147
- └─ ask → human confirm (ctx.ui.confirm); non-interactive modes degrade to deny
151
+ └─ ask → human confirm; non-interactive modes degrade to deny
148
152
 
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
153
+ [shadow cache] observe-only telemetry alongside 2/3, never changes a verdict
151
154
  ```
152
155
 
153
156
  **fail-closed**: classifier exception / timeout (25s) / contract violation → deny. Never silently allow.
package/README.zh-CN.md CHANGED
@@ -22,7 +22,20 @@ pi-verdict 补上这道缺失的门禁, 由模型基于上下文和你的意图
22
22
 
23
23
  ## 为什么是三态
24
24
 
25
- **verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」。
25
+ **verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」——目标是安全的自动化而非最大的自动化:审批疲劳与静默危险执行都是输家。
26
+
27
+ ## 设计原则
28
+
29
+ 一小组安全设计原则塑形了整个门禁——完整表述(含诚实的边界说明)见 [docs/security-principles.md](docs/security-principles.md):
30
+
31
+ - **Fail closed**——不确定产生摩擦,绝不产生许可。
32
+ - **确定性 floor 先于 AI**——硬 deny 永不被分类器或用户 allow 规则覆盖。
33
+ - **语义优先于语法**——长而只读的管道可以自动放行,短而具破坏性的照样拦下;分类器判定的是动作**做什么**,而不是命令有多长。
34
+ - **是判断,不是证明**——分类器的 `allow` 是有依据的判断;floor 的存在正因为它仅此而已。
35
+ - **最小化可信输入**——transcript 不含工具结果(#22),分类器零路径明文(ADR-0002)。
36
+ - **规范化身份**——词法 + realpath 双形匹配;「看起来在项目内」的路径不因此被信任(#20/#21)。
37
+ - **门禁守护自身**——任何配置都关不掉的自保护层(ADR-0001)。
38
+ - **是权限门禁,不是沙箱**——请在上面叠加 OS 级隔离;本门禁不替代它。
26
39
 
27
40
  ## 截图
28
41
 
@@ -32,14 +45,28 @@ pi-verdict 补上这道缺失的门禁, 由模型基于上下文和你的意图
32
45
  ## 快速开始
33
46
 
34
47
  ```bash
35
- # 从 npm 安装:
48
+ # 从 npm 安装(pi):
36
49
  pi install npm:pi-verdict
37
50
 
51
+ # 从 npm 安装(oh-my-pi / omp):
52
+ omp plugin install npm:pi-verdict
53
+
38
54
  # 或直接从源码 —— 试用一次
39
55
  pi --extension ./extensions/auto-mode.ts
40
56
 
41
57
  ```
42
58
 
59
+ ### 宿主
60
+
61
+ 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)。
62
+
63
+ | | pi | omp |
64
+ |---|---|---|
65
+ | 安装 | `pi install npm:pi-verdict` | `omp plugin install npm:pi-verdict` |
66
+ | 扩展副本 | `~/.pi/agent/extensions/` | `~/.omp/agent/plugins/node_modules/pi-verdict/` |
67
+ | 用户规则 | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
68
+ | 凭据文件(S0 硬 deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
69
+
43
70
  - `/automode` —— 显示当前状态:开/关 + 本会话影子缓存统计
44
71
  - `/automode on`
45
72
  - `/automode off`
@@ -68,26 +95,18 @@ pi --extension ./extensions/auto-mode.ts
68
95
  ```
69
96
 
70
97
  - `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`)
98
+ - `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机
99
+ - `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
100
+ - `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
78
101
 
79
- **为什么没有内置白名单?**对规则层的绕过测试([`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健壮性非常有限。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
102
+ 没有内置白名单——每一条「永远放行」声明都归你([为什么](docs/configuration.md#why-no-built-in-allowlist))。完整参考:[docs/configuration.md](docs/configuration.md)
80
103
 
81
104
  ### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
82
105
 
83
- 门禁自身的文件——`config/pi-verdict.json` 与 `<agentDir>/extensions/` 下的扩展安装副本(运行时经 `import.meta.url` 自锚定,覆盖单文件与 npm 目录两种安装形态)——**仅用户可改**:
106
+ 门禁自身的文件——配置与扩展安装副本——**仅用户可改**:门禁之内的写入一律硬 deny(读放行);你的编辑器修改不经门禁,最近的同构先例是 sudoers 必须经 visudo。
84
107
 
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 运行期间你永远改不了配置」;纯警告则可能让被忽略的警告把下一会话交给被篡改的配置——罕见而郑重的一次确认是中间道路
89
-
90
- 门禁之内的一切写入按定义均由 agent 发发——deny 写入即等价于「仅用户可改」,你的编辑器修改不经门禁。最近的同构先例是 sudoers 必须经 visudo。
108
+ - **不可经任何配置关闭**——`builtinDenyFloor: false` 与用户 `allow` 规则都动不了这一层
109
+ - **变更检测**作纵深兜底:受保护文件在 `session_start` 快照、每次裁决前复核——扩展副本被改 自动还原 + 本会话 fail-closed;配置被改 → 一次明确的双选确认(差分处置的完整语义见 [ADR-0001](docs/adr/0001-self-protection-layer.md))
91
110
 
92
111
  需要 pi ≥ 0.84。交互与非交互(`-p`/json/rpc)会话均支持;非交互模式下 `ask` 降级为 `deny`。
93
112
 
@@ -102,9 +121,7 @@ pi --extension ./extensions/auto-mode.ts
102
121
 
103
122
  完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
104
123
 
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 行,随功能增长。
124
+ 诚实地说: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
125
 
109
126
  ## 管线
110
127
 
@@ -112,39 +129,28 @@ pi --extension ./extensions/auto-mode.ts
112
129
  tool_call
113
130
 
114
131
  ├─ 0. 自保护层(ADR-0001;不可经任何配置关闭)
115
- │ ├─ write/edit/bash 触碰门禁自身文件
116
- │ (config/pi-verdict.json + 扩展安装副本) deny
117
- │ 读放行;用户在 pi 之外的编辑不经门禁
118
- │ └─ 变更检测:每次裁决前复核受保护文件;
119
- │ 扩展副本被改 / headless → 从会话快照自动还原
120
- │ + 本会话 fail-closed(全量拦截);
121
- │ 仅配置被改且有 UI → 一次保留/还原确认
132
+ │ ├─ write/edit/bash 触碰门禁自身文件 → deny;读放行
133
+ └─ 变更检测:每次裁决前复核
134
+ 自动还原 + fail-closed,或一次保留/还原确认
122
135
 
123
136
  ├─ 1. 规则层(确定性,零延迟)
124
- │ ├─ 内置 deny floor:bash 危险正则(完整命令串,截断上限 8192 字符)+ 路径敏感度 S0–S5
125
- │ (双形匹配 —— 词法 + realpath,符号链接别名会被解析)
126
- │ ├─ 用户规则:deny 优先于 allow(正则,见下)
127
- │ ├─ denyPaths(ADR-0002):用户声明的受保护路径,工具负责归一化
128
- │ │ (~、$HOME、相对、..、symlink、macOS/Windows 大小写)→ 终局 ask,先于用户 allow;
137
+ │ ├─ 内置 deny floor:bash 危险正则 + 路径敏感度 S0–S5
138
+ ├─ 用户规则:deny 优先于 allow
139
+ │ ├─ denyPaths(ADR-0002):受保护路径 终局 ask,先于用户 allow;
129
140
  │ │ 分类器只见存在性话术
130
141
  │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
131
142
 
132
143
  ├─ 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 配置
144
+ │ ├─ 输入:CC 风格 <transcript> —— 近期用户意图 + 工具调用,
145
+ │ │ 待审动作固定在末尾
146
+ └─ 输出契约:<verdict>allow|ask|deny</verdict> 前缀锚定
140
147
 
141
148
  └─ 3. 三态裁决
142
149
  ├─ allow → 放行
143
150
  ├─ deny → 拦截,理由回传 agent
144
- └─ ask → 人工确认(ctx.ui.confirm);非交互模式降级为 deny
151
+ └─ ask → 人工确认;非交互模式降级为 deny
145
152
 
146
- [影子缓存](observe-only,与 2/3 并行,永不改变裁决)
147
- 回放双键 LRU(128)测量 would-be 命中率
153
+ [影子缓存] observe-only 遥测,与 2/3 并行,永不改变裁决
148
154
  ```
149
155
 
150
156
  **fail-closed**:分类器异常 / 超时(25s)/ 输出违反契约 → 拦截,绝不静默放行。
@@ -204,8 +204,45 @@ 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 omp npm form
223
+ * (~/.omp/agent/plugins/node_modules/<pkg>/extensions/…); XDG-style
224
+ * ~/.config/pi trees match too because the anchor accepts any dot-dir.
225
+ * Deliberately NO host-tree existence probing: on a dual-install machine
226
+ * running under pi, a present ~/.omp must not misroute the gate.
227
+ * 3. Fallback: today's default (~/.pi/agent) — dev checkouts and any
228
+ * unanchored location.
229
+ * Both the lexical and the realpath form of ownFile are tried (symlinked
230
+ * agent trees, macOS firmlink homes).
231
+ */
232
+ export function resolveAgentDir(ownFile: string | null, home: string, envAgentDir: string | undefined): string {
233
+ if (envAgentDir) return envAgentDir;
234
+ if (ownFile) {
235
+ const anchor = new RegExp(`^${escapeRegExp(home)}(/(\\.[^/]+)/agent/(?:plugins/node_modules/(?:@[^/]+/)?[^/]+/)?extensions/)`);
236
+ for (const f of [ownFile, tryRealpath(ownFile)]) {
237
+ const m = f.match(anchor);
238
+ if (m) return path.join(home, m[2], "agent");
239
+ }
240
+ }
241
+ return path.join(home, ".pi", "agent");
242
+ }
243
+
207
244
  function agentDirPath(): string {
208
- return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
245
+ return resolveAgentDir(OWN_FILE_PATH, os.homedir(), process.env.PI_CODING_AGENT_DIR);
209
246
  }
210
247
 
211
248
  function userConfigPath(): string {
@@ -298,7 +335,7 @@ function expandHome(p: string): string {
298
335
  // the occasional false positive fails toward deny (safe direction).
299
336
  const S0_SECRET = [
300
337
  /\.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,
338
+ /(^|\/)id_rsa/i, /\.pem$/i, /_history$/i, /\.config\/gh(\/|$)/i, /\.(?:pi|omp)\/agent\/auth\.json$/i,
302
339
  // V8(安全审计):常见明文凭证文件补全
303
340
  /(^|\/)\.netrc$/i, /(^|\/)\.npmrc$/i, /(^|\/)\.pypirc$/i, /(^|\/)\.envrc$/i, /(^|\/)\.vault-token$/i,
304
341
  /\.kube(\/|$)/i, /\.docker\/config\.json$/i, /\.gem\/credentials$/i,
@@ -469,8 +506,10 @@ function hitDenyPaths(toolName: string, input: Record<string, unknown>, cwd: str
469
506
  // 门禁自身的完整性不受任何配置豁免:builtinDenyFloor:false 只关危险正则与路径
470
507
  // 敏感度,关不掉本层;用户 allow 规则亦不可越过。保护对象:
471
508
  // - <agentDir>/config/pi-verdict.json(用户规则 = 门禁的判定输入)
472
- // - 本扩展的安装副本(<agentDir>/extensions/ 下;自锚定 import.meta.url,
473
- // 覆盖单文件与 npm 包目录两种安装形态;dev checkout 不在此列)
509
+ // - 本扩展的安装副本(pi installs under <agentDir>/extensions/, omp under
510
+ // <agentDir>/plugins/node_modules/<pkg>/; self-anchored via import.meta.url,
511
+ // covering the single-file and npm package-dir install forms; dev
512
+ // 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
560
+ * <agentDir>/extensions/ (pi) or <agentDir>/plugins/node_modules/<pkg>/
561
+ * (omp npm form, #35). Dev checkouts (source inside the cwd) are NOT
562
+ * protected — in-project development writes 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,7 +613,20 @@ 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): pi installs under
617
+ // <agentDir>/extensions/, omp installs npm plugins under
618
+ // <agentDir>/plugins/node_modules/. First path segment under the matched
619
+ // root is the install target (single file → exact, package dir → prefix),
620
+ // so the omp form gets whole-package-dir protection exactly like the pi
621
+ // npm-dir form (#26).
622
+ const extRoots = new Set<string>();
623
+ for (const seg of [["extensions"], ["plugins", "node_modules"]]) {
624
+ for (const base of new Set([agentDir, tryRealpath(agentDir)])) {
625
+ const root = path.join(base, ...seg);
626
+ extRoots.add(root);
627
+ extRoots.add(tryRealpath(root));
628
+ }
629
+ }
576
630
  const ownForms = new Set([ownFile, tryRealpath(ownFile)]);
577
631
  for (const extRoot of extRoots) {
578
632
  for (const own of ownForms) {
@@ -840,9 +894,60 @@ const CLASSIFIER_TIMEOUT_MS = 25_000; // 本网关 CC 分类器分布 p90=19.8s(
840
894
  const CLASSIFIER_MAX_TOKENS = 512;
841
895
  const CLASSIFIER_RETRY_MAX_TOKENS = 1024; // 防御重试档:覆盖无视 reasoning:off 或轻思考仍超预算的模型
842
896
 
897
+ /**
898
+ * Minimal structural shape of a completion call (#35). pi exposes it as
899
+ * ModelRegistry.complete; omp 18 does not, but the pi-ai compat module exports
900
+ * a functionally identical `complete`. Options pass through verbatim on both
901
+ * hosts (thinkingEnabled/effort/cacheRetention included — see
902
+ * research/thinking-param-blackhole.md for why API-native fields matter).
903
+ */
904
+ export type CompletionFn = (
905
+ model: NonNullable<ExtensionContext["model"]>,
906
+ context: { systemPrompt?: string; messages: unknown[] },
907
+ options?: Record<string, unknown>,
908
+ ) => Promise<{ content: Array<{ type: string; text: string }>; stopReason?: string }>;
909
+
910
+ type CompatLoader = () => Promise<{ complete: CompletionFn }>;
911
+
912
+ /**
913
+ * Bind the host runtime's completion capability (#35): registry.complete when
914
+ * present (pi), else the pi-ai compat module (omp 18). The literal dynamic
915
+ * import specifier must stay inline — omp's legacy compat rewrites exactly
916
+ * this literal to its bundled pi-ai; the ./compat subpath also exists on pi,
917
+ * so resolution is safe on both hosts. The loader promise is cached; any
918
+ * rejection propagates to the caller (the classifier's fail-closed path owns it).
919
+ */
920
+ export function bindCompletion(
921
+ registry: { complete?: unknown },
922
+ compatLoader: CompatLoader = () => import("@earendil-works/pi-ai/compat") as Promise<{ complete: CompletionFn }>,
923
+ ): CompletionFn {
924
+ if (typeof registry.complete === "function") {
925
+ const complete = registry.complete as CompletionFn;
926
+ return (m, c, o) => complete.call(registry, m, c, o);
927
+ }
928
+ let compat: Promise<{ complete: CompletionFn }> | undefined;
929
+ return async (m, c, o) => {
930
+ compat ??= compatLoader();
931
+ const { complete } = await compat;
932
+ return complete(m, c, o);
933
+ };
934
+ }
935
+
936
+ // Session-lifetime cache keyed by registry instance: resolve once per registry.
937
+ const completionCache = new WeakMap<object, CompletionFn>();
938
+ function completionFor(registry: { complete?: unknown }, compatLoader?: CompatLoader): CompletionFn {
939
+ let fn = completionCache.get(registry);
940
+ if (!fn) {
941
+ fn = bindCompletion(registry, compatLoader);
942
+ completionCache.set(registry, fn);
943
+ }
944
+ return fn;
945
+ }
946
+
843
947
  /** 单次分类器调用:显式 reasoning:"off"(见下方注释),失败返回错误串而非抛出 */
844
948
  async function callClassifierOnce(
845
949
  ctx: ExtensionContext,
950
+ complete: CompletionFn,
846
951
  model: NonNullable<ExtensionContext["model"]>,
847
952
  userMessage: string,
848
953
  maxTokens: number,
@@ -852,7 +957,7 @@ async function callClassifierOnce(
852
957
  const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
853
958
  if (ctx.signal) signals.push(ctx.signal);
854
959
  try {
855
- const response = await ctx.modelRegistry.complete(
960
+ const response = await complete(
856
961
  model,
857
962
  {
858
963
  systemPrompt,
@@ -862,14 +967,25 @@ async function callClassifierOnce(
862
967
  signal: AbortSignal.any(signals),
863
968
  maxTokens,
864
969
  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 映射)。
970
+ // Thinking params go out in both hosts' native dialects (#35):
971
+ // pi's registry.complete consumes thinkingEnabled/effort (the
972
+ // API-native fields, per the blackhole findings in
973
+ // research/thinking-param-blackhole.md); omp's compat complete
974
+ // consumes reasoning/disableReasoning. Both sides ignore unknown
975
+ // option fields, so dual-send lets each host pick its own.
976
+ // pi off = explicitly disabled (verified to send
977
+ // thinking:{"type":"disabled"}; GLM downgrades to effort-low light
978
+ // thinking); suffix levels arrive via adaptive effort (minimal→low).
979
+ // omp off = disableReasoning (without it, an absent `reasoning`
980
+ // leaves the model default undefined); level vocabularies share the
981
+ // ThinkingLevel word list, reasoning passes through as-is.
870
982
  ...(thinking === "off"
871
- ? { thinkingEnabled: false }
872
- : { thinkingEnabled: true, effort: thinking === "minimal" ? ("low" as const) : thinking }),
983
+ ? { thinkingEnabled: false, disableReasoning: true }
984
+ : {
985
+ thinkingEnabled: true,
986
+ effort: thinking === "minimal" ? ("low" as const) : thinking,
987
+ reasoning: thinking === "minimal" ? ("low" as const) : thinking,
988
+ }),
873
989
  cacheRetention: "short",
874
990
  sessionId: ctx.sessionManager.getSessionId(),
875
991
  },
@@ -878,7 +994,7 @@ async function callClassifierOnce(
878
994
  .filter((b) => b.type === "text")
879
995
  .map((b) => b.text)
880
996
  .join("");
881
- return { ok: true, text, stopReason: response.stopReason };
997
+ return { ok: true, text, stopReason: response.stopReason ?? "unknown" };
882
998
  } catch (err) {
883
999
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
884
1000
  }
@@ -892,6 +1008,7 @@ async function callClassifierOnce(
892
1008
  */
893
1009
  async function classifyWithModel(
894
1010
  ctx: ExtensionContext,
1011
+ complete: CompletionFn,
895
1012
  model: NonNullable<ExtensionContext["model"]>,
896
1013
  actionLine: string,
897
1014
  thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
@@ -904,7 +1021,7 @@ async function classifyWithModel(
904
1021
  const failures: string[] = [];
905
1022
  for (const [n, maxTokens] of attempts) {
906
1023
  if (ctx.signal?.aborted) break; // 用户已取消,不再重试
907
- const r = await callClassifierOnce(ctx, model, userMessage, maxTokens, thinking, systemPrompt);
1024
+ const r = await callClassifierOnce(ctx, complete, model, userMessage, maxTokens, thinking, systemPrompt);
908
1025
  if (r.ok) {
909
1026
  const diag = `stopReason=${r.stopReason}, model=${model.id}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
910
1027
  if (r.stopReason !== "error" && r.stopReason !== "aborted") {
@@ -1044,7 +1161,12 @@ function shadowTag(probe: ShadowProbe): string {
1044
1161
  // 扩展主体
1045
1162
  // ============================================================================
1046
1163
 
1047
- export default function autoMode(pi: ExtensionAPI) {
1164
+ /** Optional dependency injection for tests (#35): fake the compat fallback loader. */
1165
+ export interface AutoModeDeps {
1166
+ compatLoader?: CompatLoader;
1167
+ }
1168
+
1169
+ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1048
1170
  pi.registerFlag("auto-mode", { description: "Enable Auto Mode (rules + model classifier gating for tool calls)", type: "boolean", default: true });
1049
1171
  pi.registerFlag("auto-mode-model", { description: "Classifier model as provider/id[:thinking] (pi --model syntax; default: inherit session model)", type: "string" });
1050
1172
  pi.registerFlag("auto-mode-debug", { description: "Notify every verdict incl. allows, with shadow-cache annotation", type: "boolean", default: false });
@@ -1063,15 +1185,9 @@ export default function autoMode(pi: ExtensionAPI) {
1063
1185
  return denyPathBases;
1064
1186
  };
1065
1187
 
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);
1188
+ // Self-protection layer (ADR-0001): self-anchored protected set + tamper
1189
+ // baseline (in-memory, per session)
1190
+ const prot = buildProtectedSet(agentDirPath(), OWN_FILE_PATH);
1075
1191
  let snapshots = takeSnapshots(prot.watchBases);
1076
1192
  let tampered = false;
1077
1193
 
@@ -1303,7 +1419,7 @@ export default function autoMode(pi: ExtensionAPI) {
1303
1419
  const ctxKey = shadowContextKey(ctx);
1304
1420
  const probe = shadow.probe(cmdKey, ctxKey);
1305
1421
 
1306
- const outcome = await classifyWithModel(ctx, model, action, classifierThinking, userRules.denyPaths.length > 0);
1422
+ const outcome = await classifyWithModel(ctx, completionFor(ctx.modelRegistry, deps.compatLoader), model, action, classifierThinking, userRules.denyPaths.length > 0);
1307
1423
 
1308
1424
  // 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
1309
1425
  // 命中且本次为可缓存裁决时,对比反事实一致性
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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",