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 +48 -45
- package/README.zh-CN.md +48 -42
- package/extensions/auto-mode.ts +144 -28
- package/package.json +1 -1
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
|
|
72
|
-
|
|
73
|
-
- `
|
|
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
|
-
|
|
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 —
|
|
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
|
-
-
|
|
86
|
-
-
|
|
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 (
|
|
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
|
-
│
|
|
117
|
-
│
|
|
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
|
|
125
|
-
│
|
|
126
|
-
│
|
|
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>
|
|
136
|
-
│ │ action under review always last
|
|
137
|
-
│
|
|
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
|
|
151
|
+
└─ ask → human confirm; non-interactive modes degrade to deny
|
|
148
152
|
|
|
149
|
-
[shadow cache]
|
|
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`
|
|
72
|
-
|
|
73
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
106
|
+
门禁自身的文件——配置与扩展安装副本——**仅用户可改**:门禁之内的写入一律硬 deny(读放行);你的编辑器修改不经门禁,最近的同构先例是 sudoers 必须经 visudo。
|
|
84
107
|
|
|
85
|
-
- `
|
|
86
|
-
-
|
|
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)——门禁完整性)
|
|
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
|
-
│
|
|
117
|
-
│
|
|
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 危险正则
|
|
125
|
-
│
|
|
126
|
-
│ ├─
|
|
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>
|
|
134
|
-
│ │ 待审动作固定在末尾
|
|
135
|
-
│
|
|
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 →
|
|
151
|
+
└─ ask → 人工确认;非交互模式降级为 deny
|
|
145
152
|
|
|
146
|
-
[影子缓存]
|
|
147
|
-
回放双键 LRU(128)测量 would-be 命中率
|
|
153
|
+
[影子缓存] observe-only 遥测,与 2/3 并行,永不改变裁决
|
|
148
154
|
```
|
|
149
155
|
|
|
150
156
|
**fail-closed**:分类器异常 / 超时(25s)/ 输出违反契约 → 拦截,绝不静默放行。
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -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
|
|
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
|
|
473
|
-
//
|
|
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
|
-
*
|
|
521
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
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
|
-
: {
|
|
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
|
-
|
|
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
|
-
//
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
// 命中且本次为可缓存裁决时,对比反事实一致性
|