pi-verdict 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/README.zh-CN.md +13 -6
- package/extensions/{auto-mode.ts → pi-verdict.ts} +401 -238
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ pi install npm:pi-verdict
|
|
|
54
54
|
omp plugin install npm:pi-verdict
|
|
55
55
|
|
|
56
56
|
# or directly from git — try it once
|
|
57
|
-
pi --extension ./extensions/
|
|
57
|
+
pi --extension ./extensions/pi-verdict.ts
|
|
58
58
|
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -89,7 +89,14 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
89
89
|
{
|
|
90
90
|
"allow": ["^ls\\b", "^git (status|log|diff)\\b"],
|
|
91
91
|
"deny": ["rm ", "docker ", "^/etc/"],
|
|
92
|
-
"denyPaths": [
|
|
92
|
+
"denyPaths": [
|
|
93
|
+
"~/.ssh/",
|
|
94
|
+
"~/.profile",
|
|
95
|
+
"~/.gnupg",
|
|
96
|
+
"~/.mc",
|
|
97
|
+
"~/.zshrc",
|
|
98
|
+
"~/.bashrc"
|
|
99
|
+
],
|
|
93
100
|
"builtinDenyFloor": true,
|
|
94
101
|
"classifierModel": null,
|
|
95
102
|
"toggleShortcut": "ctrl+shift+a"
|
|
@@ -97,7 +104,7 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
97
104
|
```
|
|
98
105
|
|
|
99
106
|
- `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
|
|
100
|
-
- `denyPaths` are plain paths you declare **protected** — touches trigger a terminal ask you adjudicate (non-interactive → deny); the classifier never learns the paths themselves, only that they exist
|
|
107
|
+
- `denyPaths` are plain paths you declare **protected** — touches trigger a terminal ask you adjudicate (non-interactive → deny); the classifier never learns the paths themselves, only that they exist. `grep`/`find`/`ls` compare their whole **search scope**: an omitted `path` (pi's default: the current directory) or a parent directory of a declared path triggers the ask as well. A fresh install pre-fills a **starter list** (`~/.ssh/`, `~/.gnupg`, `~/.mc`, shell rc/profile files), active from the first session after the initial run (any config change applies to new sessions) — a pre-filled *user declaration*, not a built-in floor: edit or empty it freely, add your own (`~/Documents/private`, …) alongside; existing configs are never rewritten
|
|
101
108
|
- `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
|
|
102
109
|
- `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
|
|
103
110
|
|
|
@@ -123,7 +130,7 @@ Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) s
|
|
|
123
130
|
|
|
124
131
|
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).
|
|
125
132
|
|
|
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/
|
|
133
|
+
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.
|
|
127
134
|
|
|
128
135
|
## Pipeline
|
|
129
136
|
|
|
@@ -178,8 +185,8 @@ Design decisions here are settled by measurement, and the lab notes ship with th
|
|
|
178
185
|
- parallel gray-zone calls are adjudicated serially
|
|
179
186
|
- 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)
|
|
180
187
|
- shadow cache is observe-only by decision; the serving switch is a one-line change once measured hit rates justify it
|
|
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). 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
|
|
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.
|
|
188
|
+
- `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
|
|
189
|
+
- `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. A recursive search issued from a shell misses in both spellings — no path argument (defaults to the cwd, e.g. a bare `rg foo`) or a parent-directory argument (`rg foo <parent-of-a-declared-path>`): an argument-less command contributes no token at all and bash tokens otherwise compare one-directionally, while the file tools' bidirectional subtree compare covers the same shapes issued through `grep`/`find`/`ls`. All three holes fall back to the classifier's existence hint, alongside substitution/base64 above
|
|
183
190
|
- 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)
|
|
184
191
|
- 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
|
|
185
192
|
|
package/README.zh-CN.md
CHANGED
|
@@ -55,7 +55,7 @@ pi install npm:pi-verdict
|
|
|
55
55
|
omp plugin install npm:pi-verdict
|
|
56
56
|
|
|
57
57
|
# 或直接从源码 —— 试用一次
|
|
58
|
-
pi --extension ./extensions/
|
|
58
|
+
pi --extension ./extensions/pi-verdict.ts
|
|
59
59
|
|
|
60
60
|
```
|
|
61
61
|
|
|
@@ -90,7 +90,14 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
90
90
|
{
|
|
91
91
|
"allow": ["^ls\\b", "^git (status|log|diff)\\b"],
|
|
92
92
|
"deny": ["rm ", "docker ", "^/etc/"],
|
|
93
|
-
"denyPaths": [
|
|
93
|
+
"denyPaths": [
|
|
94
|
+
"~/.ssh/",
|
|
95
|
+
"~/.profile",
|
|
96
|
+
"~/.gnupg",
|
|
97
|
+
"~/.mc",
|
|
98
|
+
"~/.zshrc",
|
|
99
|
+
"~/.bashrc"
|
|
100
|
+
],
|
|
94
101
|
"builtinDenyFloor": true,
|
|
95
102
|
"classifierModel": null,
|
|
96
103
|
"toggleShortcut": "ctrl+shift+a"
|
|
@@ -98,7 +105,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
98
105
|
```
|
|
99
106
|
|
|
100
107
|
- `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
|
|
101
|
-
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny)
|
|
108
|
+
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件),自初次运行后的第一个会话起生效(一切配置变更均自新会话生效)——它是预填的*用户声明*而非内置 floor:可随意增删清空,也可与自己的路径(`~/Documents/private`、……)并列;既有配置永不被改写
|
|
102
109
|
- `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
|
|
103
110
|
- `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
|
|
104
111
|
|
|
@@ -124,7 +131,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
124
131
|
|
|
125
132
|
完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
|
|
126
133
|
|
|
127
|
-
诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖的[可通读单文件](extensions/
|
|
134
|
+
诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖的[可通读单文件](extensions/pi-verdict.ts)(仍刻意单文件)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
|
|
128
135
|
|
|
129
136
|
## 管线
|
|
130
137
|
|
|
@@ -179,8 +186,8 @@ tool_call
|
|
|
179
186
|
- 并行灰区调用串行裁决
|
|
180
187
|
- 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
|
|
181
188
|
- 影子缓存按决议仅观察不生效;实测命中率达标后,生效开关是一行改动
|
|
182
|
-
- `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
|
|
183
|
-
- `denyPaths` 的 bash token 不含空格:**声明路径本身含空格时**,bash 拼写无法被提取器识别——`cat "/path with space/x"` 被拆成两个 token 永不命中(文件类工具仍命中,其路径不经 token 化)。glob 覆盖基名末段(`denyPaths: ["/proj/personal"]` 时 `cat /proj/pers*`)
|
|
189
|
+
- `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。路径归一化亦为基础档(ADR-0002):经符号链接目录写入尚不存在的目标不重建真实形、不产生命中——该间接路径同样由话术警戒覆盖(祖先重建档只适用于自保护层与路径敏感度 floor,不适用 denyPaths)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
|
|
190
|
+
- `denyPaths` 的 bash token 不含空格:**声明路径本身含空格时**,bash 拼写无法被提取器识别——`cat "/path with space/x"` 被拆成两个 token 永不命中(文件类工具仍命中,其路径不经 token 化)。glob 覆盖基名末段(`denyPaths: ["/proj/personal"]` 时 `cat /proj/pers*`)同样漏过——基名自身从未字面出现。经 shell 发起的递归搜索在两种拼写下都漏过——不带路径参数(默认搜 cwd,如裸 `rg foo`)或带父目录参数(`rg foo <声明路径的父目录>`):无参命令根本不产生 token,带参时 bash token 只做单向比较;同一形状经 `grep`/`find`/`ls` 工具发起则由双向子树比较覆盖。三个洞与上述替换/base64 一样回落到分类器的存在性话术
|
|
184
191
|
- 自保护 bash 匹配是子串正则——可被混淆绕过;变更检测兜底覆盖会话内绕过,跨会话基线(启动时哈希比对与变更确认,含升级 UX)按 ADR-0001 为二期
|
|
185
192
|
- dev checkout(从仓库而非 `<agentDir>/extensions/` 运行扩展)不受自保护——下一个正常会话加载的安装副本只在其自身会话的门禁内受保护
|
|
186
193
|
|
|
@@ -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
|
//
|
|
@@ -234,7 +293,7 @@ export function resolveAgentDir(ownFile: string | null, home: string, envAgentDi
|
|
|
234
293
|
if (envAgentDir) return envAgentDir;
|
|
235
294
|
if (ownFile) {
|
|
236
295
|
const anchor = new RegExp(`^${escapeRegExp(home)}(/(\\.[^/]+)/(?:agent/)?(?:plugins/node_modules/(?:@[^/]+/)?[^/]+/)?extensions/)`);
|
|
237
|
-
for (const f of
|
|
296
|
+
for (const f of baseForms(ownFile)) {
|
|
238
297
|
const m = f.match(anchor);
|
|
239
298
|
if (m) return path.join(home, m[2], "agent");
|
|
240
299
|
}
|
|
@@ -251,10 +310,17 @@ function userConfigPath(): string {
|
|
|
251
310
|
}
|
|
252
311
|
|
|
253
312
|
const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
254
|
-
_hint: "pi-verdict user rules. allow/deny are JS regex arrays; deny wins over allow. Match target: bash = full command string, file tools = absolute path. denyPaths is a list of protected path prefixes (plain paths, not regexes; the tool owns normalization — ~, $HOME, relative, .. and symlink forms all resolve, case folds on macOS/Windows — and any access attempt, including from bash command strings, asks for your confirmation, degrading to deny in non-interactive sessions; priority: after your deny rules, before your allow rules; never sent to the classifier). builtinDenyFloor=false disables the built-in danger/path floor (at your own risk; the self-protection layer always stays on and cannot be turned off by any config). classifierModel persistently sets the classifier model (provider/id, e.g. zai/glm-5.3-flash; accepts a pi-native thinking suffix, e.g. zai/glm-5.3-flash:low; empty = self-reflection, inherit session model). toggleShortcut sets the master-switch toggle key (pi key combo, e.g. ctrl+shift+a; null or empty disables the shortcut). This file is part of the permission gate itself: pi-verdict denies any agent-side modification of it — edit it manually outside pi. Changes apply to new sessions.",
|
|
313
|
+
_hint: "pi-verdict user rules. allow/deny are JS regex arrays; deny wins over allow. Match target: bash = full command string, file tools = absolute path. denyPaths is a list of protected path prefixes (plain paths, not regexes; the tool owns normalization — ~, $HOME, relative, .. and symlink forms all resolve, case folds on macOS/Windows — and any access attempt, including from bash command strings, asks for your confirmation, degrading to deny in non-interactive sessions; priority: after your deny rules, before your allow rules; never sent to the classifier). The template pre-fills a starter denyPaths list (~/.ssh, ~/.gnupg, shell rc files) — edit or empty it freely, it is your declaration, not a built-in floor. builtinDenyFloor=false disables the built-in danger/path floor (at your own risk; the self-protection layer always stays on and cannot be turned off by any config). classifierModel persistently sets the classifier model (provider/id, e.g. zai/glm-5.3-flash; accepts a pi-native thinking suffix, e.g. zai/glm-5.3-flash:low; empty = self-reflection, inherit session model). toggleShortcut sets the master-switch toggle key (pi key combo, e.g. ctrl+shift+a; null or empty disables the shortcut). This file is part of the permission gate itself: pi-verdict denies any agent-side modification of it — edit it manually outside pi. Changes apply to new sessions.",
|
|
255
314
|
allow: ["^ls\\b"],
|
|
256
315
|
deny: [],
|
|
257
|
-
denyPaths: [
|
|
316
|
+
denyPaths: [
|
|
317
|
+
"~/.ssh/",
|
|
318
|
+
"~/.profile",
|
|
319
|
+
"~/.gnupg",
|
|
320
|
+
"~/.mc",
|
|
321
|
+
"~/.zshrc",
|
|
322
|
+
"~/.bashrc",
|
|
323
|
+
],
|
|
258
324
|
builtinDenyFloor: true,
|
|
259
325
|
classifierModel: null,
|
|
260
326
|
toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
|
|
@@ -347,38 +413,13 @@ const S1_SYSTEM = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$
|
|
|
347
413
|
const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/i, /crontab/i, /Library\/LaunchAgents(\/|$)/i, /\.config\/systemd(\/|$)/i];
|
|
348
414
|
const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/i, /(^|\/)\.gitmodules$/i];
|
|
349
415
|
|
|
350
|
-
/**
|
|
351
|
-
* All canonical forms of a path for rule matching: the lexical absolute plus,
|
|
352
|
-
* whenever an existing ancestor can be resolved, the form rebuilt from that
|
|
353
|
-
* ancestor's realpath. Read and write targets may both not exist yet — walking
|
|
354
|
-
* up to the nearest existing ancestor means a symlink alias exposes its real
|
|
355
|
-
* form even when the final segments do not exist (#20).
|
|
356
|
-
*/
|
|
357
|
-
function targetForms(abs: string): string[] {
|
|
358
|
-
const out = new Set<string>([abs]);
|
|
359
|
-
let dir = abs;
|
|
360
|
-
const tail: string[] = [];
|
|
361
|
-
for (;;) {
|
|
362
|
-
try {
|
|
363
|
-
const real = fs.realpathSync(dir);
|
|
364
|
-
out.add(path.join(real, ...tail));
|
|
365
|
-
return [...out];
|
|
366
|
-
} catch {
|
|
367
|
-
const parent = path.dirname(dir);
|
|
368
|
-
if (parent === dir) return [...out];
|
|
369
|
-
tail.unshift(path.basename(dir));
|
|
370
|
-
dir = parent;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
416
|
/** read 类工具:S0 读取即高危(deny),其余读取放行。isWrite: write/edit 走完整分级 */
|
|
376
417
|
function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean, floorOn: boolean): RuleResult {
|
|
377
418
|
const abs = path.resolve(cwd, expandHome(rawPath));
|
|
378
419
|
// Dual-form matching (#20): rules test every canonical form of the target —
|
|
379
420
|
// a project-local symlink aliasing ~/.ssh or a .git/hooks dir must not pass
|
|
380
421
|
// the floor on its lexical spelling alone.
|
|
381
|
-
const forms =
|
|
422
|
+
const forms = rebuiltForms(abs);
|
|
382
423
|
const hit = (rules: RegExp[]) => forms.some((f) => rules.some((r) => r.test(f)));
|
|
383
424
|
// floor 关闭时:内置 deny 一律降级 gray(永不升格 allow);非 deny 分支(allow/gray)保持
|
|
384
425
|
const D = floorOn
|
|
@@ -396,7 +437,7 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
|
|
|
396
437
|
// In-cwd write allowance (#20): every canonical form must sit inside the cwd
|
|
397
438
|
// (in either its lexical or real form) — a lexical prefix hit whose real
|
|
398
439
|
// form escapes the project (symlink alias) grades as an outside-cwd write.
|
|
399
|
-
const cwdBases = new Set(
|
|
440
|
+
const cwdBases = new Set(baseForms(cwd));
|
|
400
441
|
const inCwd = (f: string) => [...cwdBases].some((b) => f === b || f.startsWith(b + path.sep));
|
|
401
442
|
if (forms.every(inCwd)) return { verdict: "allow" };
|
|
402
443
|
return { verdict: "gray", reason: `write outside project directory (CWD): ${rawPath}` };
|
|
@@ -425,13 +466,23 @@ function toolKind(toolName: string): "command" | "file" | null {
|
|
|
425
466
|
}
|
|
426
467
|
}
|
|
427
468
|
|
|
428
|
-
/**
|
|
469
|
+
/** Scope tools (grep/find/ls): pi's schema makes `path` optional (default:
|
|
470
|
+
* current directory) and the search covers a directory SUBTREE — an omitted or
|
|
471
|
+
* empty path means the cwd is the effective target (#48). */
|
|
472
|
+
function isScopeTool(toolName: string): boolean {
|
|
473
|
+
return toolName === "grep" || toolName === "find" || toolName === "ls";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 用户规则匹配目标:bash/powershell=完整命令串;路径类工具=解析后绝对路径;其余工具不参与。
|
|
477
|
+
* Scope tools with an omitted path resolve to the cwd (#48) — user rules match
|
|
478
|
+
* the effective target, never a null that skips the whole rule block. */
|
|
429
479
|
function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: string): string | null {
|
|
430
480
|
const kind = toolKind(toolName);
|
|
431
481
|
if (kind === "command") return String(input.command ?? "");
|
|
432
482
|
if (kind === "file") {
|
|
433
483
|
const p = typeof input.path === "string" && input.path ? input.path : null;
|
|
434
|
-
|
|
484
|
+
if (!p) return isScopeTool(toolName) ? path.resolve(cwd) : null;
|
|
485
|
+
return path.resolve(cwd, expandHome(p));
|
|
435
486
|
}
|
|
436
487
|
return null;
|
|
437
488
|
}
|
|
@@ -444,7 +495,9 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
|
|
|
444
495
|
// ~ / $HOME expansion, lexical resolve against cwd, realpath resolution of
|
|
445
496
|
// symlink indirection (failure — nonexistent target, glob token — degrades to
|
|
446
497
|
// the lexical form). Comparison is per path segment, both sides in dual form
|
|
447
|
-
// (lexical + realpath).
|
|
498
|
+
// (lexical + realpath). Scope tools (grep/find/ls) are subtree-scoped and
|
|
499
|
+
// bidirectional (#48): an omitted path means the cwd, and a declaration that
|
|
500
|
+
// sits INSIDE the searched subtree hits as well. The extractor is an evidence producer, never an
|
|
448
501
|
// adjudicator: a hit routes to a terminal ask (the declaring user owns the
|
|
449
502
|
// exception); non-interactive sessions degrade to deny. External script
|
|
450
503
|
// contents are never read (unsound by construction, ADR-0002); the classifier
|
|
@@ -455,32 +508,29 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
|
|
|
455
508
|
const BASH_PATH_TOKENS =
|
|
456
509
|
/(?:~|\$HOME)(?:\/[\w.@*-]+)*|\/(?:[\w.@*-]+\/)*[\w.@*-]*|\.{1,2}(?:\/[\w.@*-]+)+|[\w.-]+(?:\/[\w.-]+)+/g;
|
|
457
510
|
|
|
458
|
-
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
|
|
462
|
-
const fold = (s: string): string => (CASE_INSENSITIVE_FS ? s.toLowerCase() : s);
|
|
463
|
-
const pathEquals = (a: string, b: string): boolean => fold(a) === fold(b);
|
|
464
|
-
const pathStartsWith = (child: string, base: string): boolean => fold(child).startsWith(fold(base) + path.sep);
|
|
465
|
-
|
|
466
|
-
/** Normalized forms of one path (lexical + realpath when it exists) for denyPaths comparison */
|
|
511
|
+
/** Normalized forms of one path for denyPaths comparison: base tier only (ADR-0002) —
|
|
512
|
+
* no ancestor rebuild; a nonexistent target under a symlinked dir falls to the
|
|
513
|
+
* classifier + existence hint instead (pinned by a regression test). */
|
|
467
514
|
function denyPathForms(raw: string, cwd: string): string[] {
|
|
468
515
|
if (!raw) return [];
|
|
469
516
|
// denyPaths spellings accept $HOME/ as an alias for ~/ (user-rule targets stay raw strings — no $ expansion there)
|
|
470
517
|
const expanded = expandHome(raw.replace(/^\$HOME(?=\/|$)/, os.homedir()));
|
|
471
|
-
return
|
|
518
|
+
return baseForms(path.resolve(cwd, expanded));
|
|
472
519
|
}
|
|
473
520
|
|
|
474
521
|
/** Normalize the configured denyPaths against one cwd (ADR-0002: anchored once per session, never re-derived) */
|
|
475
522
|
const anchorDenyPaths = (paths: string[], cwd: string): string[] => paths.flatMap((b) => denyPathForms(b, cwd));
|
|
476
523
|
|
|
477
|
-
/** Every path candidate a tool call exposes to denyPaths comparison (MCP/custom tools: none — classifier + hint covers)
|
|
478
|
-
|
|
524
|
+
/** Every path candidate a tool call exposes to denyPaths comparison (MCP/custom tools: none — classifier + hint covers).
|
|
525
|
+
* Scope tools with an omitted/empty path contribute the cwd: their search scope
|
|
526
|
+
* IS the cwd subtree (#48). */
|
|
527
|
+
function denyPathCandidates(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
|
|
479
528
|
const kind = toolKind(toolName);
|
|
480
529
|
if (kind === "command") return [...String(input.command ?? "").matchAll(BASH_PATH_TOKENS)].map((m) => m[0]);
|
|
481
530
|
if (kind === "file") {
|
|
482
|
-
const p = typeof input.path === "string" ? input.path :
|
|
483
|
-
|
|
531
|
+
const p = typeof input.path === "string" && input.path ? input.path : null;
|
|
532
|
+
if (!p) return isScopeTool(toolName) ? [cwd] : [];
|
|
533
|
+
return [p];
|
|
484
534
|
}
|
|
485
535
|
return [];
|
|
486
536
|
}
|
|
@@ -488,13 +538,19 @@ function denyPathCandidates(toolName: string, input: Record<string, unknown>): s
|
|
|
488
538
|
/** Does the call touch a user-declared protected path? `bases` are the denyPaths
|
|
489
539
|
* pre-normalized ONCE at session start (anchored to the session cwd) — mid-session
|
|
490
540
|
* symlink creation or cwd drift must not change what the declaration covers.
|
|
491
|
-
* Returns the matched base for the ask dialog (UI-only plaintext, see RuleResult.detail).
|
|
541
|
+
* Returns the matched base for the ask dialog (UI-only plaintext, see RuleResult.detail).
|
|
542
|
+
* Scope tools compare BIDIRECTIONALLY (#48): their search covers a subtree, so a
|
|
543
|
+
* hit fires when the target sits under a base (single-target direction) OR a base
|
|
544
|
+
* sits inside the searched subtree (cwd-inside-declaration, declaration-under-cwd).
|
|
545
|
+
* False positives ask — the safe direction. read/write/edit and bash tokens stay
|
|
546
|
+
* one-directional: single-target semantics. */
|
|
492
547
|
function hitDenyPaths(toolName: string, input: Record<string, unknown>, cwd: string, bases: string[]): string | null {
|
|
493
548
|
if (bases.length === 0) return null;
|
|
494
|
-
|
|
549
|
+
const subtree = isScopeTool(toolName);
|
|
550
|
+
for (const candidate of denyPathCandidates(toolName, input, cwd)) {
|
|
495
551
|
for (const c of denyPathForms(candidate, cwd)) {
|
|
496
552
|
for (const b of bases) {
|
|
497
|
-
if (pathEquals(c, b) || pathStartsWith(c, b)) return b;
|
|
553
|
+
if (pathEquals(c, b) || pathStartsWith(c, b) || (subtree && pathStartsWith(b, c))) return b;
|
|
498
554
|
}
|
|
499
555
|
}
|
|
500
556
|
}
|
|
@@ -529,26 +585,6 @@ interface ProtectedSet {
|
|
|
529
585
|
|
|
530
586
|
type WatchKind = "config" | "extension";
|
|
531
587
|
|
|
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
588
|
function escapeRegExp(s: string): string {
|
|
553
589
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
554
590
|
}
|
|
@@ -566,7 +602,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
566
602
|
const prefixes = new Set<string>();
|
|
567
603
|
const configPath = path.join(agentDir, "config", "pi-verdict.json");
|
|
568
604
|
const watchBases: Array<{ file: string; kind: WatchKind }> = [{ file: configPath, kind: "config" }];
|
|
569
|
-
for (const f of
|
|
605
|
+
for (const f of baseForms(configPath)) exact.add(f);
|
|
570
606
|
|
|
571
607
|
// 安装副本目标:单文件形态 → 文件本体(exact);npm 目录形态 → 包根目录(prefix)。
|
|
572
608
|
// extRoot 与 ownFile 各取词法/realpath 双形交叉判定,集合同样双形收录——
|
|
@@ -620,17 +656,15 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
620
656
|
// → exact, a package dir (`@scope/pkg` or `pkg`) → prefix, so every
|
|
621
657
|
// npm form gets whole-package-dir protection (#26).
|
|
622
658
|
const extRoots = new Set<string>();
|
|
623
|
-
const agentBases = new Set(
|
|
659
|
+
const agentBases = new Set(baseForms(agentDir));
|
|
624
660
|
const configRootBases = new Set([...agentBases].map((b) => path.dirname(b)));
|
|
625
661
|
for (const seg of [["extensions"], ["plugins", "node_modules"]]) {
|
|
626
662
|
const bases = seg.length === 2 ? new Set([...agentBases, ...configRootBases]) : agentBases;
|
|
627
663
|
for (const base of bases) {
|
|
628
|
-
const root
|
|
629
|
-
extRoots.add(root);
|
|
630
|
-
extRoots.add(tryRealpath(root));
|
|
664
|
+
for (const root of baseForms(path.join(base, ...seg))) extRoots.add(root);
|
|
631
665
|
}
|
|
632
666
|
}
|
|
633
|
-
const ownForms = new Set(
|
|
667
|
+
const ownForms = new Set(baseForms(ownFile));
|
|
634
668
|
for (const extRoot of extRoots) {
|
|
635
669
|
for (const own of ownForms) {
|
|
636
670
|
if (!own.startsWith(extRoot + path.sep)) continue;
|
|
@@ -639,14 +673,14 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
639
673
|
// npm scopes are two-segment dirs (@scope/pkg): the install
|
|
640
674
|
// target is the package, not the whole scope dir
|
|
641
675
|
const target = singleFile ? own : path.join(extRoot, ...segs.slice(0, segs[0].startsWith("@") ? 2 : 1));
|
|
642
|
-
for (const f of
|
|
676
|
+
for (const f of baseForms(target)) {
|
|
643
677
|
(singleFile ? exact : prefixes).add(f);
|
|
644
678
|
extTargets.add(f);
|
|
645
679
|
}
|
|
646
680
|
if (!singleFile && pkgRoot === null) pkgRoot = target;
|
|
647
681
|
}
|
|
648
682
|
}
|
|
649
|
-
// one walk of the package root (lexical form; takeSnapshots'
|
|
683
|
+
// one walk of the package root (lexical form; takeSnapshots' baseForms
|
|
650
684
|
// expansion picks up real forms per file) — no duplicate entries
|
|
651
685
|
if (pkgRoot !== null) {
|
|
652
686
|
for (const f of listPackageFiles(pkgRoot)) {
|
|
@@ -670,7 +704,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
670
704
|
alts.add("\\$HOME/" + escapeRegExp(rel));
|
|
671
705
|
}
|
|
672
706
|
// $PI_CODING_AGENT_DIR 变体:词法与 realpath 两种基名列举(符号链接目录容忍)
|
|
673
|
-
for (const base of new Set(
|
|
707
|
+
for (const base of new Set(baseForms(agentDir))) {
|
|
674
708
|
if (f.startsWith(base + path.sep)) {
|
|
675
709
|
alts.add("\\$PI_CODING_AGENT_DIR/" + escapeRegExp(f.slice(base.length + 1)));
|
|
676
710
|
}
|
|
@@ -687,7 +721,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
687
721
|
* nearest existing ancestor, #20) */
|
|
688
722
|
export function isProtectedWritePath(rawPath: string, cwd: string, prot: ProtectedSet): boolean {
|
|
689
723
|
if (!rawPath) return false;
|
|
690
|
-
for (const c of
|
|
724
|
+
for (const c of rebuiltForms(path.resolve(cwd, expandHome(rawPath)))) {
|
|
691
725
|
if (prot.exact.includes(c)) return true;
|
|
692
726
|
for (const p of prot.prefixes) {
|
|
693
727
|
if (c === p || c.startsWith(p + path.sep)) return true;
|
|
@@ -697,7 +731,7 @@ export function isProtectedWritePath(rawPath: string, cwd: string, prot: Protect
|
|
|
697
731
|
}
|
|
698
732
|
|
|
699
733
|
/** 自保护层裁决(第 0 层,先于一切):触碰门禁自身文件 → 不可豁免的 deny;其余 null 交后续层 */
|
|
700
|
-
|
|
734
|
+
function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
|
|
701
735
|
switch (toolName) {
|
|
702
736
|
case "write":
|
|
703
737
|
case "edit":
|
|
@@ -727,7 +761,7 @@ function takeSnapshots(bases: Array<{ file: string; kind: WatchKind }>): Array<{
|
|
|
727
761
|
const out: Array<{ file: string; kind: WatchKind; content: Buffer | null }> = [];
|
|
728
762
|
const seen = new Set<string>();
|
|
729
763
|
for (const b of bases) {
|
|
730
|
-
for (const f of
|
|
764
|
+
for (const f of baseForms(b.file)) {
|
|
731
765
|
if (seen.has(f)) continue;
|
|
732
766
|
seen.add(f);
|
|
733
767
|
let content: Buffer | null = null;
|
|
@@ -742,6 +776,68 @@ function takeSnapshots(bases: Array<{ file: string; kind: WatchKind }>): Array<{
|
|
|
742
776
|
return out;
|
|
743
777
|
}
|
|
744
778
|
|
|
779
|
+
/**
|
|
780
|
+
* 变更检测(ADR-0001 一期)的会话实例:基线快照 + 篡改旗标。处置差分(config-only
|
|
781
|
+
* 且有 UI → select 双选)由扩展 handler 编排——本类零 UI;restoreAndFailClose 只做
|
|
782
|
+
* 还原与置位,通知由调用方按返回的文件清单/原因拼装。
|
|
783
|
+
*/
|
|
784
|
+
class IntegrityWatch {
|
|
785
|
+
private snapshots: Array<{ file: string; kind: WatchKind; content: Buffer | null }>;
|
|
786
|
+
private _tampered = false;
|
|
787
|
+
|
|
788
|
+
constructor(private watchBases: Array<{ file: string; kind: WatchKind }>) {
|
|
789
|
+
this.snapshots = takeSnapshots(watchBases);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
get tampered(): boolean {
|
|
793
|
+
return this._tampered;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** 复核受保护文件,返回变化清单(不处置——处置按 kind 差分,ADR-0001 定稿 D) */
|
|
797
|
+
detect(): Array<{ file: string; kind: WatchKind }> {
|
|
798
|
+
const hit: Array<{ file: string; kind: WatchKind }> = [];
|
|
799
|
+
for (const s of this.snapshots) {
|
|
800
|
+
let current: Buffer | null = null;
|
|
801
|
+
try {
|
|
802
|
+
current = fs.readFileSync(s.file);
|
|
803
|
+
} catch {
|
|
804
|
+
/* 不存在 */
|
|
805
|
+
}
|
|
806
|
+
const same = (a: Buffer | null, b: Buffer | null): boolean => (a === null || b === null ? a === b : a.equals(b));
|
|
807
|
+
if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
|
|
808
|
+
}
|
|
809
|
+
return hit;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** 重建基线(Accept 路径与 session_start 共用;不动篡改旗标) */
|
|
813
|
+
rebaseline(): void {
|
|
814
|
+
this.snapshots = takeSnapshots(this.watchBases);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** 会话重置:重建基线 + 清篡改旗标 */
|
|
818
|
+
startSession(): void {
|
|
819
|
+
this.rebaseline();
|
|
820
|
+
this._tampered = false;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** 从快照回写变化文件(扩展进程自身执行,不经门禁)+ fail-closed 置位 */
|
|
824
|
+
restoreAndFailClose(changed: Array<{ file: string }>, cause: string): { reason: string; files: string } {
|
|
825
|
+
for (const c of changed) {
|
|
826
|
+
const s = this.snapshots.find((x) => x.file === c.file);
|
|
827
|
+
if (s && s.content !== null) {
|
|
828
|
+
try {
|
|
829
|
+
fs.writeFileSync(s.file, s.content);
|
|
830
|
+
} catch {
|
|
831
|
+
/* 还原失败:仍 fail-closed */
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
this._tampered = true;
|
|
836
|
+
const files = [...new Set(changed.map((c) => c.file))].join(", ");
|
|
837
|
+
return { reason: `[auto-mode] self-protection: tamper detected${cause ? ` (${cause})` : ""} and restored (${files}); fail-closed until restart`, files };
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
745
841
|
/**
|
|
746
842
|
* Tool call → rule-layer verdict. Order (#12; ADR-0001 adds layer 0; ADR-0002 inserts denyPaths):
|
|
747
843
|
* 0. self-protection — deny is terminal (no config exempts it, not even builtinDenyFloor:false)
|
|
@@ -768,7 +864,8 @@ function classifyByRules(toolName: string, input: Record<string, unknown>, cwd:
|
|
|
768
864
|
// read keeps classifyPath even with an empty path: resolved to cwd, it still
|
|
769
865
|
// carries the system-directory gray grading (bit-for-bit with the old switch)
|
|
770
866
|
base = classifyPath(toolName, String(input.path ?? ""), cwd, false, user.builtinDenyFloor);
|
|
771
|
-
} else if (kind === "file") { // grep/find/ls: optional path
|
|
867
|
+
} else if (kind === "file") { // grep/find/ls: optional path; absent → cwd is the
|
|
868
|
+
// effective target, so user rules and denyPaths compare against it (#48)
|
|
772
869
|
const p = typeof input.path === "string" ? input.path : undefined;
|
|
773
870
|
base = p ? classifyPath(toolName, p, cwd, false, user.builtinDenyFloor) : { verdict: "allow" };
|
|
774
871
|
} else {
|
|
@@ -851,15 +948,19 @@ function toolCallLine(name: string, args: Record<string, unknown>): string {
|
|
|
851
948
|
return `${name}: ${transcriptSafe(JSON.stringify(args))}`;
|
|
852
949
|
}
|
|
853
950
|
|
|
951
|
+
/** 判定管线对宿主会话的最小结构需求(转录源 + 会话 id)——adjudicate 不接完整
|
|
952
|
+
* ExtensionContext,测试只喂这两个成员即可 */
|
|
953
|
+
export type PipelineHost = Pick<ExtensionContext["sessionManager"], "getBranch" | "getSessionId">;
|
|
954
|
+
|
|
854
955
|
/**
|
|
855
956
|
* 从会话分支收集精简转录原料:user 消息行与 assistant 工具调用行。
|
|
856
957
|
* 丢弃 assistant 叙述/thinking 与 toolResult(注入面与 token 大头)。
|
|
857
958
|
* 影子缓存的 contextKey 与 buildTranscript 同源(同一批 user 行),保证键与模型输入一致。
|
|
858
959
|
*/
|
|
859
|
-
function collectTranscriptParts(
|
|
960
|
+
function collectTranscriptParts(host: PipelineHost): { userLines: string[]; toolLines: string[] } {
|
|
860
961
|
const userLines: string[] = [];
|
|
861
962
|
const toolLines: string[] = [];
|
|
862
|
-
for (const entry of
|
|
963
|
+
for (const entry of host.getBranch()) {
|
|
863
964
|
if (entry.type !== "message") continue;
|
|
864
965
|
const msg = entry.message;
|
|
865
966
|
if (msg.role === "user") {
|
|
@@ -875,8 +976,8 @@ function collectTranscriptParts(ctx: ExtensionContext): { userLines: string[]; t
|
|
|
875
976
|
}
|
|
876
977
|
|
|
877
978
|
/** 精简转录:最近 user 消息 + 最近工具调用,待审查动作固定为最后一行(位置约定,借鉴 CC) */
|
|
878
|
-
function buildTranscript(
|
|
879
|
-
const { userLines, toolLines } = collectTranscriptParts(
|
|
979
|
+
function buildTranscript(host: PipelineHost, actionLine: string): string {
|
|
980
|
+
const { userLines, toolLines } = collectTranscriptParts(host);
|
|
880
981
|
const lines = [...userLines.slice(-MAX_USER_MESSAGES), ...toolLines.slice(-MAX_TOOL_CALLS)];
|
|
881
982
|
lines.push(actionLine);
|
|
882
983
|
return lines.join("\n");
|
|
@@ -949,18 +1050,22 @@ function completionFor(registry: { complete?: unknown }, compatLoader?: CompatLo
|
|
|
949
1050
|
return fn;
|
|
950
1051
|
}
|
|
951
1052
|
|
|
1053
|
+
/** 分类器思考级别(pi 原生词表;后缀语法对齐 pi --model provider/id:thinking) */
|
|
1054
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
1055
|
+
|
|
952
1056
|
/** 单次分类器调用:显式 reasoning:"off"(见下方注释),失败返回错误串而非抛出 */
|
|
953
1057
|
async function callClassifierOnce(
|
|
954
|
-
|
|
1058
|
+
host: PipelineHost,
|
|
1059
|
+
signal: AbortSignal | undefined,
|
|
955
1060
|
complete: CompletionFn,
|
|
956
1061
|
model: NonNullable<ExtensionContext["model"]>,
|
|
957
1062
|
userMessage: string,
|
|
958
1063
|
maxTokens: number,
|
|
959
|
-
thinking:
|
|
1064
|
+
thinking: ThinkingLevel = "off",
|
|
960
1065
|
systemPrompt: string = CLASSIFIER_SYSTEM,
|
|
961
1066
|
): Promise<{ ok: true; text: string; stopReason: string } | { ok: false; error: string }> {
|
|
962
1067
|
const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
|
|
963
|
-
if (
|
|
1068
|
+
if (signal) signals.push(signal);
|
|
964
1069
|
try {
|
|
965
1070
|
const response = await complete(
|
|
966
1071
|
model,
|
|
@@ -992,7 +1097,7 @@ async function callClassifierOnce(
|
|
|
992
1097
|
reasoning: thinking === "minimal" ? ("low" as const) : thinking,
|
|
993
1098
|
}),
|
|
994
1099
|
cacheRetention: "short",
|
|
995
|
-
sessionId:
|
|
1100
|
+
sessionId: host.getSessionId(),
|
|
996
1101
|
},
|
|
997
1102
|
);
|
|
998
1103
|
const text = response.content
|
|
@@ -1012,21 +1117,22 @@ async function callClassifierOnce(
|
|
|
1012
1117
|
* 两档皆失败 → fail-closed deny(理由含两次诊断)。
|
|
1013
1118
|
*/
|
|
1014
1119
|
async function classifyWithModel(
|
|
1015
|
-
|
|
1120
|
+
host: PipelineHost,
|
|
1121
|
+
signal: AbortSignal | undefined,
|
|
1016
1122
|
complete: CompletionFn,
|
|
1017
1123
|
model: NonNullable<ExtensionContext["model"]>,
|
|
1018
1124
|
actionLine: string,
|
|
1019
|
-
thinking:
|
|
1125
|
+
thinking: ThinkingLevel = "off",
|
|
1020
1126
|
denyPathsActive = false,
|
|
1021
1127
|
): Promise<ClassifierOutcome> {
|
|
1022
|
-
const transcript = buildTranscript(
|
|
1128
|
+
const transcript = buildTranscript(host, actionLine);
|
|
1023
1129
|
const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
|
|
1024
1130
|
const systemPrompt = denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
|
|
1025
1131
|
const attempts: Array<[number, number]> = [[1, CLASSIFIER_MAX_TOKENS], [2, CLASSIFIER_RETRY_MAX_TOKENS]];
|
|
1026
1132
|
const failures: string[] = [];
|
|
1027
1133
|
for (const [n, maxTokens] of attempts) {
|
|
1028
|
-
if (
|
|
1029
|
-
const r = await callClassifierOnce(
|
|
1134
|
+
if (signal?.aborted) break; // 用户已取消,不再重试
|
|
1135
|
+
const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt);
|
|
1030
1136
|
if (r.ok) {
|
|
1031
1137
|
const diag = `stopReason=${r.stopReason}, model=${model.id}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
|
|
1032
1138
|
if (r.stopReason !== "error" && r.stopReason !== "aborted") {
|
|
@@ -1151,8 +1257,8 @@ function shadowCommandKey(toolName: string, input: Record<string, unknown>, cwd:
|
|
|
1151
1257
|
return fnv1a(`${toolName}\u0000${JSON.stringify(input)}\u0000${cwd}`);
|
|
1152
1258
|
}
|
|
1153
1259
|
|
|
1154
|
-
function shadowContextKey(
|
|
1155
|
-
const { userLines } = collectTranscriptParts(
|
|
1260
|
+
function shadowContextKey(host: PipelineHost): string {
|
|
1261
|
+
const { userLines } = collectTranscriptParts(host);
|
|
1156
1262
|
return fnv1a(userLines.slice(-MAX_USER_MESSAGES).join("\u0000"));
|
|
1157
1263
|
}
|
|
1158
1264
|
|
|
@@ -1162,6 +1268,121 @@ function shadowTag(probe: ShadowProbe): string {
|
|
|
1162
1268
|
return `(shadow cache: miss:no-entry)`;
|
|
1163
1269
|
}
|
|
1164
1270
|
|
|
1271
|
+
// ============================================================================
|
|
1272
|
+
// 会话态:判定管线的会话期状态(复位清单集中一处)
|
|
1273
|
+
// ============================================================================
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* 判定管线的会话期状态。session_start 的复位清单归 reset() 拥有——新增会话态只改
|
|
1277
|
+
* 这里,install 与 session_start 不再各持一份初始化点。prot 源自安装路径而非配置,
|
|
1278
|
+
* 构造期定,不参与 reset。导出仅为测试(内部 seam 的测试面,与 adjudicate 同组)。
|
|
1279
|
+
*/
|
|
1280
|
+
export class SessionState {
|
|
1281
|
+
readonly prot: ProtectedSet;
|
|
1282
|
+
readonly shadow = new ShadowCache();
|
|
1283
|
+
userRules: UserRules;
|
|
1284
|
+
private denyPathBases: string[] | null = null;
|
|
1285
|
+
|
|
1286
|
+
constructor(prot: ProtectedSet, userRules: UserRules = loadUserRules().rules) {
|
|
1287
|
+
this.prot = prot;
|
|
1288
|
+
this.userRules = userRules;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** 会话重置:重载用户规则(配置改动新会话生效)+ 按会话 cwd 重锚 denyPaths
|
|
1292
|
+
* (ADR-0002: 每会话锚定一次)+ 清影子缓存;返回加载报告供表现层通知 */
|
|
1293
|
+
reset(cwd: string): { skipped: string[]; shortcutWarning: string | null } {
|
|
1294
|
+
const loaded = loadUserRules();
|
|
1295
|
+
this.userRules = loaded.rules;
|
|
1296
|
+
this.denyPathBases = anchorDenyPaths(loaded.rules.denyPaths, cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1297
|
+
this.shadow.reset();
|
|
1298
|
+
return { skipped: loaded.skipped, shortcutWarning: loaded.shortcutWarning };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** denyPaths 基址:session_start 已锚定;此惰性回退仅守护乱序的首次 tool_call
|
|
1302
|
+
* (pi 正常次序 session_start 先行),一旦锚定不再重derive。 */
|
|
1303
|
+
anchoredDenyPathBases(cwd: string): string[] {
|
|
1304
|
+
if (this.denyPathBases === null) this.denyPathBases = anchorDenyPaths(this.userRules.denyPaths, cwd);
|
|
1305
|
+
return this.denyPathBases;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// ============================================================================
|
|
1310
|
+
// 判定管线(adjudicate):tool_call → Verdict 的唯一裁决入口,零 UI 依赖
|
|
1311
|
+
// ============================================================================
|
|
1312
|
+
|
|
1313
|
+
/** 裁决来源:呈现模板的键之一(与 degraded 正交分解)。rule = 规则层(含自保护层
|
|
1314
|
+
* ——同走规则呈现模板);protected-path = denyPaths 命中;classifier = 灰区分类器
|
|
1315
|
+
* 结果(含其 fail-closed——呈现模板相同);fail-closed = 无可用分类器模型 */
|
|
1316
|
+
export type VerdictSource = "rule" | "protected-path" | "classifier" | "fail-closed";
|
|
1317
|
+
|
|
1318
|
+
/** 判定管线的输出值对象:一次 tool_call 的完整裁决。detail 为 UI-only 明文(受保护
|
|
1319
|
+
* 路径仅入本地确认框,ADR-0002 零泄漏承诺——reason 与通知永不携带);degraded 标记
|
|
1320
|
+
* ask 在无 UI 会话的降级产物;shadow 为影子缓存标注(仅 debug 呈现拼接用)。 */
|
|
1321
|
+
export interface Verdict {
|
|
1322
|
+
verdict: "allow" | "ask" | "deny";
|
|
1323
|
+
reason: string;
|
|
1324
|
+
detail?: string;
|
|
1325
|
+
source: VerdictSource;
|
|
1326
|
+
degraded: boolean;
|
|
1327
|
+
shadow?: string;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** 逐调用环境:呈现无关的宿主能力。model 经 getModel 惰性求值——保持「仅灰区才
|
|
1331
|
+
* 解析」的原行为(回退警告不会出现在规则已裁决的调用上);null → fail-closed。 */
|
|
1332
|
+
export interface AdjudicateEnv {
|
|
1333
|
+
cwd: string;
|
|
1334
|
+
hasUI: boolean;
|
|
1335
|
+
getModel: () => { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null;
|
|
1336
|
+
complete: CompletionFn;
|
|
1337
|
+
host: PipelineHost;
|
|
1338
|
+
signal?: AbortSignal;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* 判定管线(CONTEXT.md「判定管线」词条的实现):自保护 → 内置 floor → 用户 deny →
|
|
1343
|
+
* denyPaths ask → 用户 allow → 灰区分类器;ask 降级(无 UI → deny)与 fail-closed
|
|
1344
|
+
* 内建于此,两处重复的降级实现自此唯一。零 UI:表现(notify/confirm/select)由扩展
|
|
1345
|
+
* handler 按 source × degraded 模板呈现;变更检测(IntegrityWatch)是管线前置的
|
|
1346
|
+
* 独立关注点,不在 adjudicate 内。导出仅为测试(内部 seam 的测试面,#35 既有模式)。
|
|
1347
|
+
*/
|
|
1348
|
+
export async function adjudicate(
|
|
1349
|
+
state: SessionState,
|
|
1350
|
+
call: { toolName: string; input: Record<string, unknown> },
|
|
1351
|
+
env: AdjudicateEnv,
|
|
1352
|
+
): Promise<Verdict> {
|
|
1353
|
+
const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
|
|
1354
|
+
if (rule.verdict === "allow") return { verdict: "allow", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1355
|
+
if (rule.verdict === "deny") return { verdict: "deny", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1356
|
+
if (rule.verdict === "ask") {
|
|
1357
|
+
// denyPaths 命中 → ask 终局(ADR-0002):声明者本人裁决例外;无 UI 降级为 deny
|
|
1358
|
+
return { verdict: env.hasUI ? "ask" : "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: !env.hasUI };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// 灰区 → 分类器;无可用模型 → fail-closed
|
|
1362
|
+
const resolved = env.getModel();
|
|
1363
|
+
if (!resolved) return { verdict: "deny", reason: "no classifier model available (fail-closed)", source: "fail-closed", degraded: false };
|
|
1364
|
+
|
|
1365
|
+
// 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
|
|
1366
|
+
const cmdKey = shadowCommandKey(call.toolName, call.input, env.cwd);
|
|
1367
|
+
const ctxKey = shadowContextKey(env.host);
|
|
1368
|
+
const probe = state.shadow.probe(cmdKey, ctxKey);
|
|
1369
|
+
|
|
1370
|
+
const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, toolCallLine(call.toolName, call.input), resolved.thinking, state.userRules.denyPaths.length > 0);
|
|
1371
|
+
|
|
1372
|
+
// 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
|
|
1373
|
+
// 命中且本次为可缓存裁决时,对比反事实一致性
|
|
1374
|
+
if (outcome.source === "model" && outcome.verdict !== "ask") {
|
|
1375
|
+
if (probe.result === "hit") state.shadow.countDivergence(probe.entry.verdict, outcome.verdict);
|
|
1376
|
+
state.shadow.record(cmdKey, ctxKey, outcome.verdict);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const shadow = shadowTag(probe);
|
|
1380
|
+
if (outcome.verdict === "allow") return { verdict: "allow", reason: outcome.reason, source: "classifier", degraded: false, shadow };
|
|
1381
|
+
if (outcome.verdict === "deny") return { verdict: "deny", reason: outcome.reason, source: "classifier", degraded: false, shadow };
|
|
1382
|
+
// ask:无 UI 降级为 deny(ask 降级,CONTEXT.md 词条)
|
|
1383
|
+
return { verdict: env.hasUI ? "ask" : "deny", reason: outcome.reason, source: "classifier", degraded: !env.hasUI, shadow };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1165
1386
|
// ============================================================================
|
|
1166
1387
|
// 扩展主体
|
|
1167
1388
|
// ============================================================================
|
|
@@ -1178,56 +1399,58 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1178
1399
|
|
|
1179
1400
|
let enabled = pi.getFlag("auto-mode") !== false;
|
|
1180
1401
|
const debug = pi.getFlag("auto-mode-debug") === true || process.env.PI_AUTO_MODE_DEBUG === "1";
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
};
|
|
1192
|
-
|
|
1193
|
-
// Self-protection layer (ADR-0001): self-anchored protected set + tamper
|
|
1194
|
-
// baseline (in-memory, per session)
|
|
1195
|
-
const prot = buildProtectedSet(agentDirPath(), OWN_FILE_PATH);
|
|
1196
|
-
let snapshots = takeSnapshots(prot.watchBases);
|
|
1197
|
-
let tampered = false;
|
|
1402
|
+
// 会话态与门禁完整性监视:复位清单各归 SessionState.reset / IntegrityWatch.startSession
|
|
1403
|
+
const state = new SessionState(buildProtectedSet(agentDirPath(), OWN_FILE_PATH));
|
|
1404
|
+
const integrity = new IntegrityWatch(state.prot.watchBases);
|
|
1405
|
+
|
|
1406
|
+
/** 篡改处置呈现:还原 + fail-closed 的本地通知(含文件清单与原因) */
|
|
1407
|
+
function presentTamper(changed: Array<{ file: string; kind: WatchKind }>, ctx: ExtensionContext, cause: string): { block: true; reason: string } {
|
|
1408
|
+
const r = integrity.restoreAndFailClose(changed, cause);
|
|
1409
|
+
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");
|
|
1410
|
+
return { block: true, reason: r.reason };
|
|
1411
|
+
}
|
|
1198
1412
|
|
|
1199
|
-
/**
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1413
|
+
/** Verdict → UI(本扩展唯一的裁决呈现点):按 source × degraded 查模板,文案与
|
|
1414
|
+
* 重构前逐字节一致。受保护路径分支的通知永不携带路径明文与 action 行
|
|
1415
|
+
* (ADR-0002 story 11:通知与 block reason 回流 agent context)。 */
|
|
1416
|
+
async function presentVerdict(v: Verdict, action: string, ctx: ExtensionContext): Promise<{ block: true; reason: string } | undefined> {
|
|
1417
|
+
if (v.verdict === "allow") {
|
|
1418
|
+
if (debug) {
|
|
1419
|
+
if (v.source === "rule") ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
|
|
1420
|
+
else if (v.source === "protected-path") ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
|
|
1421
|
+
else ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}${v.shadow ? " " + v.shadow : ""}`, "info");
|
|
1208
1422
|
}
|
|
1209
|
-
|
|
1210
|
-
if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
|
|
1423
|
+
return undefined;
|
|
1211
1424
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
fs.writeFileSync(s.file, s.content);
|
|
1222
|
-
} catch {
|
|
1223
|
-
/* 还原失败:仍 fail-closed */
|
|
1224
|
-
}
|
|
1425
|
+
if (v.verdict === "deny") {
|
|
1426
|
+
if (v.source === "protected-path") {
|
|
1427
|
+
// 无 action 行:action 串可内嵌被触路径,通知不得携带受保护路径明文
|
|
1428
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, protected-path ask→deny): ${v.reason}`, "warning");
|
|
1429
|
+
return { block: true, reason: `[auto-mode] protected-path ask degraded to block in non-interactive mode: ${v.reason}` };
|
|
1430
|
+
}
|
|
1431
|
+
if (v.source === "fail-closed") {
|
|
1432
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
|
|
1433
|
+
return { block: true, reason: `[auto-mode] ${v.reason}` };
|
|
1225
1434
|
}
|
|
1435
|
+
if (v.source === "rule") {
|
|
1436
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
|
|
1437
|
+
return { block: true, reason: `[auto-mode rule block] ${v.reason}` };
|
|
1438
|
+
}
|
|
1439
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}${debug && v.shadow ? " " + v.shadow : ""}`, "warning");
|
|
1440
|
+
return { block: true, reason: `[auto-mode classifier block] ${v.reason}` };
|
|
1226
1441
|
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1442
|
+
// ask → 人工确认;非交互已在管线内降级,能走到这里的必有 UI
|
|
1443
|
+
if (v.source === "protected-path") {
|
|
1444
|
+
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?`);
|
|
1445
|
+
if (ok) {
|
|
1446
|
+
// debug notify 不带 action 行:同上,通知不得携带受保护路径明文
|
|
1447
|
+
if (debug) ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
|
|
1448
|
+
return undefined;
|
|
1449
|
+
}
|
|
1450
|
+
return { block: true, reason: "[auto-mode] user declined protected-path access" };
|
|
1451
|
+
}
|
|
1452
|
+
const ok = await ctx.ui.confirm("🛡️ Auto Mode confirmation", `${action}\n\nClassifier opinion: ${v.reason}\n\nAllow execution?`);
|
|
1453
|
+
return ok ? undefined : { block: true, reason: "[auto-mode] user declined" };
|
|
1231
1454
|
}
|
|
1232
1455
|
|
|
1233
1456
|
function refreshStatus(ctx: ExtensionContext) {
|
|
@@ -1245,23 +1468,19 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1245
1468
|
// session_start:重置影子缓存(会话内存态,#5 定案)+ 重载用户规则(配置改动新会话生效)
|
|
1246
1469
|
// + 重建自保护基线(ADR-0001:受保护文件的会话启动快照)
|
|
1247
1470
|
pi.on("session_start", async (_event, ctx) => {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
denyPathBases = anchorDenyPaths(userRules.denyPaths, ctx.cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1253
|
-
snapshots = takeSnapshots(prot.watchBases);
|
|
1254
|
-
if (loaded.skipped.length > 0) {
|
|
1255
|
-
ctx.ui.notify(`pi-verdict: skipped ${loaded.skipped.length} invalid config value(s) in config (${userConfigPath()}): ${loaded.skipped.join(", ")}`, "warning");
|
|
1471
|
+
const report = state.reset(ctx.cwd);
|
|
1472
|
+
integrity.startSession();
|
|
1473
|
+
if (report.skipped.length > 0) {
|
|
1474
|
+
ctx.ui.notify(`pi-verdict: skipped ${report.skipped.length} invalid config value(s) in config (${userConfigPath()}): ${report.skipped.join(", ")}`, "warning");
|
|
1256
1475
|
}
|
|
1257
|
-
if (
|
|
1476
|
+
if (report.shortcutWarning) ctx.ui.notify(`pi-verdict: ${report.shortcutWarning}`, "warning");
|
|
1258
1477
|
refreshStatus(ctx);
|
|
1259
1478
|
});
|
|
1260
1479
|
|
|
1261
1480
|
// 主开关 toggle 快捷键(#15):键位取首次加载的用户规则(会话内固定——改配置后
|
|
1262
1481
|
// /reload 重载扩展或新会话生效);handler 与 /automode 语义等价,静默切换,
|
|
1263
1482
|
// footer 始终显示是唯一反馈
|
|
1264
|
-
const registeredToggleKey = userRules.toggleShortcut;
|
|
1483
|
+
const registeredToggleKey = state.userRules.toggleShortcut;
|
|
1265
1484
|
if (registeredToggleKey) {
|
|
1266
1485
|
// KeyId 是 pi 的编译期联合类型(运行时即 string);用户配置键位经 KEY_COMBO_RE
|
|
1267
1486
|
// 运行时校验后断言转入,零依赖约束下不引入 pi 内部类型路径
|
|
@@ -1274,7 +1493,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1274
1493
|
/** Usage 行的 toggle 提示(#15):无注册键位时不显示;显示注册时固定的键 */
|
|
1275
1494
|
const toggleHint = () => (registeredToggleKey ? ` · toggle: ${registeredToggleKey}` : "");
|
|
1276
1495
|
/** Status line denyPaths count (ADR-0002): shown only when configured */
|
|
1277
|
-
const denyPathsHint = () => (userRules.denyPaths.length > 0 ? `\ndenyPaths: ${userRules.denyPaths.length} active` : "");
|
|
1496
|
+
const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
|
|
1278
1497
|
|
|
1279
1498
|
pi.registerCommand("automode", {
|
|
1280
1499
|
description: "Show Auto Mode status and shadow-cache stats, or set it: /automode on|off",
|
|
@@ -1282,7 +1501,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1282
1501
|
const arg = args.trim().toLowerCase();
|
|
1283
1502
|
// 裸调用:只读状态展示,无副作用(含影子缓存统计行)
|
|
1284
1503
|
if (arg === "") {
|
|
1285
|
-
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${shadow.summary()}${denyPathsHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1504
|
+
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1286
1505
|
return;
|
|
1287
1506
|
}
|
|
1288
1507
|
// 幂等设定:与现值相同不翻转,仅确认
|
|
@@ -1293,7 +1512,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1293
1512
|
const head = next
|
|
1294
1513
|
? `🛡️ Auto Mode enabled${changed ? "" : " (unchanged)"}: tool calls adjudicated by rules + classifier`
|
|
1295
1514
|
: `Auto Mode disabled${changed ? "" : " (unchanged)"}: tool calls execute directly`;
|
|
1296
|
-
ctx.ui.notify(`${head}\n${shadow.summary()}`, "info");
|
|
1515
|
+
ctx.ui.notify(`${head}\n${state.shadow.summary()}`, "info");
|
|
1297
1516
|
return;
|
|
1298
1517
|
}
|
|
1299
1518
|
// 未知参数:严格拒绝并列出用法(大小写已归一化)
|
|
@@ -1319,28 +1538,29 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1319
1538
|
return { specPart: raw, level: null };
|
|
1320
1539
|
}
|
|
1321
1540
|
|
|
1322
|
-
/**
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1541
|
+
/** 解析分类器模型与思考级别:CLI flag > 环境变量 > 配置文件(classifierModel) >
|
|
1542
|
+
* 自省(会话模型)。不可用回退会话模型并警告一次;null = 连会话模型都没有 →
|
|
1543
|
+
* fail-closed。经 AdjudicateEnv.getModel 惰性调用(仅灰区),回退警告不会出现在
|
|
1544
|
+
* 规则已裁决的调用上。 */
|
|
1545
|
+
function resolveClassifier(ctx: ExtensionContext): { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null {
|
|
1327
1546
|
const raw =
|
|
1328
|
-
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? userRules.classifierModel;
|
|
1329
|
-
|
|
1547
|
+
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
|
|
1548
|
+
let thinking: ThinkingLevel = "off";
|
|
1330
1549
|
if (raw) {
|
|
1331
1550
|
const { specPart, level } = parseModelSpec(raw, ctx);
|
|
1332
|
-
|
|
1551
|
+
thinking = (level ?? "off") as ThinkingLevel;
|
|
1333
1552
|
const slash = specPart.indexOf("/");
|
|
1334
1553
|
if (slash > 0) {
|
|
1335
1554
|
const model = ctx.modelRegistry.find(specPart.slice(0, slash), specPart.slice(slash + 1));
|
|
1336
|
-
if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return model;
|
|
1555
|
+
if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return { model, thinking };
|
|
1337
1556
|
}
|
|
1338
1557
|
if (!warnedClassifierModel) {
|
|
1339
1558
|
warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
|
|
1340
1559
|
ctx.ui.notify(`pi-verdict: classifier model "${raw}" unavailable (not found or no configured auth), falling back to session model (self-reflection)`, "warning");
|
|
1341
1560
|
}
|
|
1342
1561
|
}
|
|
1343
|
-
|
|
1562
|
+
// 自省:继承当前会话模型;显式指定的思考级别在回退时仍生效(原语义)
|
|
1563
|
+
return ctx.model ? { model: ctx.model, thinking } : null;
|
|
1344
1564
|
}
|
|
1345
1565
|
|
|
1346
1566
|
function describeAction(toolName: string, input: Record<string, unknown>): string {
|
|
@@ -1354,11 +1574,11 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1354
1574
|
const action = describeAction(event.toolName, input);
|
|
1355
1575
|
|
|
1356
1576
|
// 第 0 层前置:变更检测(ADR-0001)——篡改后本会话恒 deny(fail-closed)
|
|
1357
|
-
if (tampered) {
|
|
1577
|
+
if (integrity.tampered) {
|
|
1358
1578
|
ctx.ui.notify(`🛡️ Auto Mode blocked: self-protection fail-closed (tamper detected this session; restart to reset)\n ${action}`, "warning");
|
|
1359
1579
|
return { block: true, reason: "[auto-mode] self-protection: fail-closed until session restart (protected file was tampered with)" };
|
|
1360
1580
|
}
|
|
1361
|
-
const changed =
|
|
1581
|
+
const changed = integrity.detect();
|
|
1362
1582
|
if (changed.length > 0) {
|
|
1363
1583
|
// 差分处置(ADR-0001 定稿 D):仅 config 变化且有 UI → select 双选(选项即动作);
|
|
1364
1584
|
// 扩展副本被改 / 无 UI → 一律还原 + fail-closed。
|
|
@@ -1373,82 +1593,25 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1373
1593
|
[CONFIG_ACCEPT_CHOICE, CONFIG_DECLINE_CHOICE],
|
|
1374
1594
|
);
|
|
1375
1595
|
if (choice === CONFIG_ACCEPT_CHOICE) {
|
|
1376
|
-
|
|
1596
|
+
integrity.rebaseline(); // 重建基线
|
|
1377
1597
|
ctx.ui.notify("pi-verdict: config change accepted — new baseline taken; applies to new sessions as usual", "info");
|
|
1378
1598
|
} else {
|
|
1379
|
-
return
|
|
1599
|
+
return presentTamper(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
|
|
1380
1600
|
}
|
|
1381
1601
|
} else {
|
|
1382
|
-
return
|
|
1602
|
+
return presentTamper(changed, ctx, "");
|
|
1383
1603
|
}
|
|
1384
1604
|
}
|
|
1385
1605
|
|
|
1386
|
-
//
|
|
1387
|
-
const
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
// denyPaths hit → deterministic ask (ADR-0002): the declaring user adjudicates
|
|
1397
|
-
// the exception; non-interactive sessions degrade to deny (existing ask rule)
|
|
1398
|
-
if (rule.verdict === "ask") {
|
|
1399
|
-
if (!ctx.hasUI) {
|
|
1400
|
-
// no action line here: the action string can embed the touched path, and
|
|
1401
|
-
// notifications must not carry protected-path plaintext (ADR-0002 story 11)
|
|
1402
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, protected-path ask→deny): ${rule.reason}`, "warning");
|
|
1403
|
-
return { block: true, reason: `[auto-mode] protected-path ask degraded to block in non-interactive mode: ${rule.reason}` };
|
|
1404
|
-
}
|
|
1405
|
-
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?`);
|
|
1406
|
-
if (ok) {
|
|
1407
|
-
// debug notify stays plaintext-free too: the action line can embed the
|
|
1408
|
-
// touched path, and notifications must not carry protected-path plaintext
|
|
1409
|
-
if (debug) ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
|
|
1410
|
-
return undefined;
|
|
1411
|
-
}
|
|
1412
|
-
return { block: true, reason: "[auto-mode] user declined protected-path access" };
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// 第 2 层:灰区 → 模型分类器
|
|
1416
|
-
const model = resolveClassifierModel(ctx);
|
|
1417
|
-
if (!model) {
|
|
1418
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked: no classifier model available (fail-closed)\n ${action}`, "warning");
|
|
1419
|
-
return { block: true, reason: "[auto-mode] no classifier model available (fail-closed)" };
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
// 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
|
|
1423
|
-
const cmdKey = shadowCommandKey(event.toolName, input, ctx.cwd);
|
|
1424
|
-
const ctxKey = shadowContextKey(ctx);
|
|
1425
|
-
const probe = shadow.probe(cmdKey, ctxKey);
|
|
1426
|
-
|
|
1427
|
-
const outcome = await classifyWithModel(ctx, completionFor(ctx.modelRegistry, deps.compatLoader), model, action, classifierThinking, userRules.denyPaths.length > 0);
|
|
1428
|
-
|
|
1429
|
-
// 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
|
|
1430
|
-
// 命中且本次为可缓存裁决时,对比反事实一致性
|
|
1431
|
-
if (outcome.source === "model" && outcome.verdict !== "ask") {
|
|
1432
|
-
if (probe.result === "hit") shadow.countDivergence(probe.entry.verdict, outcome.verdict);
|
|
1433
|
-
shadow.record(cmdKey, ctxKey, outcome.verdict);
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
if (outcome.verdict === "allow") {
|
|
1437
|
-
if (debug) ctx.ui.notify(`🛡️ allow (classifier): ${outcome.reason}\n ${action} ${shadowTag(probe)}`, "info");
|
|
1438
|
-
return undefined;
|
|
1439
|
-
}
|
|
1440
|
-
if (outcome.verdict === "deny") {
|
|
1441
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked: ${outcome.reason}\n ${action}${debug ? " " + shadowTag(probe) : ""}`, "warning");
|
|
1442
|
-
return { block: true, reason: `[auto-mode classifier block] ${outcome.reason}` };
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
// ask:转人工;非交互模式 fail-closed 降级为拦截
|
|
1446
|
-
if (!ctx.hasUI) {
|
|
1447
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, ask→deny): ${outcome.reason}\n ${action}`, "warning");
|
|
1448
|
-
return { block: true, reason: `[auto-mode] ask degraded to block in non-interactive mode: ${outcome.reason}` };
|
|
1449
|
-
}
|
|
1450
|
-
const ok = await ctx.ui.confirm("🛡️ Auto Mode confirmation", `${action}\n\nClassifier opinion: ${outcome.reason}\n\nAllow execution?`);
|
|
1451
|
-
if (ok) return undefined;
|
|
1452
|
-
return { block: true, reason: "[auto-mode] user declined" };
|
|
1606
|
+
// 判定管线(零 UI)→ 呈现(source × degraded 模板)
|
|
1607
|
+
const verdict = await adjudicate(state, { toolName: event.toolName, input }, {
|
|
1608
|
+
cwd: ctx.cwd,
|
|
1609
|
+
hasUI: !!ctx.hasUI,
|
|
1610
|
+
getModel: () => resolveClassifier(ctx),
|
|
1611
|
+
complete: completionFor(ctx.modelRegistry, deps.compatLoader),
|
|
1612
|
+
host: ctx.sessionManager,
|
|
1613
|
+
signal: ctx.signal,
|
|
1614
|
+
});
|
|
1615
|
+
return presentVerdict(verdict, action, ctx);
|
|
1453
1616
|
});
|
|
1454
1617
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-verdict",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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"
|