pi-verdict 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
@@ -28,9 +29,10 @@ Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-l
28
29
  tool_call
29
30
 
30
31
  ├─ 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
32
+ │ ├─ built-in deny floor: bash danger regexes (full-string) +
33
+ path sensitivity S0–S5 (secrets/system/.git meta → deny)
34
+ ├─ your rules: user deny beats user allow (regex, see below)
35
+ │ └─ no built-in allowlist — every "always allow" claim is yours to make
34
36
 
35
37
  ├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
36
38
  │ ├─ input: CC-style <transcript> (last 5 user messages + last 10 tool calls,
@@ -76,6 +78,23 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
76
78
  | `PI_AUTO_MODE_MODEL` | — | env form of the model flag |
77
79
  | `PI_AUTO_MODE_DEBUG=1` | off | env form of debug (flag wins) |
78
80
 
81
+ ### User rules (`config/pi-verdict.json`)
82
+
83
+ ```json
84
+ {
85
+ "allow": ["^ls\\b", "^git (status|log|diff)\\b"],
86
+ "deny": ["rm ", "docker ", "^/etc/"],
87
+ "builtinDenyFloor": true
88
+ }
89
+ ```
90
+
91
+ - `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
92
+ - 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
93
+ - `builtinDenyFloor: false` turns the built-in danger/path floor off entirely (risk accepted by you; the classifier and your rules remain)
94
+ - first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`); changes apply to new sessions
95
+
96
+ 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.
97
+
79
98
  Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) sessions; in non-interactive modes `ask` degrades to `deny`.
80
99
 
81
100
  ## Evidence-driven, not vibes-driven
@@ -92,8 +111,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
92
111
 
93
112
  Prototype quality — usable, not hardened:
94
113
 
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`
114
+ - 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
115
  - AGENTS.md is not passed to the classifier as downweighted intent evidence (Claude Code does this)
98
116
  - parallel gray-zone calls are adjudicated serially
99
117
  - 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 +126,7 @@ The name: the three-state **verdict** is the core concept. The UX keeps `/automo
108
126
  ```bash
109
127
  bun install
110
128
  bun run typecheck
111
- bun test # 21 offline stub tests: rules, classifier retry, shadow cache, command semantics
129
+ bun test # 36 offline stub tests: deny floor, user rules, audit regression, classifier retry, shadow cache, commands
112
130
  ```
113
131
 
114
132
  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
 
@@ -28,9 +29,9 @@
28
29
  tool_call
29
30
 
30
31
  ├─ 1. 规则层(确定性,零延迟)
31
- │ ├─ bash:危险正则(完整命令串) deny;白名单(逐段 argv 检查)→ allow
32
- │ ├─ write/edit:路径敏感度 S0–S5(密钥/系统/.git 元数据 → deny;CWD allow)
33
- │ └─ read/grep/find/ls:密钥路径 deny,其余 → allow
32
+ │ ├─ 内置 deny floor:bash 危险正则(完整命令串)+ 路径敏感度 S0–S5
33
+ │ ├─ 用户规则:deny 优先于 allow(正则,见下)
34
+ │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
34
35
 
35
36
  ├─ 2. 灰区 → 模型分类器(默认继承会话模型 —— "自省")
36
37
  │ ├─ 输入:CC 风格 <transcript>(最近 5 条用户消息 + 最近 10 次工具调用,
@@ -76,6 +77,23 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
76
77
  | `PI_AUTO_MODE_MODEL` | — | 模型配置的环境变量形式 |
77
78
  | `PI_AUTO_MODE_DEBUG=1` | 关 | 调试的环境变量形式(flag 优先) |
78
79
 
80
+ ### 用户规则(`config/pi-verdict.json`)
81
+
82
+ ```json
83
+ {
84
+ "allow": ["^ls\\b", "^git (status|log|diff)\\b"],
85
+ "deny": ["rm ", "docker ", "^/etc/"],
86
+ "builtinDenyFloor": true
87
+ }
88
+ ```
89
+
90
+ - `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
91
+ - 匹配目标:bash = **完整命令串**;文件类工具(read/write/edit/grep/find/ls)= **绝对路径**;其余工具(MCP 等)恒走分类器
92
+ - `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在)
93
+ - 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`);修改后新会话生效
94
+
95
+ 为什么没有内置白名单?第三方安全审计(见 [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健全性需要 shell AST 分析——每条内置「永远放行」都是作者维护的安全声明。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
96
+
79
97
  需要 pi ≥ 0.84。交互与非交互(`-p`/json/rpc)会话均支持;非交互模式下 `ask` 降级为 `deny`。
80
98
 
81
99
  ## 证据驱动,不靠直觉
@@ -92,8 +110,7 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
92
110
 
93
111
  原型质量 —— 可用,未硬化:
94
112
 
95
- - bash 分段是朴素切分(无引号/AST 感知);AST 移植方案已[实测否决](research/rule-engine-sim/README.md),真实流量出现敏感路径重定向绕过时重议
96
- - 暂无用户自定义规则;规则种子集在 `extensions/auto-mode.ts` 顶部
113
+ - 设计上无内置白名单([安全审计](research/rule-layer-security-audit.md)与[用户规则](#用户规则configpi-verdictjson));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
97
114
  - AGENTS.md 未作为降权意图证据传入分类器(Claude Code 有此设计)
98
115
  - 并行灰区调用串行裁决
99
116
  - 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
@@ -108,7 +125,7 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
108
125
  ```bash
