pi-verdict 0.2.2 → 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 +59 -50
- package/README.zh-CN.md +58 -49
- package/extensions/auto-mode.ts +53 -53
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,55 +6,22 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/pi-verdict)
|
|
7
7
|
[](https://pi.dev)
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
pi has no built-in permission prompts — every tool call executes with the permissions of the pi process ([pi security docs](https://pi.dev/docs/latest/security)).
|
|
27
19
|
|
|
28
|
-
|
|
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
|
-
##
|
|
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
|
|
|
@@ -65,14 +32,12 @@ pi install npm:pi-verdict
|
|
|
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
|
|
73
|
-
- `/automode on`
|
|
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-
|
|
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 #
|
|
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,54 +6,22 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/pi-verdict)
|
|
7
7
|
[](https://pi.dev)
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
11
|
+
- 内置危险规则与你的 allow/deny 规则以零延迟先行裁决明确情形
|
|
12
|
+
- 其余交给携带会话上下文的模型分类器
|
|
13
|
+
- 任何不确定或失败一律 fail-closed, 绝不静默放行
|
|
14
|
+
- 只有几百行的极简代码
|
|
14
15
|
|
|
15
|
-
##
|
|
16
|
+
## 问题
|
|
16
17
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
回放双键 LRU(128)测量 would-be 命中率
|
|
54
|
-
```
|
|
22
|
+
## 为什么是三态
|
|
55
23
|
|
|
56
|
-
**
|
|
24
|
+
**verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」。
|
|
57
25
|
|
|
58
26
|
## 快速开始
|
|
59
27
|
|
|
@@ -64,14 +32,12 @@ pi install npm:pi-verdict
|
|
|
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`
|
|
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-
|
|
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 #
|
|
146
|
+
bun test # 42 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
|
|
138
147
|
```
|
|
139
148
|
|
|
140
149
|
Issue tracker 与决策记录在 GitHub issues(「地图」issue #1 为索引)。
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
59
|
-
{ id: "chmod-777", pattern: /\bchmod\b[^;|&]*(777|a\+rwx|ugo\+rwx|ugo=rwx|[ug]\+s)\b/i, reason: "
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
198
|
-
if (hit(S3_GIT_META)) return D(
|
|
199
|
-
if (hit(S2_USER_RC)) return { verdict: "gray", reason:
|
|
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:
|
|
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:
|
|
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:
|
|
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},
|
|
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(
|
|
422
|
+
failures.push(`attempt ${n} (${maxTokens}t) contract violation: ${diag}`);
|
|
423
423
|
} else {
|
|
424
|
-
failures.push(
|
|
424
|
+
failures.push(`attempt ${n} (${maxTokens}t) aborted/errored: ${diag}`);
|
|
425
425
|
}
|
|
426
426
|
} else {
|
|
427
|
-
failures.push(
|
|
427
|
+
failures.push(`attempt ${n} (${maxTokens}t) exception: ${r.error}`);
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
|
-
return { verdict: "deny", reason:
|
|
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
|
|
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 `(
|
|
548
|
-
if (probe.result === "ctx-changed") return `(
|
|
549
|
-
return `(
|
|
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
|
|
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
|
|
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
|
|
599
|
-
: `Auto Mode
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
668
|
-
return { block: true, reason: `[auto-mode
|
|
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
|
|
675
|
-
return { block: true, reason: "[auto-mode]
|
|
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(
|
|
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
|
|
698
|
-
return { block: true, reason: `[auto-mode
|
|
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
|
|
704
|
-
return { block: true, reason: `[auto-mode]
|
|
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
|
|
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.
|
|
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",
|