pi-verdict 0.2.1 → 0.2.3

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
@@ -6,73 +6,38 @@
6
6
  [![npm](https://img.shields.io/npm/v/pi-verdict)](https://www.npmjs.com/package/pi-verdict)
7
7
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://pi.dev)
8
8
 
9
- > Pi runs YOLO by default: every tool call executes without asking.
10
- > **verdict gives each call a three-state adjudication — `allow / ask / deny`.**
11
- > A built-in deny floor plus your own allow/deny rules first; everything else goes to a model classifier that sees the conversation context; every failure mode fails closed.
9
+ **pi-verdict is a 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.**
12
10
 
13
- **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".
14
-
15
- ## How it compares
11
+ - Built-in danger rules and your own allow/deny rules settle the clear cases first, at zero latency
12
+ - Everything else goes to a model classifier that sees the conversation context
13
+ - Any uncertainty or failure fails closed; nothing ever runs silently
14
+ - Minimal — just a few hundred lines of code
16
15
 
17
- | | three-state verdict | classifier sees context | fail direction | runtime deps |
18
- |---|---|---|---|---|
19
- | **pi-verdict** | ✅ allow / ask / deny | ✅ recent user intent + tool calls | **closed** (errors/timeout/bad output → deny; headless ask → deny) | **0** |
20
- | [@czottmann/pi-automode](https://github.com/czottmann/pi-automode) | rules 3-state, classifier 2-state | ✅ budgeted transcript | closed | 1 |
21
- | [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅ (outcome) | ❌ single-turn, no context | closed (→ ask) | 4 |
22
- | [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ deterministic only | — (no built-in classifier) | closed | 3 |
23
-
24
- 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).
16
+ ## The problem
25
17
 
26
- Honest framing: pi-automode and pi-verdict have **converged on the same architecture** (deny floor → user rules → classifier, fail-closed — see the convergence analysis). What remains distinct here: a classifier that can say `ask` (runtime human-in-the-loop, not just rule-declared), a built-in floor you can turn off (`builtinDenyFloor` user sovereignty), a zero-dependency single file (~700 lines, deliberate), and the measurement habit every design decision in this repo is backed by shipped research.
18
+ pi has no built-in permission promptsevery tool call executes with the permissions of the pi process ([pi security docs](https://pi.dev/docs/latest/security)).
27
19
 
28
- The single-file, zero-dependency shape is deliberate the whole extension is one readable [~700-line file](extensions/auto-mode.ts).
20
+ pi-verdict adds the missing gate: a model decides whether each call should run, based on the conversation context and your intent.
29
21
 
30
- ## Pipeline
22
+ ## Why three states
31
23
 
32
- ```
33
- tool_call
34
-
35
- ├─ 1. Rule layer (deterministic, zero latency)
36
- │ ├─ built-in deny floor: bash danger regexes (full-string) +
37
- │ │ path sensitivity S0–S5 (secrets/system/.git meta → deny)
38
- │ ├─ your rules: user deny beats user allow (regex, see below)
39
- │ └─ no built-in allowlist — every "always allow" claim is yours to make
40
-
41
- ├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
42
- │ ├─ input: CC-style <transcript> (last 5 user messages + last 10 tool calls,
43
- │ │ action under review always last) — user intent is evidence
44
- │ ├─ output contract: <verdict>allow|ask|deny</verdict> prefix-anchored
45
- │ ├─ thinking explicitly disabled (thinkingEnabled: false) + retry 512→1024
46
- │ └─ configurable via --auto-mode-model
47
-
48
- └─ 3. Three-state adjudication
49
- ├─ allow → pass
50
- ├─ deny → block, reason returned to the agent
51
- └─ ask → human confirm (ctx.ui.confirm); non-interactive modes degrade to deny
52
-
53
- [shadow cache] (observe-only, runs alongside 2/3, never changes a verdict)
54
- replays a double-key LRU(128) to measure would-be hit rate
55
- ```
56
-
57
- **fail-closed**: classifier exception / timeout (15s) / contract violation → deny. Never silently allow.
24
+ **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".
58
25
 
59
26
  ## Quick start
60
27
 
61
28
  ```bash
62
- # install from npm (after the first release is published)
29
+ # install from npm (listed in the package catalog: https://pi.dev/packages/pi-verdict)
63
30
  pi install npm:pi-verdict
64
31
 
65
32
  # or directly from git — try it once
66
33
  pi --extension ./extensions/auto-mode.ts
67
34
 
68
- # or install globally (auto-discovered)
69
- cp extensions/auto-mode.ts ~/.pi/agent/extensions/
70
35
  ```
71
36
 
72
- - `/automode` — status (read-only): on/off + shadow-cache stats for the session
73
- - `/automode on` / `/automode off` — idempotent set; unknown args are rejected with usage
37
+ - `/automode` — show current status: on/off + shadow-cache stats for the session
38
+ - `/automode on`
39
+ - `/automode off`
74
40
  - footer always shows `auto mode on` (highlighted) / `auto mode off` (dimmed)
75
- - `pi --auto-mode-debug` — notify every verdict incl. allows, with shadow-cache annotations
76
41
 
77
42
  | Option | Default | Description |
78
43
  |---|---|---|
@@ -97,13 +62,57 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
97
62
  - matched against the **full command string** for bash, the **absolute path** for file tools (read/write/edit/grep/find/ls); other tools (MCP etc.) always go to the classifier
98
63
  - `builtinDenyFloor: false` turns the built-in danger/path floor off entirely (risk accepted by you; the classifier and your rules remain)
99
64
  - `classifierModel: "provider/model-id"` persistently 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
100
- - the spec accepts pi's native `--model` thinking suffix: `"zai/glm-4-flash:low"` sets classifier thinking to effort low (default without suffix: thinking explicitly off — the [measured](research/thinking-param-blackhole.md) default)
65
+ - the spec accepts pi's native `--model` thinking suffix: `"zai/glm-5.3-flash:low"` sets classifier thinking to effort low (default without suffix: thinking explicitly off — the [measured](research/thinking-param-blackhole.md) default)
101
66
  - first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`); changes apply to new sessions
102
67
 
103
68
  Why no built-in allowlist? A third-party security audit ([`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md)) showed that allowlist soundness requires shell AST analysis — every built-in "always allow" would be a security claim maintained by the author. The built-in layer only makes **deny** claims (the sound direction); allow claims are yours.
104
69
 
105
70
  Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) sessions; in non-interactive modes `ask` degrades to `deny`.
106
71
 
72
+ ## How it compares
73
+
74
+ | | three-state verdict | classifier sees context | fail direction | runtime deps |
75
+ |---|---|---|---|---|
76
+ | **pi-verdict** | ✅ allow / ask / deny | ✅ recent user intent + tool calls | **closed** (errors/timeout/bad output → deny; headless ask → deny) | **0** |
77
+ | [@czottmann/pi-automode](https://github.com/czottmann/pi-automode) | rules 3-state, classifier 2-state | ✅ budgeted transcript | closed | 1 |
78
+ | [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅ (outcome) | ❌ single-turn, no context | closed (→ ask) | 4 |
79
+ | [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ deterministic only | — (no built-in classifier) | closed | 3 |
80
+
81
+ Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · convergence analysis with the closest architectural relative: [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md).
82
+
83
+ Honest framing: pi-automode and pi-verdict have **converged on the same architecture** (deny floor → user rules → classifier, fail-closed — see the convergence analysis). What remains distinct here: a classifier that can say `ask` (runtime human-in-the-loop, not just rule-declared), a built-in floor you can turn off (`builtinDenyFloor` — user sovereignty), a zero-dependency single file (~700 lines, deliberate), and the measurement habit — every design decision in this repo is backed by shipped research.
84
+
85
+ The single-file, zero-dependency shape is deliberate — the whole extension is one readable [~700-line file](extensions/auto-mode.ts).
86
+
87
+ ## Pipeline
88
+
89
+ ```
90
+ tool_call
91
+
92
+ ├─ 1. Rule layer (deterministic, zero latency)
93
+ │ ├─ built-in deny floor: bash danger regexes (full-string) +
94
+ │ │ path sensitivity S0–S5 (secrets/system/.git meta → deny)
95
+ │ ├─ your rules: user deny beats user allow (regex, see below)
96
+ │ └─ no built-in allowlist — every "always allow" claim is yours to make
97
+
98
+ ├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
99
+ │ ├─ input: CC-style <transcript> (last 5 user messages + last 10 tool calls,
100
+ │ │ action under review always last) — user intent is evidence
101
+ │ ├─ output contract: <verdict>allow|ask|deny</verdict> prefix-anchored
102
+ │ ├─ thinking explicitly disabled (thinkingEnabled: false) + retry 512→1024
103
+ │ └─ configurable via --auto-mode-model
104
+
105
+ └─ 3. Three-state adjudication
106
+ ├─ allow → pass
107
+ ├─ deny → block, reason returned to the agent
108
+ └─ ask → human confirm (ctx.ui.confirm); non-interactive modes degrade to deny
109
+
110
+ [shadow cache] (observe-only, runs alongside 2/3, never changes a verdict)
111
+ replays a double-key LRU(128) to measure would-be hit rate
112
+ ```
113
+
114
+ **fail-closed**: classifier exception / timeout (15s) / contract violation → deny. Never silently allow.
115
+
107
116
  ## Evidence-driven, not vibes-driven
108
117
 
109
118
  Design decisions here are settled by measurement, and the lab notes ship with the repo:
@@ -135,7 +144,7 @@ The name: the three-state **verdict** is the core concept. The UX keeps `/automo
135
144
  ```bash
136
145
  bun install
137
146
  bun run typecheck
138
- bun test # 36 offline stub tests: deny floor, user rules, audit regression, classifier retry, shadow cache, commands
147
+ bun test # 42 offline stub tests: deny floor, user rules, audit regression, classifier retry, shadow cache, commands
139
148
  ```
140
149
 
141
150
  Issue tracker and decision records live in the GitHub issues ("map" issue #1 indexes them).
package/README.zh-CN.md CHANGED
@@ -6,72 +6,38 @@
6
6
  [![npm](https://img.shields.io/npm/v/pi-verdict)](https://www.npmjs.com/package/pi-verdict)
7
7
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://pi.dev)
8
8
 
9
- > Pi 默认以 YOLO 模式运行:所有工具调用不经确认直接执行。
10
- > **verdict 给每次调用一个三态裁决 —— `allow / ask / deny`。**
11
- > 内置 deny floor 与你的 allow/deny 规则先行;其余交给携带会话上下文的模型分类器;任何失败路径一律 fail-closed。
9
+ **pi-verdict [pi](https://pi.dev) Claude Code 的 Auto mode 式的权限门禁:每次工具调用执行前先过检查——放行、拦截,或先问你。**
12
10
 
13
- **verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」。
11
+ - 内置危险规则与你的 allow/deny 规则以零延迟先行裁决明确情形
12
+ - 其余交给携带会话上下文的模型分类器
13
+ - 任何不确定或失败一律 fail-closed, 绝不静默放行
14
+ - 只有几百行的极简代码
14
15
 
15
- ## 与品类对比
16
+ ## 问题
16
17
 
17
- | | 三态裁决 | 分类器携带上下文 | fail 方向 | 运行时依赖 |
18
- |---|---|---|---|---|
19
- | **pi-verdict** | ✅ allow / ask / deny | ✅ 近期用户意图 + 工具调用 | **closed**(异常/超时/违约 → deny;非交互 ask → deny) | **0** |
20
- | [@czottmann/pi-automode](https://github.com/czottmann/pi-automode) | 规则三态,分类器二态 | ✅ 预算化 transcript | closed | 1 |
21
- | [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅(outcome) | ❌ 单轮无上下文 | closed(→ ask) | 4 |
22
- | [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ 纯确定性 | —(无内置分类器) | closed | 3 |
18
+ pi 没有内置的逐次权限确认——每次工具调用都以 pi 进程自身的权限直接执行([pi 安全文档](https://pi.dev/docs/latest/security))。
23
19
 
24
- 完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
25
-
26
- 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、零依赖单文件(~700 行,刻意为之)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
27
-
28
- 零依赖单文件形态是有意为之——整个扩展就是一个可通读的 [~700 行文件](extensions/auto-mode.ts)。
29
-
30
- ## 管线
31
-
32
- ```
33
- tool_call
34
-
35
- ├─ 1. 规则层(确定性,零延迟)
36
- │ ├─ 内置 deny floor:bash 危险正则(完整命令串)+ 路径敏感度 S0–S5
37
- │ ├─ 用户规则:deny 优先于 allow(正则,见下)
38
- │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
39
-
40
- ├─ 2. 灰区 → 模型分类器(默认继承会话模型 —— "自省")
41
- │ ├─ 输入:CC 风格 <transcript>(最近 5 条用户消息 + 最近 10 次工具调用,
42
- │ │ 待审动作固定在末尾)—— 用户意图是证据
43
- │ ├─ 输出契约:<verdict>allow|ask|deny</verdict> 前缀锚定
44
- │ ├─ 显式关思考(thinkingEnabled: false)+ 两档重试 512→1024
45
- │ └─ 可用 --auto-mode-model 配置
46
-
47
- └─ 3. 三态裁决
48
- ├─ allow → 放行
49
- ├─ deny → 拦截,理由回传 agent
50
- └─ ask → 人工确认(ctx.ui.confirm);非交互模式降级为 deny
20
+ pi-verdict 补上这道缺失的门禁, 由模型基于上下文和你的意图判定是否可以运行.
51
21
 
52
- [影子缓存](observe-only,与 2/3 并行,永不改变裁决)
53
- 回放双键 LRU(128)测量 would-be 命中率
54
- ```
22
+ ## 为什么是三态
55
23
 
56
- **fail-closed**:分类器异常 / 超时(15s)/ 输出违反契约 → 拦截,绝不静默放行。
24
+ **verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」。
57
25
 
58
26
  ## 快速开始
59
27
 
60
28
  ```bash
61
- # 从 npm 安装(首发版本发布后可用)
29
+ # 从 npm 安装(已收录 pi 官方包目录: https://pi.dev/packages/pi-verdict)
62
30
  pi install npm:pi-verdict
63
31
 
64
32
  # 或直接从源码 —— 试用一次
65
33
  pi --extension ./extensions/auto-mode.ts
66
34
 
67
- # 或全局安装(自动发现)
68
- cp extensions/auto-mode.ts ~/.pi/agent/extensions/
69
35
  ```
70
36
 
71
- - `/automode` —— 只读状态:开/关 + 本会话影子缓存统计
72
- - `/automode on` / `/automode off` —— 幂等设定;未知参数严格拒绝并列出用法
37
+ - `/automode` —— 显示当前状态:开/关 + 本会话影子缓存统计
38
+ - `/automode on`
39
+ - `/automode off`
73
40
  - footer 恒显 `auto mode on`(高亮)/ `auto mode off`(暗色)
74
- - `pi --auto-mode-debug` —— 全量裁决通知(含放行),附影子缓存标注
75
41
 
76
42
  | 配置 | 默认 | 说明 |
77
43
  |---|---|---|
@@ -96,13 +62,56 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
96
62
  - 匹配目标:bash = **完整命令串**;文件类工具(read/write/edit/grep/find/ls)= **绝对路径**;其余工具(MCP 等)恒走分类器
97
63
  - `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在)
98
64
  - `classifierModel: "provider/model-id"` 持久指定分类器模型(如轻量 flash 类);优先级 flag > env > config > 自省;无效值回退会话模型并一次性警告
99
- - spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-4-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考,[实测](research/thinking-param-blackhole.md)背书的默认)
65
+ - spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-5.3-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考,[实测](research/thinking-param-blackhole.md)背书的默认)
100
66
  - 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`);修改后新会话生效
101
67
 
102
68
  为什么没有内置白名单?第三方安全审计(见 [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健全性需要 shell AST 分析——每条内置「永远放行」都是作者维护的安全声明。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
103
69
 
104
70
  需要 pi ≥ 0.84。交互与非交互(`-p`/json/rpc)会话均支持;非交互模式下 `ask` 降级为 `deny`。
105
71
 
72
+ ## 与品类对比
73
+
74
+ | | 三态裁决 | 分类器携带上下文 | fail 方向 | 运行时依赖 |
75
+ |---|---|---|---|---|
76
+ | **pi-verdict** | ✅ allow / ask / deny | ✅ 近期用户意图 + 工具调用 | **closed**(异常/超时/违约 → deny;非交互 ask → deny) | **0** |
77
+ | [@czottmann/pi-automode](https://github.com/czottmann/pi-automode) | 规则三态,分类器二态 | ✅ 预算化 transcript | closed | 1 |
78
+ | [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅(outcome) | ❌ 单轮无上下文 | closed(→ ask) | 4 |
79
+ | [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ 纯确定性 | —(无内置分类器) | closed | 3 |
80
+
81
+ 完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
82
+
83
+ 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、零依赖单文件(~700 行,刻意为之)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
84
+
85
+ 零依赖单文件形态是有意为之——整个扩展就是一个可通读的 [~700 行文件](extensions/auto-mode.ts)。
86
+
87
+ ## 管线
88
+
89
+ ```
90
+ tool_call
91
+
92
+ ├─ 1. 规则层(确定性,零延迟)
93
+ │ ├─ 内置 deny floor:bash 危险正则(完整命令串)+ 路径敏感度 S0–S5
94
+ │ ├─ 用户规则:deny 优先于 allow(正则,见下)
95
+ │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
96
+
97
+ ├─ 2. 灰区 → 模型分类器(默认继承会话模型 —— "自省")
98
+ │ ├─ 输入:CC 风格 <transcript>(最近 5 条用户消息 + 最近 10 次工具调用,
99
+ │ │ 待审动作固定在末尾)—— 用户意图是证据
100
+ │ ├─ 输出契约:<verdict>allow|ask|deny</verdict> 前缀锚定
101
+ │ ├─ 显式关思考(thinkingEnabled: false)+ 两档重试 512→1024
102
+ │ └─ 可用 --auto-mode-model 配置
103
+
104
+ └─ 3. 三态裁决
105
+ ├─ allow → 放行
106
+ ├─ deny → 拦截,理由回传 agent
107
+ └─ ask → 人工确认(ctx.ui.confirm);非交互模式降级为 deny
108
+
109
+ [影子缓存](observe-only,与 2/3 并行,永不改变裁决)
110
+ 回放双键 LRU(128)测量 would-be 命中率
111
+ ```
112
+
113
+ **fail-closed**:分类器异常 / 超时(15s)/ 输出违反契约 → 拦截,绝不静默放行。
114
+
106
115
  ## 证据驱动,不靠直觉
107
116
 
108
117
  这里的设计决策用测量收敛,实验记录随仓库发布:
@@ -134,7 +143,7 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
134
143
  ```bash
135
144
  bun install
136
145
  bun run typecheck
137
- bun test # 36 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
146
+ bun test # 42 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
138
147
  ```
139
148
 
140
149
  Issue tracker 与决策记录在 GitHub issues(「地图」issue #1 为索引)。
@@ -53,20 +53,20 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
53
53
 
54
54
  /** 危险模式:对完整命令串匹配(覆盖管道/复合命令),命中即 deny(源自研究报告 §4.3) */
55
55
  const BASH_DANGER_RULES: Array<{ id: string; pattern: RegExp; reason: string }> = [
56
- { id: "rm-recursive", pattern: /\brm\b[^;|&]*(\s-(?:[a-zA-Z]*r[a-zA-Z]*f?|[a-zA-Z]*f[a-zA-Z]*r)\b|--recursive)/i, reason: "递归删除 (rm -r)" },
57
- { id: "rm-root", pattern: /\brm\s+(-[a-zA-Z]*\s+)*(--recursive\s+)?(\/|\/etc|\/usr|\/var|~|\$HOME)(?:\s|$)/i, reason: "删除根/系统/家目录" },
58
- { id: "sudo", pattern: /\bsudo\b/i, reason: "提权 (sudo)" },
59
- { id: "chmod-777", pattern: /\bchmod\b[^;|&]*(777|a\+rwx|ugo\+rwx|ugo=rwx|[ug]\+s)\b/i, reason: "权限弱化 (chmod 777/setuid)" },
60
- { id: "raw-device", pattern: /(>\s*\/dev\/(sd|hd|nvme|mmcblk|vd|xvd)|of=\/dev\/(sd|hd|nvme|mmcblk|vd|xvd)|\bmkfs\.)/i, reason: "裸设备写/格式化" },
56
+ { id: "rm-recursive", pattern: /\brm\b[^;|&]*(\s-(?:[a-zA-Z]*r[a-zA-Z]*f?|[a-zA-Z]*f[a-zA-Z]*r)\b|--recursive)/i, reason: "recursive delete (rm -r)" },
57
+ { id: "rm-root", pattern: /\brm\s+(-[a-zA-Z]*\s+)*(--recursive\s+)?(\/|\/etc|\/usr|\/var|~|\$HOME)(?:\s|$)/i, reason: "delete root/system/home directory" },
58
+ { id: "sudo", pattern: /\bsudo\b/i, reason: "privilege escalation (sudo)" },
59
+ { id: "chmod-777", pattern: /\bchmod\b[^;|&]*(777|a\+rwx|ugo\+rwx|ugo=rwx|[ug]\+s)\b/i, reason: "permission weakening (chmod 777/setuid)" },
60
+ { id: "raw-device", pattern: /(>\s*\/dev\/(sd|hd|nvme|mmcblk|vd|xvd)|of=\/dev\/(sd|hd|nvme|mmcblk|vd|xvd)|\bmkfs\.)/i, reason: "raw device write/format" },
61
61
  { id: "git-push-force", pattern: /\bgit\s+push\b[^;|&]*(-f\b|--force\b)/i, reason: "git push --force" },
62
62
  { id: "git-reset-hard", pattern: /\bgit\s+reset\s+--hard\b/i, reason: "git reset --hard" },
63
63
  { id: "git-clean-force", pattern: /\bgit\s+clean\b[^;|&]*(\s-[a-zA-Z]*f|--force)/i, reason: "git clean -f" },
64
- { id: "git-checkout-dot", pattern: /\bgit\s+checkout\s+(--\s+)?\.(?:\s|$)/i, reason: "git checkout . (丢弃工作区)" },
65
- { id: "git-restore", pattern: /\bgit\s+restore\b/i, reason: "git restore (丢弃修改)" },
66
- { id: "remote-exec", pattern: /\b(curl|wget)\b[^;|&]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/i, reason: "远程代码执行 (curl|sh)" },
67
- { id: "gh-repo", pattern: /\bgh\s+repo\s+(create|delete|rename|archive)\b/i, reason: "GitHub 仓库级变更" },
68
- { id: "gh-release", pattern: /\bgh\s+release\s+(create|delete|edit)\b/i, reason: "GitHub release 变更" },
69
- { id: "fork-bomb", pattern: /:\(\)\s*\{/, reason: "fork 炸弹" },
64
+ { id: "git-checkout-dot", pattern: /\bgit\s+checkout\s+(--\s+)?\.(?:\s|$)/i, reason: "git checkout . (discard working tree)" },
65
+ { id: "git-restore", pattern: /\bgit\s+restore\b/i, reason: "git restore (discard changes)" },
66
+ { id: "remote-exec", pattern: /\b(curl|wget)\b[^;|&]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/i, reason: "remote code execution (curl|sh)" },
67
+ { id: "gh-repo", pattern: /\bgh\s+repo\s+(create|delete|rename|archive)\b/i, reason: "GitHub repository-level change" },
68
+ { id: "gh-release", pattern: /\bgh\s+release\s+(create|delete|edit)\b/i, reason: "GitHub release change" },
69
+ { id: "fork-bomb", pattern: /:\(\)\s*\{/, reason: "fork bomb" },
70
70
  ];
71
71
 
72
72
  type RuleVerdict = "allow" | "deny" | "gray";
@@ -79,12 +79,12 @@ function classifyBash(command: string, floorOn: boolean): RuleResult {
79
79
  // 内置 deny floor:危险正则对完整命令串匹配;可经 builtinDenyFloor 整体关闭
80
80
  if (floorOn) {
81
81
  for (const rule of BASH_DANGER_RULES) {
82
- if (rule.pattern.test(command)) return { verdict: "deny", reason: `规则 ${rule.id}: ${rule.reason}` };
82
+ if (rule.pattern.test(command)) return { verdict: "deny", reason: `rule ${rule.id}: ${rule.reason}` };
83
83
  }
84
84
  }
85
- if (!command.trim()) return { verdict: "allow", reason: "空命令" };
85
+ if (!command.trim()) return { verdict: "allow", reason: "empty command" };
86
86
  // 无内置白名单(#12):一切非危险命令交用户规则与分类器
87
- return { verdict: "gray", reason: "无内置白名单" };
87
+ return { verdict: "gray", reason: "no built-in allowlist" };
88
88
  }
89
89
 
90
90
  // ============================================================================
@@ -115,7 +115,7 @@ function userConfigPath(): string {
115
115
  }
116
116
 
117
117
  const USER_CONFIG_TEMPLATE = `${JSON.stringify({
118
- _hint: "pi-verdict 用户规则。allow/deny JS 正则数组;deny 优先于 allow;匹配目标:bash=完整命令串,文件工具=绝对路径。builtinDenyFloor=false 可关闭内置危险规则/路径敏感度拦截(风险自担)classifierModel 可持久指定分类器模型(provider/id,如 zai/glm-4-flash;可带 pi 原生思考后缀如 zai/glm-4-flash:low;留空=自省继承会话模型)。修改后新会话生效。",
118
+ _hint: "pi-verdict user rules. allow/deny are JS regex arrays; deny wins over allow. Match target: bash = full command string, file tools = absolute path. builtinDenyFloor=false disables the built-in danger/path floor (at your own risk). classifierModel persistently sets the classifier model (provider/id, e.g. zai/glm-5.3-flash; accepts a pi-native thinking suffix, e.g. zai/glm-5.3-flash:low; empty = self-reflection, inherit session model). Changes apply to new sessions.",
119
119
  allow: ["^ls\\b"],
120
120
  deny: [],
121
121
  builtinDenyFloor: true,
@@ -189,16 +189,16 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
189
189
  ? (reason: string): RuleResult => ({ verdict: "deny", reason })
190
190
  : (reason: string): RuleResult => ({ verdict: "gray", reason });
191
191
 
192
- if (hit(S0_SECRET)) return D(`S0 密钥/凭证路径: ${rawPath}`);
192
+ if (hit(S0_SECRET)) return D(`S0 secrets/credential path: ${rawPath}`);
193
193
  if (!isWrite) {
194
- if (hit(S1_SYSTEM)) return { verdict: "gray", reason: `读取系统配置路径: ${rawPath}` };
194
+ if (hit(S1_SYSTEM)) return { verdict: "gray", reason: `read system config path: ${rawPath}` };
195
195
  return { verdict: "allow" };
196
196
  }
197
- if (hit(S1_SYSTEM)) return D(`写入系统目录: ${rawPath}`);
198
- if (hit(S3_GIT_META)) return D(`写入 .git 元数据(可执行代码入口): ${rawPath}` );
199
- if (hit(S2_USER_RC)) return { verdict: "gray", reason: `写入用户配置/持久化入口: ${rawPath}` };
197
+ if (hit(S1_SYSTEM)) return D(`write to system directory: ${rawPath}`);
198
+ if (hit(S3_GIT_META)) return D(`write to .git metadata (executable code entry point): ${rawPath}` );
199
+ if (hit(S2_USER_RC)) return { verdict: "gray", reason: `write to user config/persistence entry point: ${rawPath}` };
200
200
  if (abs === cwd || abs.startsWith(cwd + path.sep)) return { verdict: "allow" };
201
- return { verdict: "gray", reason: `写入项目目录(CWD)外: ${rawPath}` };
201
+ return { verdict: "gray", reason: `write outside project directory (CWD): ${rawPath}` };
202
202
  }
203
203
 
204
204
  /** 用户规则匹配目标:bash/powershell=完整命令串;路径类工具=解析后绝对路径;其余工具不参与 */
@@ -250,17 +250,17 @@ function classifyByRules(toolName: string, input: Record<string, unknown>, cwd:
250
250
  break;
251
251
  }
252
252
  default:
253
- base = { verdict: "gray", reason: `未内置规则覆盖的工具: ${toolName}` };
253
+ base = { verdict: "gray", reason: `tool not covered by built-in rules: ${toolName}` };
254
254
  }
255
255
  if (base.verdict === "deny") return base; // 内置 floor:deny 优先于一切用户规则
256
256
 
257
257
  const target = userRuleTarget(toolName, input, cwd);
258
258
  if (target !== null) {
259
259
  for (const re of user.deny) {
260
- if (re.test(target)) return { verdict: "deny", reason: `用户黑名单: ${re.source}` };
260
+ if (re.test(target)) return { verdict: "deny", reason: `user deny rule: ${re.source}` };
261
261
  }
262
262
  for (const re of user.allow) {
263
- if (re.test(target)) return { verdict: "allow", reason: "用户白名单" };
263
+ if (re.test(target)) return { verdict: "allow", reason: "user allow rule" };
264
264
  }
265
265
  }
266
266
  return base;
@@ -415,19 +415,19 @@ async function classifyWithModel(
415
415
  if (ctx.signal?.aborted) break; // 用户已取消,不再重试
416
416
  const r = await callClassifierOnce(ctx, model, userMessage, maxTokens, thinking);
417
417
  if (r.ok) {
418
- const diag = `stopReason=${r.stopReason}, model=${model.id}, 原始输出=${JSON.stringify(r.text.slice(0, 200))}`;
418
+ const diag = `stopReason=${r.stopReason}, model=${model.id}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
419
419
  if (r.stopReason !== "error" && r.stopReason !== "aborted") {
420
420
  const parsed = parseVerdict(r.text);
421
421
  if (parsed) return { ...parsed, source: "model" };
422
- failures.push(`第${n}(${maxTokens}t)输出违反契约: ${diag}`);
422
+ failures.push(`attempt ${n} (${maxTokens}t) contract violation: ${diag}`);
423
423
  } else {
424
- failures.push(`第${n}(${maxTokens}t)中止/出错: ${diag}`);
424
+ failures.push(`attempt ${n} (${maxTokens}t) aborted/errored: ${diag}`);
425
425
  }
426
426
  } else {
427
- failures.push(`第${n}(${maxTokens}t)异常: ${r.error}`);
427
+ failures.push(`attempt ${n} (${maxTokens}t) exception: ${r.error}`);
428
428
  }
429
429
  }
430
- return { verdict: "deny", reason: `分类器失败(fail-closed): ${failures.join("; ")}`, source: "fail-closed" };
430
+ return { verdict: "deny", reason: `classifier failure (fail-closed): ${failures.join("; ")}`, source: "fail-closed" };
431
431
  }
432
432
 
433
433
  // ============================================================================
@@ -528,9 +528,9 @@ class ShadowCache {
528
528
  /** /automode 展示用摘要 */
529
529
  summary(): string {
530
530
  const s = this.stats;
531
- if (s.gray === 0) return "影子缓存: 本会话暂无灰区裁决";
531
+ if (s.gray === 0) return "shadow cache: no gray-zone verdicts yet this session";
532
532
  const rate = ((100 * s.hits) / s.gray).toFixed(1);
533
- return `影子缓存: 灰区 ${s.gray} · 双键命中 ${s.hits} (${rate}%) · miss 无条目 ${s.missNoEntry}/上下文变 ${s.missCtx} · 命令重复 ${s.cmdRepeats} · 分歧 危险 ${s.divergeDangerous}/保守 ${s.divergeConservative}`;
533
+ return `shadow cache: gray ${s.gray} · two-key hits ${s.hits} (${rate}%) · miss no-entry ${s.missNoEntry}/ctx-changed ${s.missCtx} · cmd repeats ${s.cmdRepeats} · divergence dangerous ${s.divergeDangerous}/conservative ${s.divergeConservative}`;
534
534
  }
535
535
  }
536
536
 
@@ -544,9 +544,9 @@ function shadowContextKey(ctx: ExtensionContext): string {
544
544
  }
545
545
 
546
546
  function shadowTag(probe: ShadowProbe): string {
547
- if (probe.result === "hit") return `(影子缓存: would-hit ${probe.entry.verdict})`;
548
- if (probe.result === "ctx-changed") return `(影子缓存: miss:context-changed, 原裁决 ${probe.prevVerdict})`;
549
- return `(影子缓存: miss:no-entry)`;
547
+ if (probe.result === "hit") return `(shadow cache: would-hit ${probe.entry.verdict})`;
548
+ if (probe.result === "ctx-changed") return `(shadow cache: miss:context-changed, previous ${probe.prevVerdict})`;
549
+ return `(shadow cache: miss:no-entry)`;
550
550
  }
551
551
 
552
552
  // ============================================================================
@@ -574,7 +574,7 @@ export default function autoMode(pi: ExtensionAPI) {
574
574
  const loaded = loadUserRules();
575
575
  userRules = loaded.rules;
576
576
  if (loaded.skipped.length > 0) {
577
- ctx.ui.notify(`pi-verdict:配置中 ${loaded.skipped.length} 条非法正则已跳过(${userConfigPath()})`, "warning");
577
+ ctx.ui.notify(`pi-verdict: skipped ${loaded.skipped.length} invalid regex(es) in config (${userConfigPath()})`, "warning");
578
578
  }
579
579
  refreshStatus(ctx);
580
580
  });
@@ -585,7 +585,7 @@ export default function autoMode(pi: ExtensionAPI) {
585
585
  const arg = args.trim().toLowerCase();
586
586
  // 裸调用:只读状态展示,无副作用(含影子缓存统计行)
587
587
  if (arg === "") {
588
- ctx.ui.notify(`${enabled ? "🛡️ Auto Mode:开启" : "Auto Mode:关闭"}\n${shadow.summary()}\n用法: /automode on|off`, "info");
588
+ ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${shadow.summary()}\nUsage: /automode on|off`, "info");
589
589
  return;
590
590
  }
591
591
  // 幂等设定:与现值相同不翻转,仅确认
@@ -595,13 +595,13 @@ export default function autoMode(pi: ExtensionAPI) {
595
595
  enabled = next;
596
596
  refreshStatus(ctx);
597
597
  const head = next
598
- ? `🛡️ Auto Mode 已开启${changed ? "" : "(未变化)"}:工具调用由规则+分类器自动裁决`
599
- : `Auto Mode 已关闭${changed ? "" : "(未变化)"}:工具调用直接执行`;
598
+ ? `🛡️ Auto Mode enabled${changed ? "" : " (unchanged)"}: tool calls adjudicated by rules + classifier`
599
+ : `Auto Mode disabled${changed ? "" : " (unchanged)"}: tool calls execute directly`;
600
600
  ctx.ui.notify(`${head}\n${shadow.summary()}`, "info");
601
601
  return;
602
602
  }
603
603
  // 未知参数:严格拒绝并列出用法(大小写已归一化)
604
- ctx.ui.notify(`未知参数: ${arg}\n用法: /automode(查看状态)| /automode on | /automode off`, "warning");
604
+ ctx.ui.notify(`unknown argument: ${arg}\nUsage: /automode (status) | /automode on | /automode off`, "warning");
605
605
  },
606
606
  });
607
607
 
@@ -618,7 +618,7 @@ export default function autoMode(pi: ExtensionAPI) {
618
618
  }
619
619
  if (colon > slash + 1 && !warnedClassifierModel) {
620
620
  warnedClassifierModel = true;
621
- ctx.ui.notify(`pi-verdict:思考级别后缀 "${raw.slice(colon + 1)}" 无效(合法:${[...THINKING_LEVELS].join("/")}),已忽略`, "warning");
621
+ ctx.ui.notify(`pi-verdict: invalid thinking-level suffix "${raw.slice(colon + 1)}" (valid: ${[...THINKING_LEVELS].join("/")}), ignored`, "warning");
622
622
  }
623
623
  return { specPart: raw, level: null };
624
624
  }
