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 CHANGED
@@ -3,11 +3,12 @@
3
3
  **[English](README.md)** | [简体中文](README.zh-CN.md)
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+ [![npm](https://img.shields.io/npm/v/pi-verdict)](https://www.npmjs.com/package/pi-verdict)
6
7
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](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
- > Deterministic rules first; gray areas go to a model classifier that sees the conversation context; every failure mode fails closed.
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). The single-file, zero-dependency shape is deliberate — the whole extension is one readable [~590-line file](extensions/auto-mode.ts).
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
- │ ├─ bash: danger regexes (full-string) → deny; whitelist (per-segment argv) → allow
32
- ├─ write/edit: path sensitivity S0–S5 (secrets/system/.git meta → deny; in-CWD → allow)
33
- └─ read/grep/find/ls: secret paths deny, else allow
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
- - bash segmentation is naive (no quoting/AST awareness); the AST-port alternative was [measured and rejected](research/rule-engine-sim/README.md), revisit if real traffic shows sensitive-path redirects slipping through
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 # 21 offline stub tests: rules, classifier retry, shadow cache, command semantics
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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+ [![npm](https://img.shields.io/npm/v/pi-verdict)](https://www.npmjs.com/package/pi-verdict)
6
7
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://pi.dev)
7
8
 
8
9
  > Pi 默认以 YOLO 模式运行:所有工具调用不经确认直接执行。
9
10
  > **verdict 给每次调用一个三态裁决 —— `allow / ask / deny`。**
10
- > 确定性规则先行;灰区交给携带会话上下文的模型分类器;任何失败路径一律 fail-closed。
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)。零依赖单文件形态是有意为之——整个扩展就是一个可通读的 [~590 行文件](extensions/auto-mode.ts)。
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:危险正则(完整命令串) deny;白名单(逐段 argv 检查)→ allow
32
- │ ├─ write/edit:路径敏感度 S0–S5(密钥/系统/.git 元数据 → deny;CWD allow)
33
- │ └─ read/grep/find/ls:密钥路径 deny,其余 → allow
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
- - bash 分段是朴素切分(无引号/AST 感知);AST 移植方案已[实测否决](research/rule-engine-sim/README.md),真实流量出现敏感路径重定向绕过时重议
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 # 21 个离线桩测试:规则层 / 分类器重试 / 影子缓存 / 命令语义
137
+ bun test # 36 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
112
138
  ```
113
139
 
114
140
  Issue tracker 与决策记录在 GitHub issues(「地图」issue #1 为索引)。
@@ -5,7 +5,11 @@
5
5
  * 语义对齐 Claude Code Auto Mode,但方向相反:Pi 默认放行 → 本扩展自动拦截。
6
6
  *
7
7
  * 管线(tool_call 钩子):
8
- * 1. 规则层:bash 白名单/危险正则 + 文件路径敏感度分级 → 硬 allow / 硬 deny
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 分段是朴素切分(不处理引号内的 | ),无 AST
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
- * 朴素 bash 分段:按 && || ; | 切开,不处理引号包裹的运算符(原型简化)。
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 isConditionalSafe(argv: string[]): boolean {
109
- const [cmd, ...rest] = argv;
110
- switch (cmd) {
111
- case "git": {
112
- const sub = rest.find((t) => !t.startsWith("-"));
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
- type RuleVerdict = "allow" | "deny" | "gray";
150
- interface RuleResult {
151
- verdict: RuleVerdict;
152
- reason?: string;
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
- function classifyBash(command: string): RuleResult {
156
- // 危险规则:对完整命令串匹配(单 argv 看不到管道另一侧)
157
- for (const rule of BASH_DANGER_RULES) {
158
- if (rule.pattern.test(command)) return { verdict: "deny", reason: `规则 ${rule.id}: ${rule.reason}` };
159
- }
160
- // 白名单:逐段检查,全部命中才放行
161
- const segments = splitShellChain(command);
162
- if (segments.length === 0) return { verdict: "allow", reason: "空命令" };
163
- for (const segment of segments) {
164
- const argv = segmentArgv(segment);
165
- if (argv.length === 0) continue;
166
- const [cmd, ...rest] = argv;
167
- if (BASH_SAFE_UNCONDITIONAL.has(cmd)) continue;
168
- // <cmd> --help / --version 一律放行
169
- if (rest.length === 1 && /^(--help|-h|--version|-v)$/.test(rest[0])) continue;
170
- if (OUTPUT_FLAG_COMMANDS.has(cmd) || isConditionalSafe(argv)) continue;
171
- return { verdict: "gray", reason: `命令不在白名单: ${cmd}` };
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 { verdict: "deny", reason: `S0 密钥/凭证路径: ${rawPath}` };
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 { verdict: "deny", reason: `写入系统目录: ${rawPath}` };
203
- if (hit(S3_GIT_META)) return { verdict: "deny", reason: `写入 .git 元数据(可执行代码入口): ${rawPath}` };
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
- /** 工具调用 → 规则层裁决。未覆盖的工具(含 MCP/自定义)→ gray,交分类器 */
210
- function classifyByRules(toolName: string, input: Record<string, unknown>, cwd: string): RuleResult {
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 classifyBash(String(input.command ?? ""));
209
+ return String(input.command ?? "");
210
+ case "read":
215
211
  case "write":
216
212
  case "edit":
217
- return classifyPath(toolName, String(input.path ?? ""), cwd, true);
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
- return classifyPath(toolName, String(input.path ?? ""), cwd, false);
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
- if (p) return classifyPath(toolName, p, cwd, false);
225
- return { verdict: "allow" };
249
+ base = p ? classifyPath(toolName, p, cwd, false, user.builtinDenyFloor) : { verdict: "allow" };
250
+ break;
226
251
  }
227
252
  default:
228
- return { verdict: "gray", reason: `未内置规则覆盖的工具: ${toolName}` };
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 = 15_000;
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
- // 关思考必须用 API 原生字段 thinkingEnabled:false,而非 reasoning:"off"。
339
- // 扩展侧 ModelRegistry 只暴露 API 层 complete(),其选项类型没有 reasoning 字段
340
- // (它是 SimpleStreamOptions 的字段;宽类型 Model<Api> 的索引签名让 TS 静默放行,
341
- // 运行时被丢弃)——minimal/off 从未生效,GLM 按默认 max 档思考烧尽预算/超时。
342
- // anthropic-messages 栈上 thinkingEnabled:falsethinking:{"type":"disabled"}
343
- // GLM 降为 effort low 轻思考;其他 API 为无害多余
344
- // 属性,交由防御重试兜底。根因与研究:research/thinking-param-blackhole.md
345
- thinkingEnabled: false,
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 送达(minimallow 映射)。
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 会重置 shadow(会话内存态,#5 定案)
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
- const spec = (pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL;
565
- if (spec) {
566
- const slash = spec.indexOf("/");
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(spec.slice(0, slash), spec.slice(slash + 1));
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.0",
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
+ }