pi-verdict 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -8
- package/README.zh-CN.md +34 -8
- package/extensions/auto-mode.ts +210 -135
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
**[English](README.md)** | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
5
|
[](LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/pi-verdict)
|
|
6
7
|
[](https://pi.dev)
|
|
7
8
|
|
|
8
9
|
> Pi runs YOLO by default: every tool call executes without asking.
|
|
9
10
|
> **verdict gives each call a three-state adjudication — `allow / ask / deny`.**
|
|
10
|
-
>
|
|
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.
|
|
11
12
|
|
|
12
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".
|
|
13
14
|
|
|
@@ -20,7 +21,11 @@
|
|
|
20
21
|
| [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅ (outcome) | ❌ single-turn, no context | closed (→ ask) | 4 |
|
|
21
22
|
| [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ deterministic only | — (no built-in classifier) | closed | 3 |
|
|
22
23
|
|
|
23
|
-
Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md)
|
|
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).
|
|
25
|
+
|
|
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.
|
|
27
|
+
|
|
28
|
+
The single-file, zero-dependency shape is deliberate — the whole extension is one readable [~700-line file](extensions/auto-mode.ts).
|
|
24
29
|
|
|
25
30
|
## Pipeline
|
|
26
31
|
|
|
@@ -28,9 +33,10 @@ Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-l
|
|
|
28
33
|
tool_call
|
|
29
34
|
│
|
|
30
35
|
├─ 1. Rule layer (deterministic, zero latency)
|
|
31
|
-
│ ├─
|
|
32
|
-
│
|
|
33
|
-
│
|
|
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
|
|
34
40
|
│
|
|
35
41
|
├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
|
|
36
42
|
│ ├─ input: CC-style <transcript> (last 5 user messages + last 10 tool calls,
|
|
@@ -76,6 +82,26 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
|
|
|
76
82
|
| `PI_AUTO_MODE_MODEL` | — | env form of the model flag |
|
|
77
83
|
| `PI_AUTO_MODE_DEBUG=1` | off | env form of debug (flag wins) |
|
|
78
84
|
|
|
85
|
+
### User rules (`config/pi-verdict.json`)
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"allow": ["^ls\\b", "^git (status|log|diff)\\b"],
|
|
90
|
+
"deny": ["rm ", "docker ", "^/etc/"],
|
|
91
|
+
"builtinDenyFloor": true,
|
|
92
|
+
"classifierModel": null
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
|
|
97
|
+
- 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
|
+
- `builtinDenyFloor: false` turns the built-in danger/path floor off entirely (risk accepted by you; the classifier and your rules remain)
|
|
99
|
+
- `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)
|
|
101
|
+
- first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`); changes apply to new sessions
|
|
102
|
+
|
|
103
|
+
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
|
+
|
|
79
105
|
Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) sessions; in non-interactive modes `ask` degrades to `deny`.
|
|
80
106
|
|
|
81
107
|
## Evidence-driven, not vibes-driven
|
|
@@ -86,14 +112,15 @@ Design decisions here are settled by measurement, and the lab notes ship with th
|
|
|
86
112
|
- [`research/thinking-param-blackhole.md`](research/thinking-param-blackhole.md) — three-layer forensic root-cause of thinking models burning the classifier budget; why the fix is `thinkingEnabled: false`
|
|
87
113
|
- [`research/rule-engine-sim`](research/rule-engine-sim/README.md) — measured a tree-sitter rule-engine port against 746 real bash calls (**absorbs 0 gray calls**) and rejected it
|
|
88
114
|
- [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) — the competitive landscape this README's positioning is checked against
|
|
115
|
+
- [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md) — third-party audit of the rule layer (8/8 reproduced → fixed architecturally in 0.2.0)
|
|
116
|
+
- [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md) — where this project genuinely converges with pi-automode, and what remains distinct
|
|
89
117
|
- [`research/claude-code-classifier-prompts.md`](research/claude-code-classifier-prompts.md) — structural reconstruction of Claude Code's classifier design (via self-hosted Langfuse observations) that this extension's transcript contract descends from
|
|
90
118
|
|
|
91
119
|
## Status & limitations
|
|
92
120
|
|
|
93
121
|
Prototype quality — usable, not hardened:
|
|
94
122
|
|
|
95
|
-
-
|
|
96
|
-
- no user-defined rules yet; rule seeds live at the top of `extensions/auto-mode.ts`
|
|
123
|
+
- no built-in allowlist by design (see the [security audit](research/rule-layer-security-audit.md)); with an empty `allow` config most commands go to the classifier — point `--auto-mode-model` at a fast model if per-call latency matters
|
|
97
124
|
- AGENTS.md is not passed to the classifier as downweighted intent evidence (Claude Code does this)
|
|
98
125
|
- parallel gray-zone calls are adjudicated serially
|
|
99
126
|
- self-reflection means the session model adjudicates — point `--auto-mode-model` at a lighter model if verdict latency/cost matters (open question tracked in the issue tracker)
|
|
@@ -108,7 +135,7 @@ The name: the three-state **verdict** is the core concept. The UX keeps `/automo
|
|
|
108
135
|
```bash
|
|
109
136
|
bun install
|
|
110
137
|
bun run typecheck
|
|
111
|
-
bun test #
|
|
138
|
+
bun test # 36 offline stub tests: deny floor, user rules, audit regression, classifier retry, shadow cache, commands
|
|
112
139
|
```
|
|
113
140
|
|
|
114
141
|
Issue tracker and decision records live in the GitHub issues ("map" issue #1 indexes them).
|
package/README.zh-CN.md
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
[English](README.md) | **[简体中文](README.zh-CN.md)**
|
|
4
4
|
|
|
5
5
|
[](LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/pi-verdict)
|
|
6
7
|
[](https://pi.dev)
|
|
7
8
|
|
|
8
9
|
> Pi 默认以 YOLO 模式运行:所有工具调用不经确认直接执行。
|
|
9
10
|
> **verdict 给每次调用一个三态裁决 —— `allow / ask / deny`。**
|
|
10
|
-
>
|
|
11
|
+
> 内置 deny floor 与你的 allow/deny 规则先行;其余交给携带会话上下文的模型分类器;任何失败路径一律 fail-closed。
|
|
11
12
|
|
|
12
13
|
**verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」。
|
|
13
14
|
|
|
@@ -20,7 +21,11 @@
|
|
|
20
21
|
| [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅(outcome) | ❌ 单轮无上下文 | closed(→ ask) | 4 |
|
|
21
22
|
| [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ 纯确定性 | —(无内置分类器) | closed | 3 |
|
|
22
23
|
|
|
23
|
-
完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md)
|
|
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)。
|
|
24
29
|
|
|
25
30
|
## 管线
|
|
26
31
|
|
|
@@ -28,9 +33,9 @@
|
|
|
28
33
|
tool_call
|
|
29
34
|
│
|
|
30
35
|
├─ 1. 规则层(确定性,零延迟)
|
|
31
|
-
│ ├─ bash
|
|
32
|
-
│ ├─
|
|
33
|
-
│ └─
|
|
36
|
+
│ ├─ 内置 deny floor:bash 危险正则(完整命令串)+ 路径敏感度 S0–S5
|
|
37
|
+
│ ├─ 用户规则:deny 优先于 allow(正则,见下)
|
|
38
|
+
│ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
|
|
34
39
|
│
|
|
35
40
|
├─ 2. 灰区 → 模型分类器(默认继承会话模型 —— "自省")
|
|
36
41
|
│ ├─ 输入:CC 风格 <transcript>(最近 5 条用户消息 + 最近 10 次工具调用,
|
|
@@ -76,6 +81,26 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
|
|
|
76
81
|
| `PI_AUTO_MODE_MODEL` | — | 模型配置的环境变量形式 |
|
|
77
82
|
| `PI_AUTO_MODE_DEBUG=1` | 关 | 调试的环境变量形式(flag 优先) |
|
|
78
83
|
|
|
84
|
+
### 用户规则(`config/pi-verdict.json`)
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"allow": ["^ls\\b", "^git (status|log|diff)\\b"],
|
|
89
|
+
"deny": ["rm ", "docker ", "^/etc/"],
|
|
90
|
+
"builtinDenyFloor": true,
|
|
91
|
+
"classifierModel": null
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
|
|
96
|
+
- 匹配目标:bash = **完整命令串**;文件类工具(read/write/edit/grep/find/ls)= **绝对路径**;其余工具(MCP 等)恒走分类器
|
|
97
|
+
- `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在)
|
|
98
|
+
- `classifierModel: "provider/model-id"` 持久指定分类器模型(如轻量 flash 类);优先级 flag > env > config > 自省;无效值回退会话模型并一次性警告
|
|
99
|
+
- spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-4-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考,[实测](research/thinking-param-blackhole.md)背书的默认)
|
|
100
|
+
- 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`);修改后新会话生效
|
|
101
|
+
|
|
102
|
+
为什么没有内置白名单?第三方安全审计(见 [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健全性需要 shell AST 分析——每条内置「永远放行」都是作者维护的安全声明。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
|
|
103
|
+
|
|
79
104
|
需要 pi ≥ 0.84。交互与非交互(`-p`/json/rpc)会话均支持;非交互模式下 `ask` 降级为 `deny`。
|
|
80
105
|
|
|
81
106
|
## 证据驱动,不靠直觉
|
|
@@ -86,14 +111,15 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
|
|
|
86
111
|
- [`research/thinking-param-blackhole.md`](research/thinking-param-blackhole.md) —— 思考模型烧尽分类器预算的三层取证,以及为什么修复是 `thinkingEnabled: false`
|
|
87
112
|
- [`research/rule-engine-sim`](research/rule-engine-sim/README.md) —— 用 746 条真实 bash 调用实测 tree-sitter 规则引擎移植(**灰区吸收 0 条**)并否决
|
|
88
113
|
- [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) —— 本 README 定位所对照的竞品全景
|
|
114
|
+
- [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md) —— 规则层第三方安全审计(8/8 复现 → 0.2.0 架构性修复)
|
|
115
|
+
- [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md) —— 与 pi-automode 何处真正收敛、何处仍然不同
|
|
89
116
|
- [`research/claude-code-classifier-prompts.md`](research/claude-code-classifier-prompts.md) —— Claude Code 分类器设计的结构化还原(基于自托管 Langfuse 观测),本扩展 transcript 契约的血统来源
|
|
90
117
|
|
|
91
118
|
## 状态与限制
|
|
92
119
|
|
|
93
120
|
原型质量 —— 可用,未硬化:
|
|
94
121
|
|
|
95
|
-
-
|
|
96
|
-
- 暂无用户自定义规则;规则种子集在 `extensions/auto-mode.ts` 顶部
|
|
122
|
+
- 设计上无内置白名单(见[安全审计](research/rule-layer-security-audit.md)与[用户规则](#用户规则configpi-verdictjson));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
|
|
97
123
|
- AGENTS.md 未作为降权意图证据传入分类器(Claude Code 有此设计)
|
|
98
124
|
- 并行灰区调用串行裁决
|
|
99
125
|
- 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
|
|
@@ -108,7 +134,7 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
|
|
|
108
134
|
```bash
|
|
109
135
|
bun install
|
|
110
136
|
bun run typecheck
|
|
111
|
-
bun test #
|
|
137
|
+
bun test # 36 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
|
|
112
138
|
```
|
|
113
139
|
|
|
114
140
|
Issue tracker 与决策记录在 GitHub issues(「地图」issue #1 为索引)。
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* 语义对齐 Claude Code Auto Mode,但方向相反:Pi 默认放行 → 本扩展自动拦截。
|
|
6
6
|
*
|
|
7
7
|
* 管线(tool_call 钩子):
|
|
8
|
-
* 1.
|
|
8
|
+
* 1. 规则层(内置 deny floor + 用户规则):
|
|
9
|
+
* - 内置 floor:bash 危险正则 + 路径敏感度分级(S0-S5)→ 硬 deny
|
|
10
|
+
* (默认开;配置 builtinDenyFloor:false 可整体关闭,风险自担)
|
|
11
|
+
* - 用户规则:config/pi-verdict.json 的 allow/deny 正则(黑名单优先于白名单)
|
|
12
|
+
* - 无内置白名单(安全声明由用户配置承担,#12/审计响应)
|
|
9
13
|
* 2. 灰区:模型分类器(默认"自省":继承当前会话 provider/model)
|
|
10
14
|
* - 输入:CC 风格 <transcript> 精简转录(用户消息流 + 工具调用流,
|
|
11
15
|
* 不含 assistant 叙述与工具结果),待审查动作固定为最后一行
|
|
@@ -19,13 +23,17 @@
|
|
|
19
23
|
*
|
|
20
24
|
* 配置:
|
|
21
25
|
* --auto-mode / --no-auto-mode CLI flag,总开关(默认开)
|
|
22
|
-
* --auto-mode-model provider/id 分类器模型
|
|
26
|
+
* --auto-mode-model provider/id[:thinking] 分类器模型 + 可选思考级别后缀
|
|
27
|
+
* (pi 原生 --model 语法;缺省 off = 显式关思考)
|
|
23
28
|
* PI_AUTO_MODE_MODEL 同上的环境变量形式
|
|
24
29
|
* --auto-mode-debug 所有裁决(含放行)都弹通知;影子缓存标注同步开启
|
|
25
30
|
* PI_AUTO_MODE_DEBUG=1 同上的环境变量形式(兼容保留)
|
|
31
|
+
* <agentDir>/config/pi-verdict.json 用户规则:{ allow: [regex], deny: [regex],
|
|
32
|
+
* builtinDenyFloor, classifierModel }
|
|
33
|
+
* 匹配:bash=完整命令串 / 文件工具=绝对路径;新会话生效
|
|
26
34
|
*
|
|
27
35
|
* 已知原型简化(见 README「已知限制」):
|
|
28
|
-
* - bash
|
|
36
|
+
* - 无内置 bash 白名单;危险识别依赖正则 floor(无 AST 解析)——未知形态交分类器
|
|
29
37
|
* - 裁决缓存暂缓引入(#5 决议):现为影子缓存 observe-only 遥测,积累数据后决断;
|
|
30
38
|
* 无熔断器(重议信号 = deny 风暴成本失控 / 非交互长期运行)
|
|
31
39
|
* - 未把 AGENTS.md 作为降权意图证据传入分类器
|
|
@@ -34,6 +42,7 @@
|
|
|
34
42
|
* research/pi-model-call-and-ref-implementations.md
|
|
35
43
|
*/
|
|
36
44
|
|
|
45
|
+
import * as fs from "node:fs";
|
|
37
46
|
import * as os from "node:os";
|
|
38
47
|
import * as path from "node:path";
|
|
39
48
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -42,32 +51,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
42
51
|
// 规则层:bash
|
|
43
52
|
// ============================================================================
|
|
44
53
|
|
|
45
|
-
/** 无条件白名单:只读/无副作用命令(源自 pi-permission BUILTIN_UNCONDITIONAL_SAFE + pi-auto-approve Tier 1) */
|
|
46
|
-
const BASH_SAFE_UNCONDITIONAL = new Set([
|
|
47
|
-
"arch", "basename", "cat", "cd", "cksum", "cmp", "column", "comm", "cut", "diff", "dirname",
|
|
48
|
-
"du", "df", "echo", "expand", "expr", "false", "file", "fold", "grep", "groups", "head", "id",
|
|
49
|
-
"jq", "ls", "md5sum", "nl", "paste", "printenv", "ps", "pwd", "readlink", "realpath", "rev",
|
|
50
|
-
"seq", "sha256sum", "shasum", "stat", "tail", "tr", "true", "tsort", "uniq", "uname", "uptime",
|
|
51
|
-
"wc", "whereis", "who", "whoami", "which", "tree", "less", "more", "rg", "ag", "ack", "locate",
|
|
52
|
-
"type", "hostname", "env", "date",
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
/** 条件白名单:argv 级检查(源自研究报告 §4.2) */
|
|
56
|
-
const GIT_READONLY_SUBCOMMANDS = new Set([
|
|
57
|
-
"status", "log", "diff", "show", "branch", "tag", "remote", "rev-parse", "rev-list",
|
|
58
|
-
"describe", "whatchanged", "shortlog", "blame", "grep", "ls-remote",
|
|
59
|
-
]);
|
|
60
|
-
const GIT_FORBIDDEN_FLAGS = new Set([
|
|
61
|
-
"-c", "-C", "-p", "--config-env", "--exec-path", "--git-dir", "--namespace",
|
|
62
|
-
"--paginate", "--super-prefix", "--work-tree", "--output", "--ext-diff", "--textconv", "--exec",
|
|
63
|
-
]);
|
|
64
|
-
const FIND_FORBIDDEN = new Set(["-exec", "-execdir", "-ok", "-okdir", "-delete", "-fls", "-fprint", "-fprint0", "-fprintf"]);
|
|
65
|
-
const RG_FORBIDDEN = new Set(["--pre", "--hostname-bin", "--search-zip", "-z"]);
|
|
66
|
-
const OUTPUT_FLAG_COMMANDS = new Set(["base64", "sort", "iconv", "shuf"]);
|
|
67
|
-
const PKG_READONLY = new Set(["list", "info", "view", "outdated", "audit", "why"]);
|
|
68
|
-
const PIP_READONLY = new Set(["list", "show", "freeze", "search"]);
|
|
69
|
-
const DOCKER_READONLY = new Set(["ps", "images", "inspect", "logs", "stats", "info", "version", "history", "top", "diff"]);
|
|
70
|
-
|
|
71
54
|
/** 危险模式:对完整命令串匹配(覆盖管道/复合命令),命中即 deny(源自研究报告 §4.3) */
|
|
72
55
|
const BASH_DANGER_RULES: Array<{ id: string; pattern: RegExp; reason: string }> = [
|
|
73
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)" },
|
|
@@ -86,91 +69,96 @@ const BASH_DANGER_RULES: Array<{ id: string; pattern: RegExp; reason: string }>
|
|
|
86
69
|
{ id: "fork-bomb", pattern: /:\(\)\s*\{/, reason: "fork 炸弹" },
|
|
87
70
|
];
|
|
88
71
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
function splitShellChain(command: string): string[] {
|
|
94
|
-
return command
|
|
95
|
-
.split(/&&|\|\||[;|]/)
|
|
96
|
-
.map((s) => s.trim())
|
|
97
|
-
.filter((s) => s.length > 0);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** 提取段内 argv:跳过前导 VAR=value 赋值,命令名去路径前缀 */
|
|
101
|
-
function segmentArgv(segment: string): string[] {
|
|
102
|
-
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
103
|
-
while (tokens.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
|
|
104
|
-
if (tokens.length > 0) tokens[0] = path.basename(tokens[0]);
|
|
105
|
-
return tokens;
|
|
72
|
+
type RuleVerdict = "allow" | "deny" | "gray";
|
|
73
|
+
interface RuleResult {
|
|
74
|
+
verdict: RuleVerdict;
|
|
75
|
+
reason?: string;
|
|
106
76
|
}
|
|
107
77
|
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!sub || !GIT_READONLY_SUBCOMMANDS.has(sub)) return false;
|
|
114
|
-
if (rest.some((t) => GIT_FORBIDDEN_FLAGS.has(t))) return false;
|
|
115
|
-
if (sub === "branch") {
|
|
116
|
-
return rest.every((t) => t === "branch" || !t.startsWith("-") || /^(-l|--list|-a|-r|-v|--show-current|--format=)/.test(t));
|
|
117
|
-
}
|
|
118
|
-
return true;
|
|
78
|
+
function classifyBash(command: string, floorOn: boolean): RuleResult {
|
|
79
|
+
// 内置 deny floor:危险正则对完整命令串匹配;可经 builtinDenyFloor 整体关闭
|
|
80
|
+
if (floorOn) {
|
|
81
|
+
for (const rule of BASH_DANGER_RULES) {
|
|
82
|
+
if (rule.pattern.test(command)) return { verdict: "deny", reason: `规则 ${rule.id}: ${rule.reason}` };
|
|
119
83
|
}
|
|
120
|
-
case "find":
|
|
121
|
-
return !rest.some((t) => FIND_FORBIDDEN.has(t));
|
|
122
|
-
case "rg":
|
|
123
|
-
return !rest.some((t) => RG_FORBIDDEN.has(t));
|
|
124
|
-
case "base64":
|
|
125
|
-
case "sort":
|
|
126
|
-
case "iconv":
|
|
127
|
-
case "shuf":
|
|
128
|
-
return !rest.some((t) => t === "-o" || t === "--output" || (/^-[a-zA-Z]*o/.test(t) && !t.startsWith("--")));
|
|
129
|
-
case "sed":
|
|
130
|
-
// 仅放行 sed -n {N|M,N}p [file]
|
|
131
|
-
return rest[0] === "-n" && rest.length <= 3 && (rest.length === 1 || /^\d*(,\d+)?p$/.test(rest[1] ?? ""));
|
|
132
|
-
case "date":
|
|
133
|
-
return !rest.some((t) => t === "-s" || t === "--set" || (/^-[a-zA-Z]*s/.test(t) && !t.startsWith("--")));
|
|
134
|
-
case "npm":
|
|
135
|
-
case "yarn":
|
|
136
|
-
case "pnpm":
|
|
137
|
-
return rest.length > 0 && PKG_READONLY.has(rest[0]);
|
|
138
|
-
case "pip":
|
|
139
|
-
case "pip3":
|
|
140
|
-
return rest.length > 0 && PIP_READONLY.has(rest[0]);
|
|
141
|
-
case "docker":
|
|
142
|
-
case "podman":
|
|
143
|
-
return rest.length > 0 && DOCKER_READONLY.has(rest[0]);
|
|
144
|
-
default:
|
|
145
|
-
return false;
|
|
146
84
|
}
|
|
85
|
+
if (!command.trim()) return { verdict: "allow", reason: "空命令" };
|
|
86
|
+
// 无内置白名单(#12):一切非危险命令交用户规则与分类器
|
|
87
|
+
return { verdict: "gray", reason: "无内置白名单" };
|
|
147
88
|
}
|
|
148
89
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// 用户规则:白名单/黑名单(可配置;#12 审计响应)
|
|
92
|
+
//
|
|
93
|
+
// 配置:<agentDir>/config/pi-verdict.json(尊重 PI_CODING_AGENT_DIR 覆盖):
|
|
94
|
+
// { "allow": ["^ls\\b", "^git (status|log|diff)\\b"], "deny": ["rm ", "^/etc/"] }
|
|
95
|
+
// 匹配目标:bash/powershell = 完整命令串;read/write/edit/grep/find/ls = 解析后绝对路径;
|
|
96
|
+
// 其余工具(MCP/自定义)不参与用户规则,恒走分类器。
|
|
97
|
+
// 优先级:内置 deny floor → 用户 deny → 用户 allow → gray;floor 默认开,可经 builtinDenyFloor:false 关闭。
|
|
98
|
+
// 非法正则跳过并通知(配置错误不导致扩展失效);新会话生效。
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
interface UserRules {
|
|
102
|
+
allow: RegExp[];
|
|
103
|
+
deny: RegExp[];
|
|
104
|
+
/** 内置 deny floor 开关(危险正则 + 路径敏感度 deny),默认 true;关闭后依赖用户规则与分类器 */
|
|
105
|
+
builtinDenyFloor: boolean;
|
|
106
|
+
/** 分类器模型 spec(provider/id);null = 未配置(自省继承会话模型) */
|
|
107
|
+
classifierModel: string | null;
|
|
153
108
|
}
|
|
154
109
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
110
|
+
const EMPTY_RULES: UserRules = { allow: [], deny: [], builtinDenyFloor: true, classifierModel: null };
|
|
111
|
+
|
|
112
|
+
function userConfigPath(): string {
|
|
113
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
114
|
+
return path.join(agentDir, "config", "pi-verdict.json");
|
|
115
|
+
}
|
|
116
|
+
|
|
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;留空=自省继承会话模型)。修改后新会话生效。",
|
|
119
|
+
allow: ["^ls\\b"],
|
|
120
|
+
deny: [],
|
|
121
|
+
builtinDenyFloor: true,
|
|
122
|
+
classifierModel: null,
|
|
123
|
+
}, null, 2)}\n`;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 加载用户规则。首启生成带注释模板(allow 内示例默认仅 ^ls\b 可用,其余为说明占位);
|
|
127
|
+
* 配置缺失/损坏/字段非法一律回退空规则(安全默认,不失效),非法正则收集回报。
|
|
128
|
+
*/
|
|
129
|
+
function loadUserRules(): { rules: UserRules; skipped: string[] } {
|
|
130
|
+
try {
|
|
131
|
+
const p = userConfigPath();
|
|
132
|
+
if (!fs.existsSync(p)) {
|
|
133
|
+
try {
|
|
134
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
135
|
+
fs.writeFileSync(p, USER_CONFIG_TEMPLATE);
|
|
136
|
+
} catch { /* 只读环境静默跳过 */ }
|
|
137
|
+
return { rules: EMPTY_RULES, skipped: [] };
|
|
138
|
+
}
|
|
139
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as { allow?: unknown; deny?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown };
|
|
140
|
+
const skipped: string[] = [];
|
|
141
|
+
const compile = (list: unknown): RegExp[] =>
|
|
142
|
+
(Array.isArray(list) ? list : []).filter((x): x is string => typeof x === "string").flatMap((src) => {
|
|
143
|
+
try {
|
|
144
|
+
return [new RegExp(src)];
|
|
145
|
+
} catch {
|
|
146
|
+
skipped.push(src);
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
rules: {
|
|
152
|
+
allow: compile(raw.allow),
|
|
153
|
+
deny: compile(raw.deny),
|
|
154
|
+
builtinDenyFloor: raw.builtinDenyFloor !== false,
|
|
155
|
+
classifierModel: typeof raw.classifierModel === "string" && raw.classifierModel.trim() ? raw.classifierModel.trim() : null,
|
|
156
|
+
},
|
|
157
|
+
skipped,
|
|
158
|
+
};
|
|
159
|
+
} catch {
|
|
160
|
+
return { rules: EMPTY_RULES, skipped: [] };
|
|
172
161
|
}
|
|
173
|
-
return { verdict: "allow" };
|
|
174
162
|
}
|
|
175
163
|
|
|
176
164
|
// ============================================================================
|
|
@@ -184,49 +172,98 @@ function expandHome(p: string): string {
|
|
|
184
172
|
const S0_SECRET = [
|
|
185
173
|
/\.ssh(\/|$)/, /\.aws(\/|$)/, /\.gnupg(\/|$)/, /(^|\/)\.env(\.|$)/, /credentials?(\.|\/|$)/i,
|
|
186
174
|
/(^|\/)id_rsa/, /\.pem$/, /_history$/, /\.config\/gh(\/|$)/, /\.pi\/agent\/auth\.json$/,
|
|
175
|
+
// V8(安全审计):常见明文凭证文件补全
|
|
176
|
+
/(^|\/)\.netrc$/, /(^|\/)\.npmrc$/, /(^|\/)\.pypirc$/, /(^|\/)\.envrc$/, /(^|\/)\.vault-token$/,
|
|
177
|
+
/\.kube(\/|$)/, /\.docker\/config\.json$/, /\.gem\/credentials$/,
|
|
187
178
|
];
|
|
188
179
|
const S1_SYSTEM = [/^\/etc(\/|$)/, /^\/usr(\/|$)/, /^\/var(\/|$)/, /^\/System(\/|$)/, /(^|\/)authorized_keys$/];
|
|
189
180
|
const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/, /crontab/, /Library\/LaunchAgents(\/|$)/, /\.config\/systemd(\/|$)/];
|
|
190
181
|
const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/, /(^|\/)\.gitmodules$/];
|
|
191
182
|
|
|
192
183
|
/** read 类工具:S0 读取即高危(deny),其余读取放行。isWrite: write/edit 走完整分级 */
|
|
193
|
-
function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean): RuleResult {
|
|
184
|
+
function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean, floorOn: boolean): RuleResult {
|
|
194
185
|
const abs = path.resolve(cwd, expandHome(rawPath));
|
|
195
186
|
const hit = (rules: RegExp[]) => rules.some((r) => r.test(abs));
|
|
187
|
+
// floor 关闭时:内置 deny 一律降级 gray(永不升格 allow);非 deny 分支(allow/gray)保持
|
|
188
|
+
const D = floorOn
|
|
189
|
+
? (reason: string): RuleResult => ({ verdict: "deny", reason })
|
|
190
|
+
: (reason: string): RuleResult => ({ verdict: "gray", reason });
|
|
196
191
|
|
|
197
|
-
if (hit(S0_SECRET)) return
|
|
192
|
+
if (hit(S0_SECRET)) return D(`S0 密钥/凭证路径: ${rawPath}`);
|
|
198
193
|
if (!isWrite) {
|
|
199
194
|
if (hit(S1_SYSTEM)) return { verdict: "gray", reason: `读取系统配置路径: ${rawPath}` };
|
|
200
195
|
return { verdict: "allow" };
|
|
201
196
|
}
|
|
202
|
-
if (hit(S1_SYSTEM)) return
|
|
203
|
-
if (hit(S3_GIT_META)) return
|
|
197
|
+
if (hit(S1_SYSTEM)) return D(`写入系统目录: ${rawPath}`);
|
|
198
|
+
if (hit(S3_GIT_META)) return D(`写入 .git 元数据(可执行代码入口): ${rawPath}` );
|
|
204
199
|
if (hit(S2_USER_RC)) return { verdict: "gray", reason: `写入用户配置/持久化入口: ${rawPath}` };
|
|
205
200
|
if (abs === cwd || abs.startsWith(cwd + path.sep)) return { verdict: "allow" };
|
|
206
201
|
return { verdict: "gray", reason: `写入项目目录(CWD)外: ${rawPath}` };
|
|
207
202
|
}
|
|
208
203
|
|
|
209
|
-
/**
|
|
210
|
-
function
|
|
204
|
+
/** 用户规则匹配目标:bash/powershell=完整命令串;路径类工具=解析后绝对路径;其余工具不参与 */
|
|
205
|
+
function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: string): string | null {
|
|
211
206
|
switch (toolName) {
|
|
212
207
|
case "bash":
|
|
213
208
|
case "powershell":
|
|
214
|
-
return
|
|
209
|
+
return String(input.command ?? "");
|
|
210
|
+
case "read":
|
|
215
211
|
case "write":
|
|
216
212
|
case "edit":
|
|
217
|
-
|
|
213
|
+
case "grep":
|
|
214
|
+
case "find":
|
|
215
|
+
case "ls": {
|
|
216
|
+
const p = typeof input.path === "string" && input.path ? input.path : null;
|
|
217
|
+
return p ? path.resolve(cwd, expandHome(p)) : null;
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 工具调用 → 规则层裁决。裁决序(#12):
|
|
226
|
+
* 1. 内置 base(bash 危险正则 floor / 路径敏感度分级)——deny 即终局(floor 可经 builtinDenyFloor 关闭)
|
|
227
|
+
* 2. 用户黑名单 → deny(优先于白名单)
|
|
228
|
+
* 3. 用户白名单 → allow
|
|
229
|
+
* 4. base(路径类工具的默认 allow/gray;其余 gray)→ 交分类器
|
|
230
|
+
*/
|
|
231
|
+
function classifyByRules(toolName: string, input: Record<string, unknown>, cwd: string, user: UserRules): RuleResult {
|
|
232
|
+
let base: RuleResult;
|
|
233
|
+
switch (toolName) {
|
|
234
|
+
case "bash":
|
|
235
|
+
case "powershell":
|
|
236
|
+
base = classifyBash(String(input.command ?? ""), user.builtinDenyFloor);
|
|
237
|
+
break;
|
|
238
|
+
case "write":
|
|
239
|
+
case "edit":
|
|
240
|
+
base = classifyPath(toolName, String(input.path ?? ""), cwd, true, user.builtinDenyFloor);
|
|
241
|
+
break;
|
|
218
242
|
case "read":
|
|
219
|
-
|
|
243
|
+
base = classifyPath(toolName, String(input.path ?? ""), cwd, false, user.builtinDenyFloor);
|
|
244
|
+
break;
|
|
220
245
|
case "grep":
|
|
221
246
|
case "find":
|
|
222
247
|
case "ls": {
|
|
223
248
|
const p = typeof input.path === "string" ? input.path : undefined;
|
|
224
|
-
|
|
225
|
-
|
|
249
|
+
base = p ? classifyPath(toolName, p, cwd, false, user.builtinDenyFloor) : { verdict: "allow" };
|
|
250
|
+
break;
|
|
226
251
|
}
|
|
227
252
|
default:
|
|
228
|
-
|
|
253
|
+
base = { verdict: "gray", reason: `未内置规则覆盖的工具: ${toolName}` };
|
|
229
254
|
}
|
|
255
|
+
if (base.verdict === "deny") return base; // 内置 floor:deny 优先于一切用户规则
|
|
256
|
+
|
|
257
|
+
const target = userRuleTarget(toolName, input, cwd);
|
|
258
|
+
if (target !== null) {
|
|
259
|
+
for (const re of user.deny) {
|
|
260
|
+
if (re.test(target)) return { verdict: "deny", reason: `用户黑名单: ${re.source}` };
|
|
261
|
+
}
|
|
262
|
+
for (const re of user.allow) {
|
|
263
|
+
if (re.test(target)) return { verdict: "allow", reason: "用户白名单" };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return base;
|
|
230
267
|
}
|
|
231
268
|
|
|
232
269
|
// ============================================================================
|
|
@@ -311,7 +348,7 @@ interface ClassifierOutcome {
|
|
|
311
348
|
source: "model" | "fail-closed";
|
|
312
349
|
}
|
|
313
350
|
|
|
314
|
-
const CLASSIFIER_TIMEOUT_MS =
|
|
351
|
+
const CLASSIFIER_TIMEOUT_MS = 25_000; // 本网关 CC 分类器分布 p90=19.8s(15s 会误杀 ~15%),research/cache-sim 数据
|
|
315
352
|
const CLASSIFIER_MAX_TOKENS = 512;
|
|
316
353
|
const CLASSIFIER_RETRY_MAX_TOKENS = 1024; // 防御重试档:覆盖无视 reasoning:off 或轻思考仍超预算的模型
|
|
317
354
|
|
|
@@ -321,6 +358,7 @@ async function callClassifierOnce(
|
|
|
321
358
|
model: NonNullable<ExtensionContext["model"]>,
|
|
322
359
|
userMessage: string,
|
|
323
360
|
maxTokens: number,
|
|
361
|
+
thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
|
|
324
362
|
): Promise<{ ok: true; text: string; stopReason: string } | { ok: false; error: string }> {
|
|
325
363
|
const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
|
|
326
364
|
if (ctx.signal) signals.push(ctx.signal);
|
|
@@ -335,14 +373,14 @@ async function callClassifierOnce(
|
|
|
335
373
|
signal: AbortSignal.any(signals),
|
|
336
374
|
maxTokens,
|
|
337
375
|
temperature: 0,
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
376
|
+
// 思考参数必须用 API 原生字段(thinkingEnabled/effort),而非 reasoning
|
|
377
|
+
// (API 层 complete() 无此字段,宽类型索引签名静默放行后运行时丢弃——见
|
|
378
|
+
// research/thinking-param-blackhole.md)。
|
|
379
|
+
// 缺省 off = 显式关思考(实证送达 thinking:{"type":"disabled"},GLM 降为
|
|
380
|
+
// effort low 轻思考);后缀级别经 adaptive effort 送达(minimal→low 映射)。
|
|
381
|
+
...(thinking === "off"
|
|
382
|
+
? { thinkingEnabled: false }
|
|
383
|
+
: { thinkingEnabled: true, effort: thinking === "minimal" ? ("low" as const) : thinking }),
|
|
346
384
|
cacheRetention: "short",
|
|
347
385
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
348
386
|
},
|
|
@@ -367,6 +405,7 @@ async function classifyWithModel(
|
|
|
367
405
|
ctx: ExtensionContext,
|
|
368
406
|
model: NonNullable<ExtensionContext["model"]>,
|
|
369
407
|
actionLine: string,
|
|
408
|
+
thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
|
|
370
409
|
): Promise<ClassifierOutcome> {
|
|
371
410
|
const transcript = buildTranscript(ctx, actionLine);
|
|
372
411
|
const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
|
|
@@ -374,7 +413,7 @@ async function classifyWithModel(
|
|
|
374
413
|
const failures: string[] = [];
|
|
375
414
|
for (const [n, maxTokens] of attempts) {
|
|
376
415
|
if (ctx.signal?.aborted) break; // 用户已取消,不再重试
|
|
377
|
-
const r = await callClassifierOnce(ctx, model, userMessage, maxTokens);
|
|
416
|
+
const r = await callClassifierOnce(ctx, model, userMessage, maxTokens, thinking);
|
|
378
417
|
if (r.ok) {
|
|
379
418
|
const diag = `stopReason=${r.stopReason}, model=${model.id}, 原始输出=${JSON.stringify(r.text.slice(0, 200))}`;
|
|
380
419
|
if (r.stopReason !== "error" && r.stopReason !== "aborted") {
|
|
@@ -516,21 +555,27 @@ function shadowTag(probe: ShadowProbe): string {
|
|
|
516
555
|
|
|
517
556
|
export default function autoMode(pi: ExtensionAPI) {
|
|
518
557
|
pi.registerFlag("auto-mode", { description: "Enable Auto Mode (rules + model classifier gating for tool calls)", type: "boolean", default: true });
|
|
519
|
-
pi.registerFlag("auto-mode-model", { description: "Classifier model as provider/id (default: inherit session model)", type: "string" });
|
|
558
|
+
pi.registerFlag("auto-mode-model", { description: "Classifier model as provider/id[:thinking] (pi --model syntax; default: inherit session model)", type: "string" });
|
|
520
559
|
pi.registerFlag("auto-mode-debug", { description: "Notify every verdict incl. allows, with shadow-cache annotation", type: "boolean", default: false });
|
|
521
560
|
|
|
522
561
|
let enabled = pi.getFlag("auto-mode") !== false;
|
|
523
562
|
const debug = pi.getFlag("auto-mode-debug") === true || process.env.PI_AUTO_MODE_DEBUG === "1";
|
|
524
563
|
const shadow = new ShadowCache();
|
|
564
|
+
let userRules: UserRules = loadUserRules().rules;
|
|
525
565
|
|
|
526
566
|
function refreshStatus(ctx: ExtensionContext) {
|
|
527
567
|
// 双态恒显:on 高亮 / off 暗色(原来 off 直接隐藏,状态不可见)
|
|
528
568
|
ctx.ui.setStatus("auto-mode", ctx.ui.theme.fg(enabled ? "accent" : "dim", enabled ? "auto mode on" : "auto mode off"));
|
|
529
569
|
}
|
|
530
570
|
|
|
531
|
-
// session_start
|
|
571
|
+
// session_start:重置影子缓存(会话内存态,#5 定案)+ 重载用户规则(配置改动新会话生效)
|
|
532
572
|
pi.on("session_start", async (_event, ctx) => {
|
|
533
573
|
shadow.reset();
|
|
574
|
+
const loaded = loadUserRules();
|
|
575
|
+
userRules = loaded.rules;
|
|
576
|
+
if (loaded.skipped.length > 0) {
|
|
577
|
+
ctx.ui.notify(`pi-verdict:配置中 ${loaded.skipped.length} 条非法正则已跳过(${userConfigPath()})`, "warning");
|
|
578
|
+
}
|
|
534
579
|
refreshStatus(ctx);
|
|
535
580
|
});
|
|
536
581
|
|
|
@@ -560,14 +605,44 @@ export default function autoMode(pi: ExtensionAPI) {
|
|
|
560
605
|
},
|
|
561
606
|
});
|
|
562
607
|
|
|
608
|
+
let warnedClassifierModel = false;
|
|
609
|
+
/** 思考级别集(pi 原生 EXTENDED_THINKING_LEVELS;后缀语法对齐 pi --model provider/id:thinking) */
|
|
610
|
+
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
611
|
+
|
|
612
|
+
/** 解析 "provider/id:thinking" → { specPart, level }。无效后缀 → 忽略并警告一次 */
|
|
613
|
+
function parseModelSpec(raw: string, ctx: ExtensionContext): { specPart: string; level: string | null } {
|
|
614
|
+
const slash = raw.lastIndexOf("/");
|
|
615
|
+
const colon = raw.lastIndexOf(":");
|
|
616
|
+
if (colon > slash + 1 && THINKING_LEVELS.has(raw.slice(colon + 1))) {
|
|
617
|
+
return { specPart: raw.slice(0, colon), level: raw.slice(colon + 1) };
|
|
618
|
+
}
|
|
619
|
+
if (colon > slash + 1 && !warnedClassifierModel) {
|
|
620
|
+
warnedClassifierModel = true;
|
|
621
|
+
ctx.ui.notify(`pi-verdict:思考级别后缀 "${raw.slice(colon + 1)}" 无效(合法:${[...THINKING_LEVELS].join("/")}),已忽略`, "warning");
|
|
622
|
+
}
|
|
623
|
+
return { specPart: raw, level: null };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** 分类器思考级别:spec 后缀指定;缺省 off(显式关思考,blackhole 研究背书) */
|
|
627
|
+
let classifierThinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off";
|
|
628
|
+
|
|
563
629
|
function resolveClassifierModel(ctx: ExtensionContext): NonNullable<ExtensionContext["model"]> | null {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
630
|
+
// 优先级:CLI flag > 环境变量 > 配置文件(classifierModel) > 自省(会话模型)
|
|
631
|
+
const raw =
|
|
632
|
+
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? userRules.classifierModel;
|
|
633
|
+
classifierThinking = "off";
|
|
634
|
+
if (raw) {
|
|
635
|
+
const { specPart, level } = parseModelSpec(raw, ctx);
|
|
636
|
+
if (level) classifierThinking = level as typeof classifierThinking;
|
|
637
|
+
const slash = specPart.indexOf("/");
|
|
567
638
|
if (slash > 0) {
|
|
568
|
-
const model = ctx.modelRegistry.find(
|
|
639
|
+
const model = ctx.modelRegistry.find(specPart.slice(0, slash), specPart.slice(slash + 1));
|
|
569
640
|
if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return model;
|
|
570
641
|
}
|
|
642
|
+
if (!warnedClassifierModel) {
|
|
643
|
+
warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
|
|
644
|
+
ctx.ui.notify(`pi-verdict:分类器模型 "${raw}" 不可用(未找到或未配置凭证),回退会话模型(自省)`, "warning");
|
|
645
|
+
}
|
|
571
646
|
}
|
|
572
647
|
return ctx.model ?? null; // 自省:继承当前会话模型
|
|
573
648
|
}
|
|
@@ -583,7 +658,7 @@ export default function autoMode(pi: ExtensionAPI) {
|
|
|
583
658
|
const action = describeAction(event.toolName, input);
|
|
584
659
|
|
|
585
660
|
// 第 1 层:规则
|
|
586
|
-
const rule = classifyByRules(event.toolName, input, ctx.cwd);
|
|
661
|
+
const rule = classifyByRules(event.toolName, input, ctx.cwd, userRules);
|
|
587
662
|
if (rule.verdict === "allow") {
|
|
588
663
|
if (debug) ctx.ui.notify(`🛡️ allow(规则): ${action}`, "info");
|
|
589
664
|
return undefined;
|
|
@@ -605,7 +680,7 @@ export default function autoMode(pi: ExtensionAPI) {
|
|
|
605
680
|
const ctxKey = shadowContextKey(ctx);
|
|
606
681
|
const probe = shadow.probe(cmdKey, ctxKey);
|
|
607
682
|
|
|
608
|
-
const outcome = await classifyWithModel(ctx, model, action);
|
|
683
|
+
const outcome = await classifyWithModel(ctx, model, action, classifierThinking);
|
|
609
684
|
|
|
610
685
|
// 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
|
|
611
686
|
// 命中且本次为可缓存裁决时,对比反事实一致性
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-verdict",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
+
"author": "Jesset (https://github.com/jesset)",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "extensions/auto-mode.ts",
|
|
7
8
|
"files": [
|
|
@@ -44,4 +45,4 @@
|
|
|
44
45
|
"@types/node": "^26.3.0",
|
|
45
46
|
"typescript": "^7.0.2"
|
|
46
47
|
}
|
|
47
|
-
}
|
|
48
|
+
}
|