pi-verdict 0.6.0 → 0.7.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 +10 -8
- package/README.zh-CN.md +12 -9
- package/extensions/{auto-mode.ts → pi-verdict.ts} +386 -247
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**pi-verdict is a minimal permission gate for [pi](https://pi.dev) in the style of Claude Code's auto mode: every tool call gets checked before it runs — allow, deny, or ask you first.**
|
|
10
10
|
|
|
11
|
-
- Minimal — just
|
|
11
|
+
- Minimal — just 1k+ lines of code
|
|
12
12
|
- Built-in danger rules and your own allow/deny rules settle the clear cases first, at zero latency
|
|
13
13
|
- Everything else goes to a model classifier that sees the conversation context
|
|
14
14
|
- Any uncertainty or failure fails closed; nothing ever runs silently
|
|
@@ -26,19 +26,21 @@ pi-verdict adds the missing gate: a model decides whether each call should run,
|
|
|
26
26
|
|
|
27
27
|
## Design principles
|
|
28
28
|
|
|
29
|
-
A small set of security principles shapes the whole gate — the full statement, with the honest edges, lives in [docs/security-principles.md](docs/security-principles.md):
|
|
30
|
-
|
|
31
29
|
- **Fail closed** — uncertainty produces friction, never permission.
|
|
32
30
|
- **Deterministic floor before AI** — hard denies are never overridden by the classifier or user allow rules.
|
|
33
|
-
- **Semantics over syntax** —
|
|
31
|
+
- **Semantics over syntax** — the classifier judges what an action *does*, not how long it is.
|
|
34
32
|
- **Judgments, not proofs** — a classifier `allow` is an informed opinion; the floor exists because that is all it is.
|
|
35
33
|
- **Minimal trusted input** — no tool results in the transcript (#22), zero path plaintext to the classifier (ADR-0002).
|
|
36
34
|
- **Canonical identity** — lexical + realpath dual-form matching; a workspace-*looking* path is not trusted as one (#20/#21).
|
|
37
35
|
- **The gate guards itself** — self-protection that no configuration can disable (ADR-0001).
|
|
38
36
|
- **A permission gate, not a sandbox** — stack OS isolation on top; this gate never replaces it.
|
|
39
37
|
|
|
38
|
+
Full statement in [docs/security-principles.md](docs/security-principles.md).
|
|
39
|
+
|
|
40
40
|
## Screenshots
|
|
41
41
|
|
|
42
|
+

|
|
43
|
+
|
|
42
44
|

|
|
43
45
|

|
|
44
46
|
|
|
@@ -52,7 +54,7 @@ pi install npm:pi-verdict
|
|
|
52
54
|
omp plugin install npm:pi-verdict
|
|
53
55
|
|
|
54
56
|
# or directly from git — try it once
|
|
55
|
-
pi --extension ./extensions/
|
|
57
|
+
pi --extension ./extensions/pi-verdict.ts
|
|
56
58
|
|
|
57
59
|
```
|
|
58
60
|
|
|
@@ -63,7 +65,7 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
63
65
|
| | pi | omp |
|
|
64
66
|
|---|---|---|
|
|
65
67
|
| install | `pi install npm:pi-verdict` | `omp plugin install npm:pi-verdict` |
|
|
66
|
-
| extension copy | `~/.pi/agent/extensions/` | `~/.omp/
|
|
68
|
+
| extension copy | `~/.pi/agent/extensions/` | `~/.omp/plugins/node_modules/pi-verdict/` (omp 18.1+; ≤18.0: under `agent/`) |
|
|
67
69
|
| user rules | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
|
|
68
70
|
| credential file (S0 hard deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
|
|
69
71
|
|
|
@@ -121,7 +123,7 @@ Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) s
|
|
|
121
123
|
|
|
122
124
|
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).
|
|
123
125
|
|
|
124
|
-
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 self-protection layer that no config can turn off ([ADR-0001](docs/adr/0001-self-protection-layer.md) — gate integrity), a zero-dependency single file ([one readable file](extensions/
|
|
126
|
+
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 self-protection layer that no config can turn off ([ADR-0001](docs/adr/0001-self-protection-layer.md) — gate integrity), a zero-dependency single file ([one readable file](extensions/pi-verdict.ts), still one file on purpose), and the measurement habit — every design decision in this repo is backed by shipped research.
|
|
125
127
|
|
|
126
128
|
## Pipeline
|
|
127
129
|
|
|
@@ -176,7 +178,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
|
|
|
176
178
|
- parallel gray-zone calls are adjudicated serially
|
|
177
179
|
- 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)
|
|
178
180
|
- shadow cache is observe-only by decision; the serving switch is a one-line change once measured hit rates justify it
|
|
179
|
-
- `denyPaths` bash extraction is token-level ([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)): command substitution, base64-embedded paths and external script contents produce no hit signal — those calls fall back to the classifier's existence-hint vigilance. MCP and custom tools bypass the extractor entirely (their gray-zone adjudication still carries the hint). Honest framing, same as the self-protection substring precedent: the deterministic layer is obfuscatable, which is exactly why a hit routes to *you* rather than silently deciding
|
|
181
|
+
- `denyPaths` bash extraction is token-level ([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)): command substitution, base64-embedded paths and external script contents produce no hit signal — those calls fall back to the classifier's existence-hint vigilance. MCP and custom tools bypass the extractor entirely (their gray-zone adjudication still carries the hint). Path normalization is base-tier only (ADR-0002): a nonexistent target written through a symlinked directory rebuilds no real form and produces no hit — that indirection falls to the hint vigilance too (the ancestor-rebuilding tier applies to the self-protection layer and the sensitivity floor, not denyPaths). Honest framing, same as the self-protection substring precedent: the deterministic layer is obfuscatable, which is exactly why a hit routes to *you* rather than silently deciding
|
|
180
182
|
- `denyPaths` bash tokens contain no spaces: a *declared* path containing spaces cannot be spelled in a bash command in a way the extractor sees — `cat "/path with space/x"` splits into two tokens and never hits (file tools still hit, their path is not tokenized). A glob covering the final segment of a base (`cat /proj/pers*` against `denyPaths: ["/proj/personal"]`) also misses — the base's own name never appears literally. Both holes fall back to the classifier's existence hint, alongside substitution/base64 above
|
|
181
183
|
- self-protection bash matching is substring regex — obfuscatable; the tamper-detection backstop catches within-session bypasses, but a cross-session baseline (hash + change confirmation at startup, incl. upgrade UX) is phase 2 per [ADR-0001](docs/adr/0001-self-protection-layer.md)
|
|
182
184
|
- dev checkouts (running the extension from a repo, not `<agentDir>/extensions/`) are not self-protected — the installed copy the *next* normal session loads is only covered by its own sessions' gate
|
package/README.zh-CN.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**pi-verdict 是 [pi](https://pi.dev) 的 Claude Code 风格的 Auto mode 式的极简权限门禁:每次工具调用执行前先过检查——放行、拦截,或先问你。**
|
|
10
10
|
|
|
11
|
-
-
|
|
11
|
+
- 只有1k行左右的极简代码
|
|
12
12
|
- 内置危险规则与你的 allow/deny 规则以零延迟先行裁决明确情形
|
|
13
13
|
- 其余交给携带会话上下文的模型分类器
|
|
14
14
|
- 任何不确定或失败一律 fail-closed, 绝不静默放行
|
|
@@ -22,23 +22,26 @@ pi-verdict 补上这道缺失的门禁, 由模型基于上下文和你的意图
|
|
|
22
22
|
|
|
23
23
|
## 为什么是三态
|
|
24
24
|
|
|
25
|
-
**verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`)
|
|
25
|
+
**verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」——目标是安全的自动化而非最大的自动化:审批疲劳与静默危险执行都是危险。
|
|
26
26
|
|
|
27
27
|
## 设计原则
|
|
28
28
|
|
|
29
|
-
一小组安全设计原则塑形了整个门禁——完整表述(含诚实的边界说明)见 [docs/security-principles.md](docs/security-principles.md):
|
|
30
|
-
|
|
31
29
|
- **Fail closed**——不确定产生摩擦,绝不产生许可。
|
|
32
30
|
- **确定性 floor 先于 AI**——硬 deny 永不被分类器或用户 allow 规则覆盖。
|
|
33
|
-
-
|
|
31
|
+
- **语义优先于语法**——分类器判定的是动作**做什么可能会产生什么安全影响**,而不是命令有多长。
|
|
34
32
|
- **是判断,不是证明**——分类器的 `allow` 是有依据的判断;floor 的存在正因为它仅此而已。
|
|
35
33
|
- **最小化可信输入**——transcript 不含工具结果(#22),分类器零路径明文(ADR-0002)。
|
|
36
34
|
- **规范化身份**——词法 + realpath 双形匹配;「看起来在项目内」的路径不因此被信任(#20/#21)。
|
|
37
35
|
- **门禁守护自身**——任何配置都关不掉的自保护层(ADR-0001)。
|
|
38
36
|
- **是权限门禁,不是沙箱**——请在上面叠加 OS 级隔离;本门禁不替代它。
|
|
39
37
|
|
|
38
|
+
完整表述见 [docs/security-principles.md](docs/security-principles.md):
|
|
39
|
+
|
|
40
|
+
|
|
40
41
|
## 截图
|
|
41
42
|
|
|
43
|
+

|
|
44
|
+
|
|
42
45
|

|
|
43
46
|

|
|
44
47
|
|
|
@@ -52,7 +55,7 @@ pi install npm:pi-verdict
|
|
|
52
55
|
omp plugin install npm:pi-verdict
|
|
53
56
|
|
|
54
57
|
# 或直接从源码 —— 试用一次
|
|
55
|
-
pi --extension ./extensions/
|
|
58
|
+
pi --extension ./extensions/pi-verdict.ts
|
|
56
59
|
|
|
57
60
|
```
|
|
58
61
|
|
|
@@ -63,7 +66,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
63
66
|
| | pi | omp |
|
|
64
67
|
|---|---|---|
|
|
65
68
|
| 安装 | `pi install npm:pi-verdict` | `omp plugin install npm:pi-verdict` |
|
|
66
|
-
| 扩展副本 | `~/.pi/agent/extensions/` | `~/.omp/
|
|
69
|
+
| 扩展副本 | `~/.pi/agent/extensions/` | `~/.omp/plugins/node_modules/pi-verdict/`(omp 18.1+;≤18.0 在 `agent/` 下) |
|
|
67
70
|
| 用户规则 | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
|
|
68
71
|
| 凭据文件(S0 硬 deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
|
|
69
72
|
|
|
@@ -121,7 +124,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
121
124
|
|
|
122
125
|
完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
|
|
123
126
|
|
|
124
|
-
诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖的[可通读单文件](extensions/
|
|
127
|
+
诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖的[可通读单文件](extensions/pi-verdict.ts)(仍刻意单文件)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
|
|
125
128
|
|
|
126
129
|
## 管线
|
|
127
130
|
|
|
@@ -176,7 +179,7 @@ tool_call
|
|
|
176
179
|
- 并行灰区调用串行裁决
|
|
177
180
|
- 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
|
|
178
181
|
- 影子缓存按决议仅观察不生效;实测命中率达标后,生效开关是一行改动
|
|
179
|
-
- `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
|
|
182
|
+
- `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。路径归一化亦为基础档(ADR-0002):经符号链接目录写入尚不存在的目标不重建真实形、不产生命中——该间接路径同样由话术警戒覆盖(祖先重建档只适用于自保护层与路径敏感度 floor,不适用 denyPaths)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
|
|
180
183
|
- `denyPaths` 的 bash token 不含空格:**声明路径本身含空格时**,bash 拼写无法被提取器识别——`cat "/path with space/x"` 被拆成两个 token 永不命中(文件类工具仍命中,其路径不经 token 化)。glob 覆盖基名末段(`denyPaths: ["/proj/personal"]` 时 `cat /proj/pers*`)同样漏过——基名自身从未字面出现。两个洞与上述替换/base64 一样回落到分类器的存在性话术
|
|
181
184
|
- 自保护 bash 匹配是子串正则——可被混淆绕过;变更检测兜底覆盖会话内绕过,跨会话基线(启动时哈希比对与变更确认,含升级 UX)按 ADR-0001 为二期
|
|
182
185
|
- dev checkout(从仓库而非 `<agentDir>/extensions/` 运行扩展)不受自保护——下一个正常会话加载的安装副本只在其自身会话的门禁内受保护
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
* 3. Three-state verdict: allow passes / deny blocks / ask goes to a human
|
|
37
37
|
* (ctx.ui.confirm)
|
|
38
38
|
*
|
|
39
|
+
* Structure: the pipeline is adjudicate() — a zero-UI module returning a Verdict
|
|
40
|
+
* value object (source: rule|protected-path|classifier|fail-closed, plus a
|
|
41
|
+
* `degraded` flag for ask→deny in non-interactive sessions); the tool_call
|
|
42
|
+
* handler maps verdicts to UI (notify/confirm/select) by source × degraded and
|
|
43
|
+
* runs IntegrityWatch (ADR-0001) as a pre-pipeline gate-integrity check.
|
|
44
|
+
*
|
|
39
45
|
* Shadow cache (observe-only, #7): gray-zone verdicts are replayed against a
|
|
40
46
|
* double-key LRU(128) to measure would-be hit rate; recorded, never applied
|
|
41
47
|
* (verdicts always come from the model), accumulating pi field data for the
|
|
@@ -136,6 +142,59 @@ function classifyBash(command: string, floorOn: boolean): RuleResult {
|
|
|
136
142
|
return { verdict: "gray", reason: "no built-in allowlist" };
|
|
137
143
|
}
|
|
138
144
|
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// 规范形:双形匹配两档的唯一实现(纪律见 CONTEXT.md「双形匹配」词条)
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 基础档(ADR-0002):词法绝对形 + 整路径 realpath 形(realpath 解析 symlink
|
|
151
|
+
* 间接;失败——目标不存在、glob token——降级为仅词法形)。denyPaths 与一切
|
|
152
|
+
* 「基址侧」双形集合(cwd 基址、agentDir、安装根、受保护集合、基线快照)走这一档。
|
|
153
|
+
*/
|
|
154
|
+
function baseForms(p: string): string[] {
|
|
155
|
+
const out = [p];
|
|
156
|
+
try {
|
|
157
|
+
const r = fs.realpathSync(p);
|
|
158
|
+
if (r !== p) out.push(r);
|
|
159
|
+
} catch {
|
|
160
|
+
/* 不存在:仅词法形 */
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 祖先重建档(#20):基础形之外,目标尚不存在时自最近存在祖先的 realpath 逐级
|
|
167
|
+
* 重建真实形——symlink 别名即使最终段不存在也暴露其真实位置。误放行代价高的
|
|
168
|
+
* 判定(自保护层、路径敏感度 floor)走这一档;denyPaths 不升档(ADR-0002)。
|
|
169
|
+
*/
|
|
170
|
+
function rebuiltForms(abs: string): string[] {
|
|
171
|
+
const out = new Set<string>([abs]);
|
|
172
|
+
let dir = abs;
|
|
173
|
+
const tail: string[] = [];
|
|
174
|
+
for (;;) {
|
|
175
|
+
try {
|
|
176
|
+
const real = fs.realpathSync(dir);
|
|
177
|
+
out.add(path.join(real, ...tail));
|
|
178
|
+
return [...out];
|
|
179
|
+
} catch {
|
|
180
|
+
const parent = path.dirname(dir);
|
|
181
|
+
if (parent === dir) return [...out];
|
|
182
|
+
tail.unshift(path.basename(dir));
|
|
183
|
+
dir = parent;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Case-insensitive filesystems (default macOS APFS, Windows) compare path strings
|
|
189
|
+
* case-folded; realpath already normalizes case whenever it resolves, this covers
|
|
190
|
+
* the lexical-only forms of nonexistent targets (#21). Linux stays case-sensitive.
|
|
191
|
+
* 折叠比较仅 denyPaths 消费(S-rules 的比较纪律在正则 /i、自保护层在精确匹配
|
|
192
|
+
* ——各自持有,不因本模块统一,见双形匹配词条)。 */
|
|
193
|
+
const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
|
|
194
|
+
const fold = (s: string): string => (CASE_INSENSITIVE_FS ? s.toLowerCase() : s);
|
|
195
|
+
const pathEquals = (a: string, b: string): boolean => fold(a) === fold(b);
|
|
196
|
+
const pathStartsWith = (child: string, base: string): boolean => fold(child).startsWith(fold(base) + path.sep);
|
|
197
|
+
|
|
139
198
|
// ============================================================================
|
|
140
199
|
// 用户规则:白名单/黑名单(可配置;#12 审计响应)
|
|
141
200
|
//
|
|
@@ -217,11 +276,12 @@ const OWN_FILE_PATH: string | null = (() => {
|
|
|
217
276
|
* Resolve the agent directory the gate is anchored to (#35, dual-host):
|
|
218
277
|
* 1. PI_CODING_AGENT_DIR — explicit user override, always wins.
|
|
219
278
|
* 2. Self-anchoring from the extension's own installed path: a copy at
|
|
220
|
-
* <home>/<dot-dir>/agent/(plugins/node_modules/<pkg>/)?extensions/…
|
|
279
|
+
* <home>/<dot-dir>/(agent/)?(plugins/node_modules/<pkg>/)?extensions/…
|
|
221
280
|
* anchors to <home>/<dot-dir>/agent. Covers the pi forms
|
|
222
|
-
* (~/.pi/agent/extensions[/pkg]/…) and the omp npm
|
|
223
|
-
* (~/.omp/agent/plugins/node_modules/<pkg
|
|
224
|
-
*
|
|
281
|
+
* (~/.pi/agent/extensions[/pkg]/…) and the two omp npm layouts:
|
|
282
|
+
* under the agent dir (~/.omp/agent/plugins/node_modules/<pkg>/…) and,
|
|
283
|
+
* since omp 18.1, next to it (~/.omp/plugins/node_modules/<pkg>/…) —
|
|
284
|
+
* omp keeps its config tree under <dot-dir>/agent in both layouts.
|
|
225
285
|
* Deliberately NO host-tree existence probing: on a dual-install machine
|
|
226
286
|
* running under pi, a present ~/.omp must not misroute the gate.
|
|
227
287
|
* 3. Fallback: today's default (~/.pi/agent) — dev checkouts and any
|
|
@@ -232,8 +292,8 @@ const OWN_FILE_PATH: string | null = (() => {
|
|
|
232
292
|
export function resolveAgentDir(ownFile: string | null, home: string, envAgentDir: string | undefined): string {
|
|
233
293
|
if (envAgentDir) return envAgentDir;
|
|
234
294
|
if (ownFile) {
|
|
235
|
-
const anchor = new RegExp(`^${escapeRegExp(home)}(/(\\.[^/]+)/agent/(?:plugins/node_modules/(?:@[^/]+/)?[^/]+/)?extensions/)`);
|
|
236
|
-
for (const f of
|
|
295
|
+
const anchor = new RegExp(`^${escapeRegExp(home)}(/(\\.[^/]+)/(?:agent/)?(?:plugins/node_modules/(?:@[^/]+/)?[^/]+/)?extensions/)`);
|
|
296
|
+
for (const f of baseForms(ownFile)) {
|
|
237
297
|
const m = f.match(anchor);
|
|
238
298
|
if (m) return path.join(home, m[2], "agent");
|
|
239
299
|
}
|
|
@@ -346,38 +406,13 @@ const S1_SYSTEM = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$
|
|
|
346
406
|
const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/i, /crontab/i, /Library\/LaunchAgents(\/|$)/i, /\.config\/systemd(\/|$)/i];
|
|
347
407
|
const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/i, /(^|\/)\.gitmodules$/i];
|
|
348
408
|
|
|
349
|
-
/**
|
|
350
|
-
* All canonical forms of a path for rule matching: the lexical absolute plus,
|
|
351
|
-
* whenever an existing ancestor can be resolved, the form rebuilt from that
|
|
352
|
-
* ancestor's realpath. Read and write targets may both not exist yet — walking
|
|
353
|
-
* up to the nearest existing ancestor means a symlink alias exposes its real
|
|
354
|
-
* form even when the final segments do not exist (#20).
|
|
355
|
-
*/
|
|
356
|
-
function targetForms(abs: string): string[] {
|
|
357
|
-
const out = new Set<string>([abs]);
|
|
358
|
-
let dir = abs;
|
|
359
|
-
const tail: string[] = [];
|
|
360
|
-
for (;;) {
|
|
361
|
-
try {
|
|
362
|
-
const real = fs.realpathSync(dir);
|
|
363
|
-
out.add(path.join(real, ...tail));
|
|
364
|
-
return [...out];
|
|
365
|
-
} catch {
|
|
366
|
-
const parent = path.dirname(dir);
|
|
367
|
-
if (parent === dir) return [...out];
|
|
368
|
-
tail.unshift(path.basename(dir));
|
|
369
|
-
dir = parent;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
409
|
/** read 类工具:S0 读取即高危(deny),其余读取放行。isWrite: write/edit 走完整分级 */
|
|
375
410
|
function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean, floorOn: boolean): RuleResult {
|
|
376
411
|
const abs = path.resolve(cwd, expandHome(rawPath));
|
|
377
412
|
// Dual-form matching (#20): rules test every canonical form of the target —
|
|
378
413
|
// a project-local symlink aliasing ~/.ssh or a .git/hooks dir must not pass
|
|
379
414
|
// the floor on its lexical spelling alone.
|
|
380
|
-
const forms =
|
|
415
|
+
const forms = rebuiltForms(abs);
|
|
381
416
|
const hit = (rules: RegExp[]) => forms.some((f) => rules.some((r) => r.test(f)));
|
|
382
417
|
// floor 关闭时:内置 deny 一律降级 gray(永不升格 allow);非 deny 分支(allow/gray)保持
|
|
383
418
|
const D = floorOn
|
|
@@ -395,7 +430,7 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
|
|
|
395
430
|
// In-cwd write allowance (#20): every canonical form must sit inside the cwd
|
|
396
431
|
// (in either its lexical or real form) — a lexical prefix hit whose real
|
|
397
432
|
// form escapes the project (symlink alias) grades as an outside-cwd write.
|
|
398
|
-
const cwdBases = new Set(
|
|
433
|
+
const cwdBases = new Set(baseForms(cwd));
|
|
399
434
|
const inCwd = (f: string) => [...cwdBases].some((b) => f === b || f.startsWith(b + path.sep));
|
|
400
435
|
if (forms.every(inCwd)) return { verdict: "allow" };
|
|
401
436
|
return { verdict: "gray", reason: `write outside project directory (CWD): ${rawPath}` };
|
|
@@ -454,20 +489,14 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
|
|
|
454
489
|
const BASH_PATH_TOKENS =
|
|
455
490
|
/(?:~|\$HOME)(?:\/[\w.@*-]+)*|\/(?:[\w.@*-]+\/)*[\w.@*-]*|\.{1,2}(?:\/[\w.@*-]+)+|[\w.-]+(?:\/[\w.-]+)+/g;
|
|
456
491
|
|
|
457
|
-
/**
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
|
|
461
|
-
const fold = (s: string): string => (CASE_INSENSITIVE_FS ? s.toLowerCase() : s);
|
|
462
|
-
const pathEquals = (a: string, b: string): boolean => fold(a) === fold(b);
|
|
463
|
-
const pathStartsWith = (child: string, base: string): boolean => fold(child).startsWith(fold(base) + path.sep);
|
|
464
|
-
|
|
465
|
-
/** Normalized forms of one path (lexical + realpath when it exists) for denyPaths comparison */
|
|
492
|
+
/** Normalized forms of one path for denyPaths comparison: base tier only (ADR-0002) —
|
|
493
|
+
* no ancestor rebuild; a nonexistent target under a symlinked dir falls to the
|
|
494
|
+
* classifier + existence hint instead (pinned by a regression test). */
|
|
466
495
|
function denyPathForms(raw: string, cwd: string): string[] {
|
|
467
496
|
if (!raw) return [];
|
|
468
497
|
// denyPaths spellings accept $HOME/ as an alias for ~/ (user-rule targets stay raw strings — no $ expansion there)
|
|
469
498
|
const expanded = expandHome(raw.replace(/^\$HOME(?=\/|$)/, os.homedir()));
|
|
470
|
-
return
|
|
499
|
+
return baseForms(path.resolve(cwd, expanded));
|
|
471
500
|
}
|
|
472
501
|
|
|
473
502
|
/** Normalize the configured denyPaths against one cwd (ADR-0002: anchored once per session, never re-derived) */
|
|
@@ -506,10 +535,9 @@ function hitDenyPaths(toolName: string, input: Record<string, unknown>, cwd: str
|
|
|
506
535
|
// 门禁自身的完整性不受任何配置豁免:builtinDenyFloor:false 只关危险正则与路径
|
|
507
536
|
// 敏感度,关不掉本层;用户 allow 规则亦不可越过。保护对象:
|
|
508
537
|
// - <agentDir>/config/pi-verdict.json(用户规则 = 门禁的判定输入)
|
|
509
|
-
// - 本扩展的安装副本(pi
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
// checkouts are not in scope)
|
|
538
|
+
// - 本扩展的安装副本(pi under <agentDir>/extensions/, omp under
|
|
539
|
+
// plugins/node_modules/<pkg>/ in its config root — install forms listed
|
|
540
|
+
// with resolveAgentDir; dev checkouts are not in scope)
|
|
513
541
|
// 语义:门禁内一切写入按定义均由 agent 发起 → 恒 deny(reason 指引手工编辑);
|
|
514
542
|
// 读放行(读门禁文件无害);用户经编辑器的修改不经门禁,不受影响。
|
|
515
543
|
// bash 侧:命令串正则覆盖字面量/~/\$HOME/\$PI_CODING_AGENT_DIR 变体,可被混淆
|
|
@@ -529,26 +557,6 @@ interface ProtectedSet {
|
|
|
529
557
|
|
|
530
558
|
type WatchKind = "config" | "extension";
|
|
531
559
|
|
|
532
|
-
function tryRealpath(p: string): string {
|
|
533
|
-
try {
|
|
534
|
-
return fs.realpathSync(p);
|
|
535
|
-
} catch {
|
|
536
|
-
return p;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/** 路径的全部规范形:词法绝对 + realpath(存在且不同时追加) */
|
|
541
|
-
function pathForms(p: string): string[] {
|
|
542
|
-
const out = [p];
|
|
543
|
-
try {
|
|
544
|
-
const r = fs.realpathSync(p);
|
|
545
|
-
if (r !== p) out.push(r);
|
|
546
|
-
} catch {
|
|
547
|
-
/* 不存在:仅词法形 */
|
|
548
|
-
}
|
|
549
|
-
return out;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
560
|
function escapeRegExp(s: string): string {
|
|
553
561
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
554
562
|
}
|
|
@@ -556,17 +564,17 @@ function escapeRegExp(s: string): string {
|
|
|
556
564
|
/**
|
|
557
565
|
* 构建受保护集合。
|
|
558
566
|
* ownFile:本模块文件路径(import.meta.url 解析;null = 不可解析,仅保护配置)。
|
|
559
|
-
* The installed copy is protected only when ownFile sits under
|
|
560
|
-
*
|
|
561
|
-
* (
|
|
562
|
-
*
|
|
567
|
+
* The installed copy is protected only when ownFile sits under one of the
|
|
568
|
+
* install roots (forms listed with resolveAgentDir; #35). Dev checkouts
|
|
569
|
+
* (source inside the cwd) are NOT protected — in-project development writes
|
|
570
|
+
* are legitimate daily work (ADR-0001).
|
|
563
571
|
*/
|
|
564
572
|
export function buildProtectedSet(agentDir: string, ownFile: string | null): ProtectedSet {
|
|
565
573
|
const exact = new Set<string>();
|
|
566
574
|
const prefixes = new Set<string>();
|
|
567
575
|
const configPath = path.join(agentDir, "config", "pi-verdict.json");
|
|
568
576
|
const watchBases: Array<{ file: string; kind: WatchKind }> = [{ file: configPath, kind: "config" }];
|
|
569
|
-
for (const f of
|
|
577
|
+
for (const f of baseForms(configPath)) exact.add(f);
|
|
570
578
|
|
|
571
579
|
// 安装副本目标:单文件形态 → 文件本体(exact);npm 目录形态 → 包根目录(prefix)。
|
|
572
580
|
// extRoot 与 ownFile 各取词法/realpath 双形交叉判定,集合同样双形收录——
|
|
@@ -613,35 +621,38 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
613
621
|
watchBases.push({ file: ownFile, kind: "extension" });
|
|
614
622
|
const seenWatch = new Set<string>([ownFile]);
|
|
615
623
|
let pkgRoot: string | null = null;
|
|
616
|
-
// Install roots, lexical + realpath forms (#35):
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
// root
|
|
620
|
-
//
|
|
621
|
-
// npm-dir
|
|
624
|
+
// Install roots, lexical + realpath forms (#35): <agentDir>/extensions
|
|
625
|
+
// (pi) and plugins/node_modules under agentDir or its parent dir (the
|
|
626
|
+
// two omp layouts — see resolveAgentDir for the layout history). The
|
|
627
|
+
// path segments under the matched root name the install target: a file
|
|
628
|
+
// → exact, a package dir (`@scope/pkg` or `pkg`) → prefix, so every
|
|
629
|
+
// npm form gets whole-package-dir protection (#26).
|
|
622
630
|
const extRoots = new Set<string>();
|
|
631
|
+
const agentBases = new Set(baseForms(agentDir));
|
|
632
|
+
const configRootBases = new Set([...agentBases].map((b) => path.dirname(b)));
|
|
623
633
|
for (const seg of [["extensions"], ["plugins", "node_modules"]]) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
extRoots.add(root);
|
|
627
|
-
extRoots.add(tryRealpath(root));
|
|
634
|
+
const bases = seg.length === 2 ? new Set([...agentBases, ...configRootBases]) : agentBases;
|
|
635
|
+
for (const base of bases) {
|
|
636
|
+
for (const root of baseForms(path.join(base, ...seg))) extRoots.add(root);
|
|
628
637
|
}
|
|
629
638
|
}
|
|
630
|
-
const ownForms = new Set(
|
|
639
|
+
const ownForms = new Set(baseForms(ownFile));
|
|
631
640
|
for (const extRoot of extRoots) {
|
|
632
641
|
for (const own of ownForms) {
|
|
633
642
|
if (!own.startsWith(extRoot + path.sep)) continue;
|
|
634
|
-
const
|
|
635
|
-
const singleFile =
|
|
636
|
-
|
|
637
|
-
|
|
643
|
+
const segs = path.relative(extRoot, own).split(path.sep);
|
|
644
|
+
const singleFile = segs.length === 1;
|
|
645
|
+
// npm scopes are two-segment dirs (@scope/pkg): the install
|
|
646
|
+
// target is the package, not the whole scope dir
|
|
647
|
+
const target = singleFile ? own : path.join(extRoot, ...segs.slice(0, segs[0].startsWith("@") ? 2 : 1));
|
|
648
|
+
for (const f of baseForms(target)) {
|
|
638
649
|
(singleFile ? exact : prefixes).add(f);
|
|
639
650
|
extTargets.add(f);
|
|
640
651
|
}
|
|
641
652
|
if (!singleFile && pkgRoot === null) pkgRoot = target;
|
|
642
653
|
}
|
|
643
654
|
}
|
|
644
|
-
// one walk of the package root (lexical form; takeSnapshots'
|
|
655
|
+
// one walk of the package root (lexical form; takeSnapshots' baseForms
|
|
645
656
|
// expansion picks up real forms per file) — no duplicate entries
|
|
646
657
|
if (pkgRoot !== null) {
|
|
647
658
|
for (const f of listPackageFiles(pkgRoot)) {
|
|
@@ -665,7 +676,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
665
676
|
alts.add("\\$HOME/" + escapeRegExp(rel));
|
|
666
677
|
}
|
|
667
678
|
// $PI_CODING_AGENT_DIR 变体:词法与 realpath 两种基名列举(符号链接目录容忍)
|
|
668
|
-
for (const base of new Set(
|
|
679
|
+
for (const base of new Set(baseForms(agentDir))) {
|
|
669
680
|
if (f.startsWith(base + path.sep)) {
|
|
670
681
|
alts.add("\\$PI_CODING_AGENT_DIR/" + escapeRegExp(f.slice(base.length + 1)));
|
|
671
682
|
}
|
|
@@ -682,7 +693,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
682
693
|
* nearest existing ancestor, #20) */
|
|
683
694
|
export function isProtectedWritePath(rawPath: string, cwd: string, prot: ProtectedSet): boolean {
|
|
684
695
|
if (!rawPath) return false;
|
|
685
|
-
for (const c of
|
|
696
|
+
for (const c of rebuiltForms(path.resolve(cwd, expandHome(rawPath)))) {
|
|
686
697
|
if (prot.exact.includes(c)) return true;
|
|
687
698
|
for (const p of prot.prefixes) {
|
|
688
699
|
if (c === p || c.startsWith(p + path.sep)) return true;
|
|
@@ -692,7 +703,7 @@ export function isProtectedWritePath(rawPath: string, cwd: string, prot: Protect
|
|
|
692
703
|
}
|
|
693
704
|
|
|
694
705
|
/** 自保护层裁决(第 0 层,先于一切):触碰门禁自身文件 → 不可豁免的 deny;其余 null 交后续层 */
|
|
695
|
-
|
|
706
|
+
function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
|
|
696
707
|
switch (toolName) {
|
|
697
708
|
case "write":
|
|
698
709
|
case "edit":
|
|
@@ -722,7 +733,7 @@ function takeSnapshots(bases: Array<{ file: string; kind: WatchKind }>): Array<{
|
|
|
722
733
|
const out: Array<{ file: string; kind: WatchKind; content: Buffer | null }> = [];
|
|
723
734
|
const seen = new Set<string>();
|
|
724
735
|
for (const b of bases) {
|
|
725
|
-
for (const f of
|
|
736
|
+
for (const f of baseForms(b.file)) {
|
|
726
737
|
if (seen.has(f)) continue;
|
|
727
738
|
seen.add(f);
|
|
728
739
|
let content: Buffer | null = null;
|
|
@@ -737,6 +748,68 @@ function takeSnapshots(bases: Array<{ file: string; kind: WatchKind }>): Array<{
|
|
|
737
748
|
return out;
|
|
738
749
|
}
|
|
739
750
|
|
|
751
|
+
/**
|
|
752
|
+
* 变更检测(ADR-0001 一期)的会话实例:基线快照 + 篡改旗标。处置差分(config-only
|
|
753
|
+
* 且有 UI → select 双选)由扩展 handler 编排——本类零 UI;restoreAndFailClose 只做
|
|
754
|
+
* 还原与置位,通知由调用方按返回的文件清单/原因拼装。
|
|
755
|
+
*/
|
|
756
|
+
class IntegrityWatch {
|
|
757
|
+
private snapshots: Array<{ file: string; kind: WatchKind; content: Buffer | null }>;
|
|
758
|
+
private _tampered = false;
|
|
759
|
+
|
|
760
|
+
constructor(private watchBases: Array<{ file: string; kind: WatchKind }>) {
|
|
761
|
+
this.snapshots = takeSnapshots(watchBases);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
get tampered(): boolean {
|
|
765
|
+
return this._tampered;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** 复核受保护文件,返回变化清单(不处置——处置按 kind 差分,ADR-0001 定稿 D) */
|
|
769
|
+
detect(): Array<{ file: string; kind: WatchKind }> {
|
|
770
|
+
const hit: Array<{ file: string; kind: WatchKind }> = [];
|
|
771
|
+
for (const s of this.snapshots) {
|
|
772
|
+
let current: Buffer | null = null;
|
|
773
|
+
try {
|
|
774
|
+
current = fs.readFileSync(s.file);
|
|
775
|
+
} catch {
|
|
776
|
+
/* 不存在 */
|
|
777
|
+
}
|
|
778
|
+
const same = (a: Buffer | null, b: Buffer | null): boolean => (a === null || b === null ? a === b : a.equals(b));
|
|
779
|
+
if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
|
|
780
|
+
}
|
|
781
|
+
return hit;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** 重建基线(Accept 路径与 session_start 共用;不动篡改旗标) */
|
|
785
|
+
rebaseline(): void {
|
|
786
|
+
this.snapshots = takeSnapshots(this.watchBases);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** 会话重置:重建基线 + 清篡改旗标 */
|
|
790
|
+
startSession(): void {
|
|
791
|
+
this.rebaseline();
|
|
792
|
+
this._tampered = false;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** 从快照回写变化文件(扩展进程自身执行,不经门禁)+ fail-closed 置位 */
|
|
796
|
+
restoreAndFailClose(changed: Array<{ file: string }>, cause: string): { reason: string; files: string } {
|
|
797
|
+
for (const c of changed) {
|
|
798
|
+
const s = this.snapshots.find((x) => x.file === c.file);
|
|
799
|
+
if (s && s.content !== null) {
|
|
800
|
+
try {
|
|
801
|
+
fs.writeFileSync(s.file, s.content);
|
|
802
|
+
} catch {
|
|
803
|
+
/* 还原失败:仍 fail-closed */
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
this._tampered = true;
|
|
808
|
+
const files = [...new Set(changed.map((c) => c.file))].join(", ");
|
|
809
|
+
return { reason: `[auto-mode] self-protection: tamper detected${cause ? ` (${cause})` : ""} and restored (${files}); fail-closed until restart`, files };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
740
813
|
/**
|
|
741
814
|
* Tool call → rule-layer verdict. Order (#12; ADR-0001 adds layer 0; ADR-0002 inserts denyPaths):
|
|
742
815
|
* 0. self-protection — deny is terminal (no config exempts it, not even builtinDenyFloor:false)
|
|
@@ -846,15 +919,19 @@ function toolCallLine(name: string, args: Record<string, unknown>): string {
|
|
|
846
919
|
return `${name}: ${transcriptSafe(JSON.stringify(args))}`;
|
|
847
920
|
}
|
|
848
921
|
|
|
922
|
+
/** 判定管线对宿主会话的最小结构需求(转录源 + 会话 id)——adjudicate 不接完整
|
|
923
|
+
* ExtensionContext,测试只喂这两个成员即可 */
|
|
924
|
+
export type PipelineHost = Pick<ExtensionContext["sessionManager"], "getBranch" | "getSessionId">;
|
|
925
|
+
|
|
849
926
|
/**
|
|
850
927
|
* 从会话分支收集精简转录原料:user 消息行与 assistant 工具调用行。
|
|
851
928
|
* 丢弃 assistant 叙述/thinking 与 toolResult(注入面与 token 大头)。
|
|
852
929
|
* 影子缓存的 contextKey 与 buildTranscript 同源(同一批 user 行),保证键与模型输入一致。
|
|
853
930
|
*/
|
|
854
|
-
function collectTranscriptParts(
|
|
931
|
+
function collectTranscriptParts(host: PipelineHost): { userLines: string[]; toolLines: string[] } {
|
|
855
932
|
const userLines: string[] = [];
|
|
856
933
|
const toolLines: string[] = [];
|
|
857
|
-
for (const entry of
|
|
934
|
+
for (const entry of host.getBranch()) {
|
|
858
935
|
if (entry.type !== "message") continue;
|
|
859
936
|
const msg = entry.message;
|
|
860
937
|
if (msg.role === "user") {
|
|
@@ -870,8 +947,8 @@ function collectTranscriptParts(ctx: ExtensionContext): { userLines: string[]; t
|
|
|
870
947
|
}
|
|
871
948
|
|
|
872
949
|
/** 精简转录:最近 user 消息 + 最近工具调用,待审查动作固定为最后一行(位置约定,借鉴 CC) */
|
|
873
|
-
function buildTranscript(
|
|
874
|
-
const { userLines, toolLines } = collectTranscriptParts(
|
|
950
|
+
function buildTranscript(host: PipelineHost, actionLine: string): string {
|
|
951
|
+
const { userLines, toolLines } = collectTranscriptParts(host);
|
|
875
952
|
const lines = [...userLines.slice(-MAX_USER_MESSAGES), ...toolLines.slice(-MAX_TOOL_CALLS)];
|
|
876
953
|
lines.push(actionLine);
|
|
877
954
|
return lines.join("\n");
|
|
@@ -944,18 +1021,22 @@ function completionFor(registry: { complete?: unknown }, compatLoader?: CompatLo
|
|
|
944
1021
|
return fn;
|
|
945
1022
|
}
|
|
946
1023
|
|
|
1024
|
+
/** 分类器思考级别(pi 原生词表;后缀语法对齐 pi --model provider/id:thinking) */
|
|
1025
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
1026
|
+
|
|
947
1027
|
/** 单次分类器调用:显式 reasoning:"off"(见下方注释),失败返回错误串而非抛出 */
|
|
948
1028
|
async function callClassifierOnce(
|
|
949
|
-
|
|
1029
|
+
host: PipelineHost,
|
|
1030
|
+
signal: AbortSignal | undefined,
|
|
950
1031
|
complete: CompletionFn,
|
|
951
1032
|
model: NonNullable<ExtensionContext["model"]>,
|
|
952
1033
|
userMessage: string,
|
|
953
1034
|
maxTokens: number,
|
|
954
|
-
thinking:
|
|
1035
|
+
thinking: ThinkingLevel = "off",
|
|
955
1036
|
systemPrompt: string = CLASSIFIER_SYSTEM,
|
|
956
1037
|
): Promise<{ ok: true; text: string; stopReason: string } | { ok: false; error: string }> {
|
|
957
1038
|
const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
|
|
958
|
-
if (
|
|
1039
|
+
if (signal) signals.push(signal);
|
|
959
1040
|
try {
|
|
960
1041
|
const response = await complete(
|
|
961
1042
|
model,
|
|
@@ -987,7 +1068,7 @@ async function callClassifierOnce(
|
|
|
987
1068
|
reasoning: thinking === "minimal" ? ("low" as const) : thinking,
|
|
988
1069
|
}),
|
|
989
1070
|
cacheRetention: "short",
|
|
990
|
-
sessionId:
|
|
1071
|
+
sessionId: host.getSessionId(),
|
|
991
1072
|
},
|
|
992
1073
|
);
|
|
993
1074
|
const text = response.content
|
|
@@ -1007,21 +1088,22 @@ async function callClassifierOnce(
|
|
|
1007
1088
|
* 两档皆失败 → fail-closed deny(理由含两次诊断)。
|
|
1008
1089
|
*/
|
|
1009
1090
|
async function classifyWithModel(
|
|
1010
|
-
|
|
1091
|
+
host: PipelineHost,
|
|
1092
|
+
signal: AbortSignal | undefined,
|
|
1011
1093
|
complete: CompletionFn,
|
|
1012
1094
|
model: NonNullable<ExtensionContext["model"]>,
|
|
1013
1095
|
actionLine: string,
|
|
1014
|
-
thinking:
|
|
1096
|
+
thinking: ThinkingLevel = "off",
|
|
1015
1097
|
denyPathsActive = false,
|
|
1016
1098
|
): Promise<ClassifierOutcome> {
|
|
1017
|
-
const transcript = buildTranscript(
|
|
1099
|
+
const transcript = buildTranscript(host, actionLine);
|
|
1018
1100
|
const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
|
|
1019
1101
|
const systemPrompt = denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
|
|
1020
1102
|
const attempts: Array<[number, number]> = [[1, CLASSIFIER_MAX_TOKENS], [2, CLASSIFIER_RETRY_MAX_TOKENS]];
|
|
1021
1103
|
const failures: string[] = [];
|
|
1022
1104
|
for (const [n, maxTokens] of attempts) {
|
|
1023
|
-
if (
|
|
1024
|
-
const r = await callClassifierOnce(
|
|
1105
|
+
if (signal?.aborted) break; // 用户已取消,不再重试
|
|
1106
|
+
const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt);
|
|
1025
1107
|
if (r.ok) {
|
|
1026
1108
|
const diag = `stopReason=${r.stopReason}, model=${model.id}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
|
|
1027
1109
|
if (r.stopReason !== "error" && r.stopReason !== "aborted") {
|
|
@@ -1146,8 +1228,8 @@ function shadowCommandKey(toolName: string, input: Record<string, unknown>, cwd:
|
|
|
1146
1228
|
return fnv1a(`${toolName}\u0000${JSON.stringify(input)}\u0000${cwd}`);
|
|
1147
1229
|
}
|
|
1148
1230
|
|
|
1149
|
-
function shadowContextKey(
|
|
1150
|
-
const { userLines } = collectTranscriptParts(
|
|
1231
|
+
function shadowContextKey(host: PipelineHost): string {
|
|
1232
|
+
const { userLines } = collectTranscriptParts(host);
|
|
1151
1233
|
return fnv1a(userLines.slice(-MAX_USER_MESSAGES).join("\u0000"));
|
|
1152
1234
|
}
|
|
1153
1235
|
|
|
@@ -1157,6 +1239,121 @@ function shadowTag(probe: ShadowProbe): string {
|
|
|
1157
1239
|
return `(shadow cache: miss:no-entry)`;
|
|
1158
1240
|
}
|
|
1159
1241
|
|
|
1242
|
+
// ============================================================================
|
|
1243
|
+
// 会话态:判定管线的会话期状态(复位清单集中一处)
|
|
1244
|
+
// ============================================================================
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* 判定管线的会话期状态。session_start 的复位清单归 reset() 拥有——新增会话态只改
|
|
1248
|
+
* 这里,install 与 session_start 不再各持一份初始化点。prot 源自安装路径而非配置,
|
|
1249
|
+
* 构造期定,不参与 reset。导出仅为测试(内部 seam 的测试面,与 adjudicate 同组)。
|
|
1250
|
+
*/
|
|
1251
|
+
export class SessionState {
|
|
1252
|
+
readonly prot: ProtectedSet;
|
|
1253
|
+
readonly shadow = new ShadowCache();
|
|
1254
|
+
userRules: UserRules;
|
|
1255
|
+
private denyPathBases: string[] | null = null;
|
|
1256
|
+
|
|
1257
|
+
constructor(prot: ProtectedSet, userRules: UserRules = loadUserRules().rules) {
|
|
1258
|
+
this.prot = prot;
|
|
1259
|
+
this.userRules = userRules;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** 会话重置:重载用户规则(配置改动新会话生效)+ 按会话 cwd 重锚 denyPaths
|
|
1263
|
+
* (ADR-0002: 每会话锚定一次)+ 清影子缓存;返回加载报告供表现层通知 */
|
|
1264
|
+
reset(cwd: string): { skipped: string[]; shortcutWarning: string | null } {
|
|
1265
|
+
const loaded = loadUserRules();
|
|
1266
|
+
this.userRules = loaded.rules;
|
|
1267
|
+
this.denyPathBases = anchorDenyPaths(loaded.rules.denyPaths, cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1268
|
+
this.shadow.reset();
|
|
1269
|
+
return { skipped: loaded.skipped, shortcutWarning: loaded.shortcutWarning };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** denyPaths 基址:session_start 已锚定;此惰性回退仅守护乱序的首次 tool_call
|
|
1273
|
+
* (pi 正常次序 session_start 先行),一旦锚定不再重derive。 */
|
|
1274
|
+
anchoredDenyPathBases(cwd: string): string[] {
|
|
1275
|
+
if (this.denyPathBases === null) this.denyPathBases = anchorDenyPaths(this.userRules.denyPaths, cwd);
|
|
1276
|
+
return this.denyPathBases;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ============================================================================
|
|
1281
|
+
// 判定管线(adjudicate):tool_call → Verdict 的唯一裁决入口,零 UI 依赖
|
|
1282
|
+
// ============================================================================
|
|
1283
|
+
|
|
1284
|
+
/** 裁决来源:呈现模板的键之一(与 degraded 正交分解)。rule = 规则层(含自保护层
|
|
1285
|
+
* ——同走规则呈现模板);protected-path = denyPaths 命中;classifier = 灰区分类器
|
|
1286
|
+
* 结果(含其 fail-closed——呈现模板相同);fail-closed = 无可用分类器模型 */
|
|
1287
|
+
export type VerdictSource = "rule" | "protected-path" | "classifier" | "fail-closed";
|
|
1288
|
+
|
|
1289
|
+
/** 判定管线的输出值对象:一次 tool_call 的完整裁决。detail 为 UI-only 明文(受保护
|
|
1290
|
+
* 路径仅入本地确认框,ADR-0002 零泄漏承诺——reason 与通知永不携带);degraded 标记
|
|
1291
|
+
* ask 在无 UI 会话的降级产物;shadow 为影子缓存标注(仅 debug 呈现拼接用)。 */
|
|
1292
|
+
export interface Verdict {
|
|
1293
|
+
verdict: "allow" | "ask" | "deny";
|
|
1294
|
+
reason: string;
|
|
1295
|
+
detail?: string;
|
|
1296
|
+
source: VerdictSource;
|
|
1297
|
+
degraded: boolean;
|
|
1298
|
+
shadow?: string;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** 逐调用环境:呈现无关的宿主能力。model 经 getModel 惰性求值——保持「仅灰区才
|
|
1302
|
+
* 解析」的原行为(回退警告不会出现在规则已裁决的调用上);null → fail-closed。 */
|
|
1303
|
+
export interface AdjudicateEnv {
|
|
1304
|
+
cwd: string;
|
|
1305
|
+
hasUI: boolean;
|
|
1306
|
+
getModel: () => { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null;
|
|
1307
|
+
complete: CompletionFn;
|
|
1308
|
+
host: PipelineHost;
|
|
1309
|
+
signal?: AbortSignal;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* 判定管线(CONTEXT.md「判定管线」词条的实现):自保护 → 内置 floor → 用户 deny →
|
|
1314
|
+
* denyPaths ask → 用户 allow → 灰区分类器;ask 降级(无 UI → deny)与 fail-closed
|
|
1315
|
+
* 内建于此,两处重复的降级实现自此唯一。零 UI:表现(notify/confirm/select)由扩展
|
|
1316
|
+
* handler 按 source × degraded 模板呈现;变更检测(IntegrityWatch)是管线前置的
|
|
1317
|
+
* 独立关注点,不在 adjudicate 内。导出仅为测试(内部 seam 的测试面,#35 既有模式)。
|
|
1318
|
+
*/
|
|
1319
|
+
export async function adjudicate(
|
|
1320
|
+
state: SessionState,
|
|
1321
|
+
call: { toolName: string; input: Record<string, unknown> },
|
|
1322
|
+
env: AdjudicateEnv,
|
|
1323
|
+
): Promise<Verdict> {
|
|
1324
|
+
const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
|
|
1325
|
+
if (rule.verdict === "allow") return { verdict: "allow", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1326
|
+
if (rule.verdict === "deny") return { verdict: "deny", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1327
|
+
if (rule.verdict === "ask") {
|
|
1328
|
+
// denyPaths 命中 → ask 终局(ADR-0002):声明者本人裁决例外;无 UI 降级为 deny
|
|
1329
|
+
return { verdict: env.hasUI ? "ask" : "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: !env.hasUI };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// 灰区 → 分类器;无可用模型 → fail-closed
|
|
1333
|
+
const resolved = env.getModel();
|
|
1334
|
+
if (!resolved) return { verdict: "deny", reason: "no classifier model available (fail-closed)", source: "fail-closed", degraded: false };
|
|
1335
|
+
|
|
1336
|
+
// 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
|
|
1337
|
+
const cmdKey = shadowCommandKey(call.toolName, call.input, env.cwd);
|
|
1338
|
+
const ctxKey = shadowContextKey(env.host);
|
|
1339
|
+
const probe = state.shadow.probe(cmdKey, ctxKey);
|
|
1340
|
+
|
|
1341
|
+
const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, toolCallLine(call.toolName, call.input), resolved.thinking, state.userRules.denyPaths.length > 0);
|
|
1342
|
+
|
|
1343
|
+
// 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
|
|
1344
|
+
// 命中且本次为可缓存裁决时,对比反事实一致性
|
|
1345
|
+
if (outcome.source === "model" && outcome.verdict !== "ask") {
|
|
1346
|
+
if (probe.result === "hit") state.shadow.countDivergence(probe.entry.verdict, outcome.verdict);
|
|
1347
|
+
state.shadow.record(cmdKey, ctxKey, outcome.verdict);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const shadow = shadowTag(probe);
|
|
1351
|
+
if (outcome.verdict === "allow") return { verdict: "allow", reason: outcome.reason, source: "classifier", degraded: false, shadow };
|
|
1352
|
+
if (outcome.verdict === "deny") return { verdict: "deny", reason: outcome.reason, source: "classifier", degraded: false, shadow };
|
|
1353
|
+
// ask:无 UI 降级为 deny(ask 降级,CONTEXT.md 词条)
|
|
1354
|
+
return { verdict: env.hasUI ? "ask" : "deny", reason: outcome.reason, source: "classifier", degraded: !env.hasUI, shadow };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1160
1357
|
// ============================================================================
|
|
1161
1358
|
// 扩展主体
|
|
1162
1359
|
// ============================================================================
|
|
@@ -1173,56 +1370,58 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1173
1370
|
|
|
1174
1371
|
let enabled = pi.getFlag("auto-mode") !== false;
|
|
1175
1372
|
const debug = pi.getFlag("auto-mode-debug") === true || process.env.PI_AUTO_MODE_DEBUG === "1";
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
};
|
|
1187
|
-
|
|
1188
|
-
// Self-protection layer (ADR-0001): self-anchored protected set + tamper
|
|
1189
|
-
// baseline (in-memory, per session)
|
|
1190
|
-
const prot = buildProtectedSet(agentDirPath(), OWN_FILE_PATH);
|
|
1191
|
-
let snapshots = takeSnapshots(prot.watchBases);
|
|
1192
|
-
let tampered = false;
|
|
1373
|
+
// 会话态与门禁完整性监视:复位清单各归 SessionState.reset / IntegrityWatch.startSession
|
|
1374
|
+
const state = new SessionState(buildProtectedSet(agentDirPath(), OWN_FILE_PATH));
|
|
1375
|
+
const integrity = new IntegrityWatch(state.prot.watchBases);
|
|
1376
|
+
|
|
1377
|
+
/** 篡改处置呈现:还原 + fail-closed 的本地通知(含文件清单与原因) */
|
|
1378
|
+
function presentTamper(changed: Array<{ file: string; kind: WatchKind }>, ctx: ExtensionContext, cause: string): { block: true; reason: string } {
|
|
1379
|
+
const r = integrity.restoreAndFailClose(changed, cause);
|
|
1380
|
+
ctx.ui.notify(`🛡️ pi-verdict TAMPER DETECTED${cause ? ` (${cause})` : ""}: ${r.files} modified bypassing the gate; restored from session snapshot where possible. Fail-closed for the rest of this session — review the file(s) and restart the session.`, "warning");
|
|
1381
|
+
return { block: true, reason: r.reason };
|
|
1382
|
+
}
|
|
1193
1383
|
|
|
1194
|
-
/**
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1384
|
+
/** Verdict → UI(本扩展唯一的裁决呈现点):按 source × degraded 查模板,文案与
|
|
1385
|
+
* 重构前逐字节一致。受保护路径分支的通知永不携带路径明文与 action 行
|
|
1386
|
+
* (ADR-0002 story 11:通知与 block reason 回流 agent context)。 */
|
|
1387
|
+
async function presentVerdict(v: Verdict, action: string, ctx: ExtensionContext): Promise<{ block: true; reason: string } | undefined> {
|
|
1388
|
+
if (v.verdict === "allow") {
|
|
1389
|
+
if (debug) {
|
|
1390
|
+
if (v.source === "rule") ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
|
|
1391
|
+
else if (v.source === "protected-path") ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
|
|
1392
|
+
else ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}${v.shadow ? " " + v.shadow : ""}`, "info");
|
|
1203
1393
|
}
|
|
1204
|
-
|
|
1205
|
-
if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
|
|
1394
|
+
return undefined;
|
|
1206
1395
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
}
|
|
1396
|
+
if (v.verdict === "deny") {
|
|
1397
|
+
if (v.source === "protected-path") {
|
|
1398
|
+
// 无 action 行:action 串可内嵌被触路径,通知不得携带受保护路径明文
|
|
1399
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, protected-path ask→deny): ${v.reason}`, "warning");
|
|
1400
|
+
return { block: true, reason: `[auto-mode] protected-path ask degraded to block in non-interactive mode: ${v.reason}` };
|
|
1401
|
+
}
|
|
1402
|
+
if (v.source === "fail-closed") {
|
|
1403
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
|
|
1404
|
+
return { block: true, reason: `[auto-mode] ${v.reason}` };
|
|
1405
|
+
}
|
|
1406
|
+
if (v.source === "rule") {
|
|
1407
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
|
|
1408
|
+
return { block: true, reason: `[auto-mode rule block] ${v.reason}` };
|
|
1220
1409
|
}
|
|
1410
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}${debug && v.shadow ? " " + v.shadow : ""}`, "warning");
|
|
1411
|
+
return { block: true, reason: `[auto-mode classifier block] ${v.reason}` };
|
|
1221
1412
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1413
|
+
// ask → 人工确认;非交互已在管线内降级,能走到这里的必有 UI
|
|
1414
|
+
if (v.source === "protected-path") {
|
|
1415
|
+
const ok = await ctx.ui.confirm("🛡️ Auto Mode: protected path", `${action}\n\n${v.reason}\n\nProtected path: ${v.detail ?? "(see pi-verdict.json)"}\n\nAllow this access?`);
|
|
1416
|
+
if (ok) {
|
|
1417
|
+
// debug notify 不带 action 行:同上,通知不得携带受保护路径明文
|
|
1418
|
+
if (debug) ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
|
|
1419
|
+
return undefined;
|
|
1420
|
+
}
|
|
1421
|
+
return { block: true, reason: "[auto-mode] user declined protected-path access" };
|
|
1422
|
+
}
|
|
1423
|
+
const ok = await ctx.ui.confirm("🛡️ Auto Mode confirmation", `${action}\n\nClassifier opinion: ${v.reason}\n\nAllow execution?`);
|
|
1424
|
+
return ok ? undefined : { block: true, reason: "[auto-mode] user declined" };
|
|
1226
1425
|
}
|
|
1227
1426
|
|
|
1228
1427
|
function refreshStatus(ctx: ExtensionContext) {
|
|
@@ -1240,23 +1439,19 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1240
1439
|
// session_start:重置影子缓存(会话内存态,#5 定案)+ 重载用户规则(配置改动新会话生效)
|
|
1241
1440
|
// + 重建自保护基线(ADR-0001:受保护文件的会话启动快照)
|
|
1242
1441
|
pi.on("session_start", async (_event, ctx) => {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
denyPathBases = anchorDenyPaths(userRules.denyPaths, ctx.cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1248
|
-
snapshots = takeSnapshots(prot.watchBases);
|
|
1249
|
-
if (loaded.skipped.length > 0) {
|
|
1250
|
-
ctx.ui.notify(`pi-verdict: skipped ${loaded.skipped.length} invalid config value(s) in config (${userConfigPath()}): ${loaded.skipped.join(", ")}`, "warning");
|
|
1442
|
+
const report = state.reset(ctx.cwd);
|
|
1443
|
+
integrity.startSession();
|
|
1444
|
+
if (report.skipped.length > 0) {
|
|
1445
|
+
ctx.ui.notify(`pi-verdict: skipped ${report.skipped.length} invalid config value(s) in config (${userConfigPath()}): ${report.skipped.join(", ")}`, "warning");
|
|
1251
1446
|
}
|
|
1252
|
-
if (
|
|
1447
|
+
if (report.shortcutWarning) ctx.ui.notify(`pi-verdict: ${report.shortcutWarning}`, "warning");
|
|
1253
1448
|
refreshStatus(ctx);
|
|
1254
1449
|
});
|
|
1255
1450
|
|
|
1256
1451
|
// 主开关 toggle 快捷键(#15):键位取首次加载的用户规则(会话内固定——改配置后
|
|
1257
1452
|
// /reload 重载扩展或新会话生效);handler 与 /automode 语义等价,静默切换,
|
|
1258
1453
|
// footer 始终显示是唯一反馈
|
|
1259
|
-
const registeredToggleKey = userRules.toggleShortcut;
|
|
1454
|
+
const registeredToggleKey = state.userRules.toggleShortcut;
|
|
1260
1455
|
if (registeredToggleKey) {
|
|
1261
1456
|
// KeyId 是 pi 的编译期联合类型(运行时即 string);用户配置键位经 KEY_COMBO_RE
|
|
1262
1457
|
// 运行时校验后断言转入,零依赖约束下不引入 pi 内部类型路径
|
|
@@ -1269,7 +1464,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1269
1464
|
/** Usage 行的 toggle 提示(#15):无注册键位时不显示;显示注册时固定的键 */
|
|
1270
1465
|
const toggleHint = () => (registeredToggleKey ? ` · toggle: ${registeredToggleKey}` : "");
|
|
1271
1466
|
/** Status line denyPaths count (ADR-0002): shown only when configured */
|
|
1272
|
-
const denyPathsHint = () => (userRules.denyPaths.length > 0 ? `\ndenyPaths: ${userRules.denyPaths.length} active` : "");
|
|
1467
|
+
const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
|
|
1273
1468
|
|
|
1274
1469
|
pi.registerCommand("automode", {
|
|
1275
1470
|
description: "Show Auto Mode status and shadow-cache stats, or set it: /automode on|off",
|
|
@@ -1277,7 +1472,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1277
1472
|
const arg = args.trim().toLowerCase();
|
|
1278
1473
|
// 裸调用:只读状态展示,无副作用(含影子缓存统计行)
|
|
1279
1474
|
if (arg === "") {
|
|
1280
|
-
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${shadow.summary()}${denyPathsHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1475
|
+
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1281
1476
|
return;
|
|
1282
1477
|
}
|
|
1283
1478
|
// 幂等设定:与现值相同不翻转,仅确认
|
|
@@ -1288,7 +1483,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1288
1483
|
const head = next
|
|
1289
1484
|
? `🛡️ Auto Mode enabled${changed ? "" : " (unchanged)"}: tool calls adjudicated by rules + classifier`
|
|
1290
1485
|
: `Auto Mode disabled${changed ? "" : " (unchanged)"}: tool calls execute directly`;
|
|
1291
|
-
ctx.ui.notify(`${head}\n${shadow.summary()}`, "info");
|
|
1486
|
+
ctx.ui.notify(`${head}\n${state.shadow.summary()}`, "info");
|
|
1292
1487
|
return;
|
|
1293
1488
|
}
|
|
1294
1489
|
// 未知参数:严格拒绝并列出用法(大小写已归一化)
|
|
@@ -1314,28 +1509,29 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1314
1509
|
return { specPart: raw, level: null };
|
|
1315
1510
|
}
|
|
1316
1511
|
|
|
1317
|
-
/**
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1512
|
+
/** 解析分类器模型与思考级别:CLI flag > 环境变量 > 配置文件(classifierModel) >
|
|
1513
|
+
* 自省(会话模型)。不可用回退会话模型并警告一次;null = 连会话模型都没有 →
|
|
1514
|
+
* fail-closed。经 AdjudicateEnv.getModel 惰性调用(仅灰区),回退警告不会出现在
|
|
1515
|
+
* 规则已裁决的调用上。 */
|
|
1516
|
+
function resolveClassifier(ctx: ExtensionContext): { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null {
|
|
1322
1517
|
const raw =
|
|
1323
|
-
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? userRules.classifierModel;
|
|
1324
|
-
|
|
1518
|
+
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
|
|
1519
|
+
let thinking: ThinkingLevel = "off";
|
|
1325
1520
|
if (raw) {
|
|
1326
1521
|
const { specPart, level } = parseModelSpec(raw, ctx);
|
|
1327
|
-
|
|
1522
|
+
thinking = (level ?? "off") as ThinkingLevel;
|
|
1328
1523
|
const slash = specPart.indexOf("/");
|
|
1329
1524
|
if (slash > 0) {
|
|
1330
1525
|
const model = ctx.modelRegistry.find(specPart.slice(0, slash), specPart.slice(slash + 1));
|
|
1331
|
-
if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return model;
|
|
1526
|
+
if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return { model, thinking };
|
|
1332
1527
|
}
|
|
1333
1528
|
if (!warnedClassifierModel) {
|
|
1334
1529
|
warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
|
|
1335
1530
|
ctx.ui.notify(`pi-verdict: classifier model "${raw}" unavailable (not found or no configured auth), falling back to session model (self-reflection)`, "warning");
|
|
1336
1531
|
}
|
|
1337
1532
|
}
|
|
1338
|
-
|
|
1533
|
+
// 自省:继承当前会话模型;显式指定的思考级别在回退时仍生效(原语义)
|
|
1534
|
+
return ctx.model ? { model: ctx.model, thinking } : null;
|
|
1339
1535
|
}
|
|
1340
1536
|
|
|
1341
1537
|
function describeAction(toolName: string, input: Record<string, unknown>): string {
|
|
@@ -1349,11 +1545,11 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1349
1545
|
const action = describeAction(event.toolName, input);
|
|
1350
1546
|
|
|
1351
1547
|
// 第 0 层前置:变更检测(ADR-0001)——篡改后本会话恒 deny(fail-closed)
|
|
1352
|
-
if (tampered) {
|
|
1548
|
+
if (integrity.tampered) {
|
|
1353
1549
|
ctx.ui.notify(`🛡️ Auto Mode blocked: self-protection fail-closed (tamper detected this session; restart to reset)\n ${action}`, "warning");
|
|
1354
1550
|
return { block: true, reason: "[auto-mode] self-protection: fail-closed until session restart (protected file was tampered with)" };
|
|
1355
1551
|
}
|
|
1356
|
-
const changed =
|
|
1552
|
+
const changed = integrity.detect();
|
|
1357
1553
|
if (changed.length > 0) {
|
|
1358
1554
|
// 差分处置(ADR-0001 定稿 D):仅 config 变化且有 UI → select 双选(选项即动作);
|
|
1359
1555
|
// 扩展副本被改 / 无 UI → 一律还原 + fail-closed。
|
|
@@ -1368,82 +1564,25 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1368
1564
|
[CONFIG_ACCEPT_CHOICE, CONFIG_DECLINE_CHOICE],
|
|
1369
1565
|
);
|
|
1370
1566
|
if (choice === CONFIG_ACCEPT_CHOICE) {
|
|
1371
|
-
|
|
1567
|
+
integrity.rebaseline(); // 重建基线
|
|
1372
1568
|
ctx.ui.notify("pi-verdict: config change accepted — new baseline taken; applies to new sessions as usual", "info");
|
|
1373
1569
|
} else {
|
|
1374
|
-
return
|
|
1570
|
+
return presentTamper(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
|
|
1375
1571
|
}
|
|
1376
1572
|
} else {
|
|
1377
|
-
return
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
// 第 1 层:规则
|
|
1382
|
-
const rule = classifyByRules(event.toolName, input, ctx.cwd, userRules, prot, anchoredDenyPathBases(ctx.cwd));
|
|
1383
|
-
if (rule.verdict === "allow") {
|
|
1384
|
-
if (debug) ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
|
|
1385
|
-
return undefined;
|
|
1386
|
-
}
|
|
1387
|
-
if (rule.verdict === "deny") {
|
|
1388
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked: ${rule.reason}\n ${action}`, "warning");
|
|
1389
|
-
return { block: true, reason: `[auto-mode rule block] ${rule.reason}` };
|
|
1390
|
-
}
|
|
1391
|
-
// denyPaths hit → deterministic ask (ADR-0002): the declaring user adjudicates
|
|
1392
|
-
// the exception; non-interactive sessions degrade to deny (existing ask rule)
|
|
1393
|
-
if (rule.verdict === "ask") {
|
|
1394
|
-
if (!ctx.hasUI) {
|
|
1395
|
-
// no action line here: the action string can embed the touched path, and
|
|
1396
|
-
// notifications must not carry protected-path plaintext (ADR-0002 story 11)
|
|
1397
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, protected-path ask→deny): ${rule.reason}`, "warning");
|
|
1398
|
-
return { block: true, reason: `[auto-mode] protected-path ask degraded to block in non-interactive mode: ${rule.reason}` };
|
|
1399
|
-
}
|
|
1400
|
-
const ok = await ctx.ui.confirm("🛡️ Auto Mode: protected path", `${action}\n\n${rule.reason}\n\nProtected path: ${rule.detail ?? "(see pi-verdict.json)"}\n\nAllow this access?`);
|
|
1401
|
-
if (ok) {
|
|
1402
|
-
// debug notify stays plaintext-free too: the action line can embed the
|
|
1403
|
-
// touched path, and notifications must not carry protected-path plaintext
|
|
1404
|
-
if (debug) ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
|
|
1405
|
-
return undefined;
|
|
1573
|
+
return presentTamper(changed, ctx, "");
|
|
1406
1574
|
}
|
|
1407
|
-
return { block: true, reason: "[auto-mode] user declined protected-path access" };
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
// 第 2 层:灰区 → 模型分类器
|
|
1411
|
-
const model = resolveClassifierModel(ctx);
|
|
1412
|
-
if (!model) {
|
|
1413
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked: no classifier model available (fail-closed)\n ${action}`, "warning");
|
|
1414
|
-
return { block: true, reason: "[auto-mode] no classifier model available (fail-closed)" };
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
// 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
|
|
1418
|
-
const cmdKey = shadowCommandKey(event.toolName, input, ctx.cwd);
|
|
1419
|
-
const ctxKey = shadowContextKey(ctx);
|
|
1420
|
-
const probe = shadow.probe(cmdKey, ctxKey);
|
|
1421
|
-
|
|
1422
|
-
const outcome = await classifyWithModel(ctx, completionFor(ctx.modelRegistry, deps.compatLoader), model, action, classifierThinking, userRules.denyPaths.length > 0);
|
|
1423
|
-
|
|
1424
|
-
// 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
|
|
1425
|
-
// 命中且本次为可缓存裁决时,对比反事实一致性
|
|
1426
|
-
if (outcome.source === "model" && outcome.verdict !== "ask") {
|
|
1427
|
-
if (probe.result === "hit") shadow.countDivergence(probe.entry.verdict, outcome.verdict);
|
|
1428
|
-
shadow.record(cmdKey, ctxKey, outcome.verdict);
|
|
1429
1575
|
}
|
|
1430
1576
|
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
ctx.
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
if (!ctx.hasUI) {
|
|
1442
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, ask→deny): ${outcome.reason}\n ${action}`, "warning");
|
|
1443
|
-
return { block: true, reason: `[auto-mode] ask degraded to block in non-interactive mode: ${outcome.reason}` };
|
|
1444
|
-
}
|
|
1445
|
-
const ok = await ctx.ui.confirm("🛡️ Auto Mode confirmation", `${action}\n\nClassifier opinion: ${outcome.reason}\n\nAllow execution?`);
|
|
1446
|
-
if (ok) return undefined;
|
|
1447
|
-
return { block: true, reason: "[auto-mode] user declined" };
|
|
1577
|
+
// 判定管线(零 UI)→ 呈现(source × degraded 模板)
|
|
1578
|
+
const verdict = await adjudicate(state, { toolName: event.toolName, input }, {
|
|
1579
|
+
cwd: ctx.cwd,
|
|
1580
|
+
hasUI: !!ctx.hasUI,
|
|
1581
|
+
getModel: () => resolveClassifier(ctx),
|
|
1582
|
+
complete: completionFor(ctx.modelRegistry, deps.compatLoader),
|
|
1583
|
+
host: ctx.sessionManager,
|
|
1584
|
+
signal: ctx.signal,
|
|
1585
|
+
});
|
|
1586
|
+
return presentVerdict(verdict, action, ctx);
|
|
1448
1587
|
});
|
|
1449
1588
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-verdict",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A minimal permission gate for Pi in the style of Claude Code's auto mode",
|
|
5
5
|
"author": "Jesset (https://github.com/jesset)",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"main": "extensions/
|
|
7
|
+
"main": "extensions/pi-verdict.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"extensions/
|
|
9
|
+
"extensions/pi-verdict.ts",
|
|
10
10
|
"README.md",
|
|
11
11
|
"README.zh-CN.md",
|
|
12
12
|
"LICENSE"
|