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 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/auto-mode.ts
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": ["~/Documents/private", "~/work/company"],
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/auto-mode.ts), still one file on purpose), and the measurement habit — every design decision in this repo is backed by shipped research.
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. Both holes fall back to the classifier's existence hint, alongside substitution/base64 above
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/auto-mode.ts
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": ["~/Documents/private", "~/work/company"],
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/auto-mode.ts)(仍刻意单文件)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
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*`)同样漏过——基名自身从未字面出现。两个洞与上述替换/base64 一样回落到分类器的存在性话术
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 [ownFile, tryRealpath(ownFile)]) {
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 = targetForms(abs);
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([cwd, tryRealpath(cwd)]);
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
- /** 用户规则匹配目标:bash/powershell=完整命令串;路径类工具=解析后绝对路径;其余工具不参与 */
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
- return p ? path.resolve(cwd, expandHome(p)) : null;
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). The extractor is an evidence producer, never an
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
- /** Case-insensitive filesystems (default macOS APFS, Windows) compare path strings
459
- * case-folded; realpath already normalizes case whenever it resolves, this covers
460
- * the lexical-only forms of nonexistent targets (#21). Linux stays case-sensitive. */
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 pathForms(path.resolve(cwd, expanded));
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
- function denyPathCandidates(toolName: string, input: Record<string, unknown>): string[] {
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
- return p ? [p] : [];
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
- for (const candidate of denyPathCandidates(toolName, input)) {
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 pathForms(configPath)) exact.add(f);
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([agentDir, tryRealpath(agentDir)]);
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 = path.join(base, ...seg);
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([ownFile, tryRealpath(ownFile)]);
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 pathForms(target)) {
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' pathForms
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([agentDir, tryRealpath(agentDir)])) {
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 targetForms(path.resolve(cwd, expandHome(rawPath)))) {
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
- export function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
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 pathForms(b.file)) {
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, absent → plain allow
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(ctx: ExtensionContext): { userLines: string[]; toolLines: string[] } {
960
+ function collectTranscriptParts(host: PipelineHost): { userLines: string[]; toolLines: string[] } {
860
961
  const userLines: string[] = [];
861
962
  const toolLines: string[] = [];
862
- for (const entry of ctx.sessionManager.getBranch()) {
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(ctx: ExtensionContext, actionLine: string): string {
879
- const { userLines, toolLines } = collectTranscriptParts(ctx);
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
- ctx: ExtensionContext,
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: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
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 (ctx.signal) signals.push(ctx.signal);
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: ctx.sessionManager.getSessionId(),
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
- ctx: ExtensionContext,
1120
+ host: PipelineHost,
1121
+ signal: AbortSignal | undefined,
1016
1122
  complete: CompletionFn,
1017
1123
  model: NonNullable<ExtensionContext["model"]>,
1018
1124
  actionLine: string,
1019
- thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
1125
+ thinking: ThinkingLevel = "off",
1020
1126
  denyPathsActive = false,
1021
1127
  ): Promise<ClassifierOutcome> {
1022
- const transcript = buildTranscript(ctx, actionLine);
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 (ctx.signal?.aborted) break; // 用户已取消,不再重试
1029
- const r = await callClassifierOnce(ctx, complete, model, userMessage, maxTokens, thinking, systemPrompt);
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(ctx: ExtensionContext): string {
1155
- const { userLines } = collectTranscriptParts(ctx);
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
- const shadow = new ShadowCache();
1182
- let userRules: UserRules = loadUserRules().rules;
1183
- // denyPath bases, normalized ONCE per session anchored to the session cwd (ADR-0002):
1184
- // mid-session symlink creation or cwd drift must not change what the declaration covers.
1185
- // session_start anchors it; the lazy null-fallback only guards an out-of-order first
1186
- // tool_call (pi's normal order is session_start first) and, once set, it is never re-derived.
1187
- let denyPathBases: string[] | null = null;
1188
- const anchoredDenyPathBases = (cwd: string): string[] => {
1189
- if (denyPathBases === null) denyPathBases = anchorDenyPaths(userRules.denyPaths, cwd);
1190
- return denyPathBases;
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
- /** 复核受保护文件,返回变化清单(不还原——处置按 kind 差分,ADR-0001 定稿 D) */
1200
- function detectTamper(): Array<{ file: string; kind: WatchKind }> {
1201
- const hit: Array<{ file: string; kind: WatchKind }> = [];
1202
- for (const s of snapshots) {
1203
- let current: Buffer | null = null;
1204
- try {
1205
- current = fs.readFileSync(s.file);
1206
- } catch {
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
- const same = (a: Buffer | null, b: Buffer | null): boolean => (a === null || b === null ? a === b : a.equals(b));
1210
- if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
1423
+ return undefined;
1211
1424
  }
1212
- return hit;
1213
- }
1214
-
1215
- /** 从快照回写变化文件(扩展进程自身执行,不经门禁)+ fail-closed,返回 block 载荷 */
1216
- function restoreAndFailClose(changed: Array<{ file: string }>, ctx: ExtensionContext, cause: string): { block: true; reason: string } {
1217
- for (const c of changed) {
1218
- const s = snapshots.find((x) => x.file === c.file);
1219
- if (s && s.content !== null) {
1220
- try {
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
- tampered = true;
1228
- const files = [...new Set(changed.map((c) => c.file))].join(", ");
1229
- ctx.ui.notify(`🛡️ pi-verdict TAMPER DETECTED${cause ? ` (${cause})` : ""}: ${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");
1230
- return { block: true, reason: `[auto-mode] self-protection: tamper detected${cause ? ` (${cause})` : ""} and restored (${files}); fail-closed until restart` };
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
- shadow.reset();
1249
- tampered = false;
1250
- const loaded = loadUserRules();
1251
- userRules = loaded.rules;
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 (loaded.shortcutWarning) ctx.ui.notify(`pi-verdict: ${loaded.shortcutWarning}`, "warning");
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
- /** 分类器思考级别:spec 后缀指定;缺省 off(显式关思考,blackhole 研究背书) */
1323
- let classifierThinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off";
1324
-
1325
- function resolveClassifierModel(ctx: ExtensionContext): NonNullable<ExtensionContext["model"]> | null {
1326
- // 优先级:CLI flag > 环境变量 > 配置文件(classifierModel) > 自省(会话模型)
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
- classifierThinking = "off";
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
- if (level) classifierThinking = level as typeof classifierThinking;
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
- return ctx.model ?? null; // 自省:继承当前会话模型
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 = detectTamper();
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
- snapshots = takeSnapshots(prot.watchBases); // 重建基线
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 restoreAndFailClose(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
1599
+ return presentTamper(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
1380
1600
  }
1381
1601
  } else {
1382
- return restoreAndFailClose(changed, ctx, "");
1602
+ return presentTamper(changed, ctx, "");
1383
1603
  }
1384
1604
  }
1385
1605
 
1386
- // 1 层:规则
1387
- const rule = classifyByRules(event.toolName, input, ctx.cwd, userRules, prot, anchoredDenyPathBases(ctx.cwd));
1388
- if (rule.verdict === "allow") {
1389
- if (debug) ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
1390
- return undefined;
1391
- }
1392
- if (rule.verdict === "deny") {
1393
- ctx.ui.notify(`🛡️ Auto Mode blocked: ${rule.reason}\n ${action}`, "warning");
1394
- return { block: true, reason: `[auto-mode rule block] ${rule.reason}` };
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.6.1",
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/auto-mode.ts",
7
+ "main": "extensions/pi-verdict.ts",
8
8
  "files": [
9
- "extensions/auto-mode.ts",
9
+ "extensions/pi-verdict.ts",
10
10
  "README.md",
11
11
  "README.zh-CN.md",
12
12
  "LICENSE"