109
126
  bun install
110
127
  bun run typecheck
111
- bun test # 21 个离线桩测试:规则层 / 分类器重试 / 影子缓存 / 命令语义
128
+ bun test # 36 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
112
129
  ```
113
130
 
114
131
  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 叙述与工具结果),待审查动作固定为最后一行
@@ -23,9 +27,11 @@
23
27
  * PI_AUTO_MODE_MODEL 同上的环境变量形式
24
28
  * --auto-mode-debug 所有裁决(含放行)都弹通知;影子缓存标注同步开启
25
29
  * PI_AUTO_MODE_DEBUG=1 同上的环境变量形式(兼容保留)
30
+ * <agentDir>/config/pi-verdict.json 用户规则:{ allow: [regex], deny: [regex] }
31
+ * 匹配:bash=完整命令串 / 文件工具=绝对路径;新会话生效
26
32
  *
27
33
  * 已知原型简化(见 README「已知限制」):
28
- * - bash 分段是朴素切分(不处理引号内的 | ),无 AST
34
+ * - 无内置 bash 白名单;危险识别依赖正则 floor( AST 解析)——未知形态交分类器
29
35
  * - 裁决缓存暂缓引入(#5 决议):现为影子缓存 observe-only 遥测,积累数据后决断;
30
36
  * 无熔断器(重议信号 = deny 风暴成本失控 / 非交互长期运行)
31
37
  * - 未把 AGENTS.md 作为降权意图证据传入分类器
@@ -34,6 +40,7 @@
34
40
  * research/pi-model-call-and-ref-implementations.md
35
41
  */
36
42
 
43
+ import * as fs from "node:fs";
37
44
  import * as os from "node:os";
38
45
  import * as path from "node:path";
39
46
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -42,32 +49,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
42
49
  // 规则层:bash
43
50
  // ============================================================================
44
51
 
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
52
  /** 危险模式:对完整命令串匹配(覆盖管道/复合命令),命中即 deny(源自研究报告 §4.3) */
72
53
  const BASH_DANGER_RULES: Array<{ id: string; pattern: RegExp; reason: string }> = [
73
54
  { 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 +67,85 @@ const BASH_DANGER_RULES: Array<{ id: string; pattern: RegExp; reason: string }>
86
67
  { id: "fork-bomb", pattern: /:\(\)\s*\{/, reason: "fork 炸弹" },
87
68
  ];
88
69
 
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;
70
+ type RuleVerdict = "allow" | "deny" | "gray";
71
+ interface RuleResult {
72
+ verdict: RuleVerdict;
73
+ reason?: string;
106
74
  }
107
75
 
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;
76
+ function classifyBash(command: string, floorOn: boolean): RuleResult {
77
+ // 内置 deny floor:危险正则对完整命令串匹配;可经 builtinDenyFloor 整体关闭
78
+ if (floorOn) {
79
+ for (const rule of BASH_DANGER_RULES) {
80
+ if (rule.pattern.test(command)) return { verdict: "deny", reason: `规则 ${rule.id}: ${rule.reason}` };
119
81
  }
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
82
  }
83
+ if (!command.trim()) return { verdict: "allow", reason: "空命令" };
84
+ // 无内置白名单(#12):一切非危险命令交用户规则与分类器
85
+ return { verdict: "gray", reason: "无内置白名单" };
147
86
  }
148
87
 
149
- type RuleVerdict = "allow" | "deny" | "gray";
150
- interface RuleResult {
151
- verdict: RuleVerdict;
152
- reason?: string;
88
+ // ============================================================================
89
+ // 用户规则:白名单/黑名单(可配置;#12 审计响应)
90
+ //
91
+ // 配置:<agentDir>/config/pi-verdict.json(尊重 PI_CODING_AGENT_DIR 覆盖):
92
+ // { "allow": ["^ls\\b", "^git (status|log|diff)\\b"], "deny": ["rm ", "^/etc/"] }
93
+ // 匹配目标:bash/powershell = 完整命令串;read/write/edit/grep/find/ls = 解析后绝对路径;
94
+ // 其余工具(MCP/自定义)不参与用户规则,恒走分类器。
95
+ // 优先级:内置 deny floor → 用户 deny → 用户 allow → gray;floor 默认开,可经 builtinDenyFloor:false 关闭。
96
+ // 非法正则跳过并通知(配置错误不导致扩展失效);新会话生效。
97
+ // ============================================================================
98
+
99
+ interface UserRules {
100
+ allow: RegExp[];
101
+ deny: RegExp[];
102
+ /** 内置 deny floor 开关(危险正则 + 路径敏感度 deny),默认 true;关闭后依赖用户规则与分类器 */
103
+ builtinDenyFloor: boolean;
153
104
  }
154
105
 
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}` };
106
+ const EMPTY_RULES: UserRules = { allow: [], deny: [], builtinDenyFloor: true };
107
+
108
+ function userConfigPath(): string {
109
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
110
+ return path.join(agentDir, "config", "pi-verdict.json");
111
+ }
112
+
113
+ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
114
+ _hint: "pi-verdict 用户规则。allow/deny JS 正则数组;deny 优先于 allow;匹配目标:bash=完整命令串,文件工具=绝对路径。builtinDenyFloor=false 可关闭内置危险规则/路径敏感度拦截(风险自担)。修改后新会话生效。",
115
+ allow: ["^ls\\b"],
116
+ deny: [],
117
+ builtinDenyFloor: true,
118
+ }, null, 2)}\n`;
119
+
120
+ /**
121
+ * 加载用户规则。首启生成带注释模板(allow 内示例默认仅 ^ls\b 可用,其余为说明占位);
122
+ * 配置缺失/损坏/字段非法一律回退空规则(安全默认,不失效),非法正则收集回报。
123
+ */
124
+ function loadUserRules(): { rules: UserRules; skipped: string[] } {
125
+ try {
126
+ const p = userConfigPath();
127
+ if (!fs.existsSync(p)) {
128
+ try {
129
+ fs.mkdirSync(path.dirname(p), { recursive: true });
130
+ fs.writeFileSync(p, USER_CONFIG_TEMPLATE);
131
+ } catch { /* 只读环境静默跳过 */ }
132
+ return { rules: EMPTY_RULES, skipped: [] };
133
+ }
134
+ const raw = JSON.parse(fs.readFileSync(p, "utf8")) as { allow?: unknown; deny?: unknown; builtinDenyFloor?: unknown };
135
+ const skipped: string[] = [];
136
+ const compile = (list: unknown): RegExp[] =>
137
+ (Array.isArray(list) ? list : []).filter((x): x is string => typeof x === "string").flatMap((src) => {
138
+ try {
139
+ return [new RegExp(src)];
140
+ } catch {
141
+ skipped.push(src);
142
+ return [];
143
+ }
144
+ });
145
+ return { rules: { allow: compile(raw.allow), deny: compile(raw.deny), builtinDenyFloor: raw.builtinDenyFloor !== false }, skipped };
146
+ } catch {
147
+ return { rules: EMPTY_RULES, skipped: [] };
172
148
  }
173
- return { verdict: "allow" };
174
149
  }
175
150
 
176
151
  // ============================================================================
@@ -184,49 +159,98 @@ function expandHome(p: string): string {
184
159
  const S0_SECRET = [
185
160
  /\.ssh(\/|$)/, /\.aws(\/|$)/, /\.gnupg(\/|$)/, /(^|\/)\.env(\.|$)/, /credentials?(\.|\/|$)/i,
186
161
  /(^|\/)id_rsa/, /\.pem$/, /_history$/, /\.config\/gh(\/|$)/, /\.pi\/agent\/auth\.json$/,
162
+ // V8(安全审计):常见明文凭证文件补全
163
+ /(^|\/)\.netrc$/, /(^|\/)\.npmrc$/, /(^|\/)\.pypirc$/, /(^|\/)\.envrc$/, /(^|\/)\.vault-token$/,
164
+ /\.kube(\/|$)/, /\.docker\/config\.json$/, /\.gem\/credentials$/,
187
165
  ];
188
166
  const S1_SYSTEM = [/^\/etc(\/|$)/, /^\/usr(\/|$)/, /^\/var(\/|$)/, /^\/System(\/|$)/, /(^|\/)authorized_keys$/];
189
167
  const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/, /crontab/, /Library\/LaunchAgents(\/|$)/, /\.config\/systemd(\/|$)/];
190
168
  const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/, /(^|\/)\.gitmodules$/];
191
169
 
192
170
  /** read 类工具:S0 读取即高危(deny),其余读取放行。isWrite: write/edit 走完整分级 */
193
- function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean): RuleResult {
171
+ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean, floorOn: boolean): RuleResult {
194
172
  const abs = path.resolve(cwd, expandHome(rawPath));
195
173
  const hit = (rules: RegExp[]) => rules.some((r) => r.test(abs));
174
+ // floor 关闭时:内置 deny 一律降级 gray(永不升格 allow);非 deny 分支(allow/gray)保持
175
+ const D = floorOn
176
+ ? (reason: string): RuleResult => ({ verdict: "deny", reason })
177
+ : (reason: string): RuleResult => ({ verdict: "gray", reason });
196
178
 
197
- if (hit(S0_SECRET)) return { verdict: "deny", reason: `S0 密钥/凭证路径: ${rawPath}` };
179
+ if (hit(S0_SECRET)) return D(`S0 密钥/凭证路径: ${rawPath}`);
198
180
  if (!isWrite) {
199
181
  if (hit(S1_SYSTEM)) return { verdict: "gray", reason: `读取系统配置路径: ${rawPath}` };
200
182
  return { verdict: "allow" };
201
183
  }
202
- if (hit(S1_SYSTEM)) return { verdict: "deny", reason: `写入系统目录: ${rawPath}` };
203
- if (hit(S3_GIT_META)) return { verdict: "deny", reason: `写入 .git 元数据(可执行代码入口): ${rawPath}` };
184
+ if (hit(S1_SYSTEM)) return D(`写入系统目录: ${rawPath}`);
185
+ if (hit(S3_GIT_META)) return D(`写入 .git 元数据(可执行代码入口): ${rawPath}` );
204
186
  if (hit(S2_USER_RC)) return { verdict: "gray", reason: `写入用户配置/持久化入口: ${rawPath}` };
205
187
  if (abs === cwd || abs.startsWith(cwd + path.sep)) return { verdict: "allow" };
206
188
  return { verdict: "gray", reason: `写入项目目录(CWD)外: ${rawPath}` };
207
189
  }
208
190
 
209
- /** 工具调用 → 规则层裁决。未覆盖的工具(含 MCP/自定义)→ gray,交分类器 */
210
- function classifyByRules(toolName: string, input: Record<string, unknown>, cwd: string): RuleResult {
191
+ /** 用户规则匹配目标:bash/powershell=完整命令串;路径类工具=解析后绝对路径;其余工具不参与 */
192
+ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: string): string | null {
211
193
  switch (toolName) {
212
194
  case "bash":
213
195
  case "powershell":
214
- return classifyBash(String(input.command ?? ""));
196
+ return String(input.command ?? "");
197
+ case "read":
215
198
  case "write":
216
199
  case "edit":
217
- return classifyPath(toolName, String(input.path ?? ""), cwd, true);
200
+ case "grep":
201
+ case "find":
202
+ case "ls": {
203
+ const p = typeof input.path === "string" && input.path ? input.path : null;
204
+ return p ? path.resolve(cwd, expandHome(p)) : null;
205
+ }
206
+ default:
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * 工具调用 → 规则层裁决。裁决序(#12):
213
+ * 1. 内置 base(bash 危险正则 floor / 路径敏感度分级)——deny 即终局(floor 可经 builtinDenyFloor 关闭)
214
+ * 2. 用户黑名单 → deny(优先于白名单)
215
+ * 3. 用户白名单 → allow
216
+ * 4. base(路径类工具的默认 allow/gray;其余 gray)→ 交分类器
217
+ */
218
+ function classifyByRules(toolName: string, input: Record<string, unknown>, cwd: string, user: UserRules): RuleResult {
219
+ let base: RuleResult;
220
+ switch (toolName) {
221
+ case "bash":
222
+ case "powershell":
223
+ base = classifyBash(String(input.command ?? ""), user.builtinDenyFloor);
224
+ break;
225
+ case "write":
226
+ case "edit":
227
+ base = classifyPath(toolName, String(input.path ?? ""), cwd, true, user.builtinDenyFloor);
228
+ break;
218
229
  case "read":
219
- return classifyPath(toolName, String(input.path ?? ""), cwd, false);
230
+ base = classifyPath(toolName, String(input.path ?? ""), cwd, false, user.builtinDenyFloor);
231
+ break;
220
232
  case "grep":
221
233
  case "find":
222
234
  case "ls": {
223
235
  const p = typeof input.path === "string" ? input.path : undefined;
224
- if (p) return classifyPath(toolName, p, cwd, false);
225
- return { verdict: "allow" };
236
+ base = p ? classifyPath(toolName, p, cwd, false, user.builtinDenyFloor) : { verdict: "allow" };
237
+ break;
226
238
  }
227
239
  default:
228
- return { verdict: "gray", reason: `未内置规则覆盖的工具: ${toolName}` };
240
+ base = { verdict: "gray", reason: `未内置规则覆盖的工具: ${toolName}` };
241
+ }
242
+ if (base.verdict === "deny") return base; // 内置 floor:deny 优先于一切用户规则
243
+
244
+ const target = userRuleTarget(toolName, input, cwd);
245
+ if (target !== null) {
246
+ for (const re of user.deny) {
247
+ if (re.test(target)) return { verdict: "deny", reason: `用户黑名单: ${re.source}` };
248
+ }
249
+ for (const re of user.allow) {
250
+ if (re.test(target)) return { verdict: "allow", reason: "用户白名单" };
251
+ }
229
252
  }
253
+ return base;
230
254
  }
231
255
 
232
256
  // ============================================================================
@@ -311,7 +335,7 @@ interface ClassifierOutcome {
311
335
  source: "model" | "fail-closed";
312
336
  }
313
337
 
314
- const CLASSIFIER_TIMEOUT_MS = 15_000;
338
+ const CLASSIFIER_TIMEOUT_MS = 25_000; // 本网关 CC 分类器分布 p90=19.8s(15s 会误杀 ~15%),research/cache-sim 数据
315
339
  const CLASSIFIER_MAX_TOKENS = 512;
316
340
  const CLASSIFIER_RETRY_MAX_TOKENS = 1024; // 防御重试档:覆盖无视 reasoning:off 或轻思考仍超预算的模型
317
341
 
@@ -522,15 +546,21 @@ export default function autoMode(pi: ExtensionAPI) {
522
546
  let enabled = pi.getFlag("auto-mode") !== false;
523
547
  const debug = pi.getFlag("auto-mode-debug") === true || process.env.PI_AUTO_MODE_DEBUG === "1";
524
548
  const shadow = new ShadowCache();
549
+ let userRules: UserRules = loadUserRules().rules;
525
550
 
526
551
  function refreshStatus(ctx: ExtensionContext) {
527
552
  // 双态恒显:on 高亮 / off 暗色(原来 off 直接隐藏,状态不可见)
528
553
  ctx.ui.setStatus("auto-mode", ctx.ui.theme.fg(enabled ? "accent" : "dim", enabled ? "auto mode on" : "auto mode off"));
529
554
  }
530
555
 
531
- // session_start 会重置 shadow(会话内存态,#5 定案)
556
+ // session_start:重置影子缓存(会话内存态,#5 定案)+ 重载用户规则(配置改动新会话生效)
532
557
  pi.on("session_start", async (_event, ctx) => {
533
558
  shadow.reset();
559
+ const loaded = loadUserRules();
560
+ userRules = loaded.rules;
561
+ if (loaded.skipped.length > 0) {
562
+ ctx.ui.notify(`pi-verdict:配置中 ${loaded.skipped.length} 条非法正则已跳过(${userConfigPath()})`, "warning");
563
+ }
534
564
  refreshStatus(ctx);
535
565
  });
536
566
 
@@ -583,7 +613,7 @@ export default function autoMode(pi: ExtensionAPI) {
583
613
  const action = describeAction(event.toolName, input);
584
614
 
585
615
  // 第 1 层:规则
586
- const rule = classifyByRules(event.toolName, input, ctx.cwd);
616
+ const rule = classifyByRules(event.toolName, input, ctx.cwd, userRules);
587
617
  if (rule.verdict === "allow") {
588
618
  if (debug) ctx.ui.notify(`🛡️ allow(规则): ${action}`, "info");
589
619
  return undefined;
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
+ }