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 +25 -7
- package/README.zh-CN.md +24 -7
- package/extensions/auto-mode.ts +149 -119
- 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
|
|
|
@@ -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
|
-
│ ├─
|
|
32
|
-
│
|
|
33
|
-
│
|
|
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
|
-
-
|
|
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 #
|
|
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)
|
|
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
|
|
|
@@ -28,9 +29,9 @@
|
|
|
28
29
|
tool_call
|
|
29
30
|
│
|
|
30
31
|
├─ 1. 规则层(确定性,零延迟)
|
|
31
|
-
│ ├─ bash
|
|
32
|
-
│ ├─
|
|
33
|
-
│ └─
|
|
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
|
-
-
|
|
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 #
|
|
128
|
+
bun test # 36 个离线桩测试:deny floor / 用户规则 / 审计回归 / 分类器重试 / 影子缓存 / 命令
|
|
112
129
|
```
|
|
113
130
|
|
|
114
131
|
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 叙述与工具结果),待审查动作固定为最后一行
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
203
|
-
if (hit(S3_GIT_META)) return
|
|
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
|
-
/**
|
|
210
|
-
function
|
|
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
|
|
196
|
+
return String(input.command ?? "");
|
|
197
|
+
case "read":
|
|
215
198
|
case "write":
|
|
216
199
|
case "edit":
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
236
|
+
base = p ? classifyPath(toolName, p, cwd, false, user.builtinDenyFloor) : { verdict: "allow" };
|
|
237
|
+
break;
|
|
226
238
|
}
|
|
227
239
|
default:
|
|
228
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
+
}
|