@@ -641,7 +641,7 @@ export default function autoMode(pi: ExtensionAPI) {
641
641
  }
642
642
  if (!warnedClassifierModel) {
643
643
  warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
644
- ctx.ui.notify(`pi-verdict:分类器模型 "${raw}" 不可用(未找到或未配置凭证),回退会话模型(自省)`, "warning");
644
+ ctx.ui.notify(`pi-verdict: classifier model "${raw}" unavailable (not found or no configured auth), falling back to session model (self-reflection)`, "warning");
645
645
  }
646
646
  }
647
647
  return ctx.model ?? null; // 自省:继承当前会话模型
@@ -660,19 +660,19 @@ export default function autoMode(pi: ExtensionAPI) {
660
660
  // 第 1 层:规则
661
661
  const rule = classifyByRules(event.toolName, input, ctx.cwd, userRules);
662
662
  if (rule.verdict === "allow") {
663
- if (debug) ctx.ui.notify(`🛡️ allow(规则): ${action}`, "info");
663
+ if (debug) ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
664
664
  return undefined;
665
665
  }
666
666
  if (rule.verdict === "deny") {
667
- ctx.ui.notify(`🛡️ Auto Mode 拦截: ${rule.reason}\n ${action}`, "warning");
668
- return { block: true, reason: `[auto-mode 规则拦截] ${rule.reason}` };
667
+ ctx.ui.notify(`🛡️ Auto Mode blocked: ${rule.reason}\n ${action}`, "warning");
668
+ return { block: true, reason: `[auto-mode rule block] ${rule.reason}` };
669
669
  }
670
670
 
671
671
  // 第 2 层:灰区 → 模型分类器
672
672
  const model = resolveClassifierModel(ctx);
673
673
  if (!model) {
674
- ctx.ui.notify(`🛡️ Auto Mode 拦截: 无可用分类器模型(fail-closed)\n ${action}`, "warning");
675
- return { block: true, reason: "[auto-mode] 无可用分类器模型(fail-closed)" };
674
+ ctx.ui.notify(`🛡️ Auto Mode blocked: no classifier model available (fail-closed)\n ${action}`, "warning");
675
+ return { block: true, reason: "[auto-mode] no classifier model available (fail-closed)" };
676
676
  }
677
677
 
678
678
  // 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
@@ -690,21 +690,21 @@ export default function autoMode(pi: ExtensionAPI) {
690
690
  }
691
691
 
692
692
  if (outcome.verdict === "allow") {
693
- if (debug) ctx.ui.notify(`🛡️ allow(分类器): ${outcome.reason}\n ${action} ${shadowTag(probe)}`, "info");
693
+ if (debug) ctx.ui.notify(`🛡️ allow (classifier): ${outcome.reason}\n ${action} ${shadowTag(probe)}`, "info");
694
694
  return undefined;
695
695
  }
696
696
  if (outcome.verdict === "deny") {
697
- ctx.ui.notify(`🛡️ Auto Mode 拦截: ${outcome.reason}\n ${action}${debug ? " " + shadowTag(probe) : ""}`, "warning");
698
- return { block: true, reason: `[auto-mode 分类器拦截] ${outcome.reason}` };
697
+ ctx.ui.notify(`🛡️ Auto Mode blocked: ${outcome.reason}\n ${action}${debug ? " " + shadowTag(probe) : ""}`, "warning");
698
+ return { block: true, reason: `[auto-mode classifier block] ${outcome.reason}` };
699
699
  }
700
700
 
701
701
  // ask:转人工;非交互模式 fail-closed 降级为拦截
702
702
  if (!ctx.hasUI) {
703
- ctx.ui.notify(`🛡️ Auto Mode 拦截(非交互,ask→deny): ${outcome.reason}\n ${action}`, "warning");
704
- return { block: true, reason: `[auto-mode] 非交互模式下 ask 降级为拦截: ${outcome.reason}` };
703
+ ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, ask→deny): ${outcome.reason}\n ${action}`, "warning");
704
+ return { block: true, reason: `[auto-mode] ask degraded to block in non-interactive mode: ${outcome.reason}` };
705
705
  }
706
- const ok = await ctx.ui.confirm("🛡️ Auto Mode 需要确认", `${action}\n\n分类器意见: ${outcome.reason}\n\n允许执行?`);
706
+ const ok = await ctx.ui.confirm("🛡️ Auto Mode confirmation", `${action}\n\nClassifier opinion: ${outcome.reason}\n\nAllow execution?`);
707
707
  if (ok) return undefined;
708
- return { block: true, reason: "[auto-mode] 用户拒绝" };
708
+ return { block: true, reason: "[auto-mode] user declined" };
709
709
  });
710
710
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pi coding agent extension: automatic tool-call permission verdicts — rule layer + model classifier with three-state adjudication (allow/ask/deny), fail-closed, evidence-driven",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",
@@ -12,9 +12,13 @@
12
12
  "LICENSE"
13
13
  ],
14
14
  "keywords": [
15
+ "pi-package",
15
16
  "pi",
16
17
  "pi-extension",
18
+ "extension",
17
19
  "pi-coding-agent",
20
+ "auto-mode",
21
+ "automode",
18
22
  "permissions",
19
23
  "security",
20
24
  "tool-call",
@@ -28,6 +32,9 @@
28
32
  "homepage": "https://github.com/jesset/pi-verdict#readme",
29
33
  "bugs": "https://github.com/jesset/pi-verdict/issues",
30
34
  "license": "MIT",
35
+ "pi": {
36
+ "extensions": ["./extensions"]
37
+ },
31
38
  "scripts": {
32
39
  "typecheck": "tsc --noEmit -p tsconfig.json",
33
40
  "test": "bun test"