pi-verdict 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
@@ -123,7 +123,7 @@ Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) s
123
123
 
124
124
  Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · convergence analysis with the closest architectural relative: [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md).
125
125
 
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.
126
+ Honest framing: pi-automode and pi-verdict have **converged on the same architecture** (deny floor → user rules → classifier, fail-closed — see the convergence analysis). What remains distinct here: a classifier that can say `ask` (runtime human-in-the-loop, not just rule-declared), a built-in floor you can turn off (`builtinDenyFloor` — user sovereignty), a self-protection layer that no config can turn off ([ADR-0001](docs/adr/0001-self-protection-layer.md) — gate integrity), a zero-dependency single file ([one readable file](extensions/pi-verdict.ts), still one file on purpose), and the measurement habit — every design decision in this repo is backed by shipped research.
127
127
 
128
128
  ## Pipeline
129
129
 
@@ -178,7 +178,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
178
178
  - parallel gray-zone calls are adjudicated serially
179
179
  - self-reflection means the session model adjudicates — point `--auto-mode-model` at a lighter model if verdict latency/cost matters (open question tracked in the issue tracker)
180
180
  - 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
181
+ - `denyPaths` bash extraction is token-level ([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)): command substitution, base64-embedded paths and external script contents produce no hit signal — those calls fall back to the classifier's existence-hint vigilance. MCP and custom tools bypass the extractor entirely (their gray-zone adjudication still carries the hint). Path normalization is base-tier only (ADR-0002): a nonexistent target written through a symlinked directory rebuilds no real form and produces no hit — that indirection falls to the hint vigilance too (the ancestor-rebuilding tier applies to the self-protection layer and the sensitivity floor, not denyPaths). Honest framing, same as the self-protection substring precedent: the deterministic layer is obfuscatable, which is exactly why a hit routes to *you* rather than silently deciding
182
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
183
183
  - self-protection bash matching is substring regex — obfuscatable; the tamper-detection backstop catches within-session bypasses, but a cross-session baseline (hash + change confirmation at startup, incl. upgrade UX) is phase 2 per [ADR-0001](docs/adr/0001-self-protection-layer.md)
184
184
  - dev checkouts (running the extension from a repo, not `<agentDir>/extensions/`) are not self-protected — the installed copy the *next* normal session loads is only covered by its own sessions' gate
package/README.zh-CN.md CHANGED
@@ -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
 
@@ -124,7 +124,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
124
124
 
125
125
  完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
126
126
 
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)(仍刻意单文件)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
127
+ 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、任何配置都关不掉的自保护层([ADR-0001](docs/adr/0001-self-protection-layer.md)——门禁完整性)、零依赖的[可通读单文件](extensions/pi-verdict.ts)(仍刻意单文件)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
128
128
 
129
129
  ## 管线
130
130
 
@@ -179,7 +179,7 @@ tool_call
179
179
  - 并行灰区调用串行裁决
180
180
  - 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
181
181
  - 影子缓存按决议仅观察不生效;实测命中率达标后,生效开关是一行改动
182
- - `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
182
+ - `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。路径归一化亦为基础档(ADR-0002):经符号链接目录写入尚不存在的目标不重建真实形、不产生命中——该间接路径同样由话术警戒覆盖(祖先重建档只适用于自保护层与路径敏感度 floor,不适用 denyPaths)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
183
183
  - `denyPaths` 的 bash token 不含空格:**声明路径本身含空格时**,bash 拼写无法被提取器识别——`cat "/path with space/x"` 被拆成两个 token 永不命中(文件类工具仍命中,其路径不经 token 化)。glob 覆盖基名末段(`denyPaths: ["/proj/personal"]` 时 `cat /proj/pers*`)同样漏过——基名自身从未字面出现。两个洞与上述替换/base64 一样回落到分类器的存在性话术
184
184
  - 自保护 bash 匹配是子串正则——可被混淆绕过;变更检测兜底覆盖会话内绕过,跨会话基线(启动时哈希比对与变更确认,含升级 UX)按 ADR-0001 为二期
185
185
  - dev checkout(从仓库而非 `<agentDir>/extensions/` 运行扩展)不受自保护——下一个正常会话加载的安装副本只在其自身会话的门禁内受保护
@@ -36,6 +36,12 @@
36
36
  * 3. Three-state verdict: allow passes / deny blocks / ask goes to a human
37
37
  * (ctx.ui.confirm)
38
38
  *
39
+ * Structure: the pipeline is adjudicate() — a zero-UI module returning a Verdict
40
+ * value object (source: rule|protected-path|classifier|fail-closed, plus a
41
+ * `degraded` flag for ask→deny in non-interactive sessions); the tool_call
42
+ * handler maps verdicts to UI (notify/confirm/select) by source × degraded and
43
+ * runs IntegrityWatch (ADR-0001) as a pre-pipeline gate-integrity check.
44
+ *
39
45
  * Shadow cache (observe-only, #7): gray-zone verdicts are replayed against a
40
46
  * double-key LRU(128) to measure would-be hit rate; recorded, never applied
41
47
  * (verdicts always come from the model), accumulating pi field data for the
@@ -136,6 +142,59 @@ function classifyBash(command: string, floorOn: boolean): RuleResult {
136
142
  return { verdict: "gray", reason: "no built-in allowlist" };
137
143
  }
138
144
 
145
+ // ============================================================================
146
+ // 规范形:双形匹配两档的唯一实现(纪律见 CONTEXT.md「双形匹配」词条)
147
+ // ============================================================================
148
+
149
+ /**
150
+ * 基础档(ADR-0002):词法绝对形 + 整路径 realpath 形(realpath 解析 symlink
151
+ * 间接;失败——目标不存在、glob token——降级为仅词法形)。denyPaths 与一切
152
+ * 「基址侧」双形集合(cwd 基址、agentDir、安装根、受保护集合、基线快照)走这一档。
153
+ */
154
+ function baseForms(p: string): string[] {
155
+ const out = [p];
156
+ try {
157
+ const r = fs.realpathSync(p);
158
+ if (r !== p) out.push(r);
159
+ } catch {
160
+ /* 不存在:仅词法形 */
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * 祖先重建档(#20):基础形之外,目标尚不存在时自最近存在祖先的 realpath 逐级
167
+ * 重建真实形——symlink 别名即使最终段不存在也暴露其真实位置。误放行代价高的
168
+ * 判定(自保护层、路径敏感度 floor)走这一档;denyPaths 不升档(ADR-0002)。
169
+ */
170
+ function rebuiltForms(abs: string): string[] {
171
+ const out = new Set<string>([abs]);
172
+ let dir = abs;
173
+ const tail: string[] = [];
174
+ for (;;) {
175
+ try {
176
+ const real = fs.realpathSync(dir);
177
+ out.add(path.join(real, ...tail));
178
+ return [...out];
179
+ } catch {
180
+ const parent = path.dirname(dir);
181
+ if (parent === dir) return [...out];
182
+ tail.unshift(path.basename(dir));
183
+ dir = parent;
184
+ }
185
+ }
186
+ }
187
+
188
+ /** Case-insensitive filesystems (default macOS APFS, Windows) compare path strings
189
+ * case-folded; realpath already normalizes case whenever it resolves, this covers
190
+ * the lexical-only forms of nonexistent targets (#21). Linux stays case-sensitive.
191
+ * 折叠比较仅 denyPaths 消费(S-rules 的比较纪律在正则 /i、自保护层在精确匹配
192
+ * ——各自持有,不因本模块统一,见双形匹配词条)。 */
193
+ const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
194
+ const fold = (s: string): string => (CASE_INSENSITIVE_FS ? s.toLowerCase() : s);
195
+ const pathEquals = (a: string, b: string): boolean => fold(a) === fold(b);
196
+ const pathStartsWith = (child: string, base: string): boolean => fold(child).startsWith(fold(base) + path.sep);
197
+
139
198
  // ============================================================================
140
199
  // 用户规则:白名单/黑名单(可配置;#12 审计响应)
141
200
  //
@@ -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
  }
@@ -347,38 +406,13 @@ const S1_SYSTEM = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$
347
406
  const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/i, /crontab/i, /Library\/LaunchAgents(\/|$)/i, /\.config\/systemd(\/|$)/i];
348
407
  const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/i, /(^|\/)\.gitmodules$/i];
349
408
 
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
409
  /** read 类工具:S0 读取即高危(deny),其余读取放行。isWrite: write/edit 走完整分级 */
376
410
  function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean, floorOn: boolean): RuleResult {
377
411
  const abs = path.resolve(cwd, expandHome(rawPath));
378
412
  // Dual-form matching (#20): rules test every canonical form of the target —
379
413
  // a project-local symlink aliasing ~/.ssh or a .git/hooks dir must not pass
380
414
  // the floor on its lexical spelling alone.
381
- const forms = targetForms(abs);
415
+ const forms = rebuiltForms(abs);
382
416
  const hit = (rules: RegExp[]) => forms.some((f) => rules.some((r) => r.test(f)));
383
417
  // floor 关闭时:内置 deny 一律降级 gray(永不升格 allow);非 deny 分支(allow/gray)保持
384
418
  const D = floorOn
@@ -396,7 +430,7 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
396
430
  // In-cwd write allowance (#20): every canonical form must sit inside the cwd
397
431
  // (in either its lexical or real form) — a lexical prefix hit whose real
398
432
  // form escapes the project (symlink alias) grades as an outside-cwd write.
399
- const cwdBases = new Set([cwd, tryRealpath(cwd)]);
433
+ const cwdBases = new Set(baseForms(cwd));
400
434
  const inCwd = (f: string) => [...cwdBases].some((b) => f === b || f.startsWith(b + path.sep));
401
435
  if (forms.every(inCwd)) return { verdict: "allow" };
402
436
  return { verdict: "gray", reason: `write outside project directory (CWD): ${rawPath}` };
@@ -455,20 +489,14 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
455
489
  const BASH_PATH_TOKENS =
456
490
  /(?:~|\$HOME)(?:\/[\w.@*-]+)*|\/(?:[\w.@*-]+\/)*[\w.@*-]*|\.{1,2}(?:\/[\w.@*-]+)+|[\w.-]+(?:\/[\w.-]+)+/g;
457
491
 
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 */
492
+ /** Normalized forms of one path for denyPaths comparison: base tier only (ADR-0002) —
493
+ * no ancestor rebuild; a nonexistent target under a symlinked dir falls to the
494
+ * classifier + existence hint instead (pinned by a regression test). */
467
495
  function denyPathForms(raw: string, cwd: string): string[] {
468
496
  if (!raw) return [];
469
497
  // denyPaths spellings accept $HOME/ as an alias for ~/ (user-rule targets stay raw strings — no $ expansion there)
470
498
  const expanded = expandHome(raw.replace(/^\$HOME(?=\/|$)/, os.homedir()));
471
- return pathForms(path.resolve(cwd, expanded));
499
+ return baseForms(path.resolve(cwd, expanded));
472
500
  }
473
501
 
474
502
  /** Normalize the configured denyPaths against one cwd (ADR-0002: anchored once per session, never re-derived) */
@@ -529,26 +557,6 @@ interface ProtectedSet {
529
557
 
530
558
  type WatchKind = "config" | "extension";
531
559
 
532
- function tryRealpath(p: string): string {
533
- try {
534
- return fs.realpathSync(p);
535
- } catch {
536
- return p;
537
- }
538
- }
539
-
540
- /** 路径的全部规范形:词法绝对 + realpath(存在且不同时追加) */
541
- function pathForms(p: string): string[] {
542
- const out = [p];
543
- try {
544
- const r = fs.realpathSync(p);
545
- if (r !== p) out.push(r);
546
- } catch {
547
- /* 不存在:仅词法形 */
548
- }
549
- return out;
550
- }
551
-
552
560
  function escapeRegExp(s: string): string {
553
561
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
554
562
  }
@@ -566,7 +574,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
566
574
  const prefixes = new Set<string>();
567
575
  const configPath = path.join(agentDir, "config", "pi-verdict.json");
568
576
  const watchBases: Array<{ file: string; kind: WatchKind }> = [{ file: configPath, kind: "config" }];
569
- for (const f of pathForms(configPath)) exact.add(f);
577
+ for (const f of baseForms(configPath)) exact.add(f);
570
578
 
571
579
  // 安装副本目标:单文件形态 → 文件本体(exact);npm 目录形态 → 包根目录(prefix)。
572
580
  // extRoot 与 ownFile 各取词法/realpath 双形交叉判定,集合同样双形收录——
@@ -620,17 +628,15 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
620
628
  // → exact, a package dir (`@scope/pkg` or `pkg`) → prefix, so every
621
629
  // npm form gets whole-package-dir protection (#26).
622
630
  const extRoots = new Set<string>();
623
- const agentBases = new Set([agentDir, tryRealpath(agentDir)]);
631
+ const agentBases = new Set(baseForms(agentDir));
624
632
  const configRootBases = new Set([...agentBases].map((b) => path.dirname(b)));
625
633
  for (const seg of [["extensions"], ["plugins", "node_modules"]]) {
626
634
  const bases = seg.length === 2 ? new Set([...agentBases, ...configRootBases]) : agentBases;
627
635
  for (const base of bases) {
628
- const root = path.join(base, ...seg);
629
- extRoots.add(root);
630
- extRoots.add(tryRealpath(root));
636
+ for (const root of baseForms(path.join(base, ...seg))) extRoots.add(root);
631
637
  }
632
638
  }
633
- const ownForms = new Set([ownFile, tryRealpath(ownFile)]);
639
+ const ownForms = new Set(baseForms(ownFile));
634
640
  for (const extRoot of extRoots) {
635
641
  for (const own of ownForms) {
636
642
  if (!own.startsWith(extRoot + path.sep)) continue;
@@ -639,14 +645,14 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
639
645
  // npm scopes are two-segment dirs (@scope/pkg): the install
640
646
  // target is the package, not the whole scope dir
641
647
  const target = singleFile ? own : path.join(extRoot, ...segs.slice(0, segs[0].startsWith("@") ? 2 : 1));
642
- for (const f of pathForms(target)) {
648
+ for (const f of baseForms(target)) {
643
649
  (singleFile ? exact : prefixes).add(f);
644
650
  extTargets.add(f);
645
651
  }
646
652
  if (!singleFile && pkgRoot === null) pkgRoot = target;
647
653
  }
648
654
  }
649
- // one walk of the package root (lexical form; takeSnapshots' pathForms
655
+ // one walk of the package root (lexical form; takeSnapshots' baseForms
650
656
  // expansion picks up real forms per file) — no duplicate entries
651
657
  if (pkgRoot !== null) {
652
658
  for (const f of listPackageFiles(pkgRoot)) {
@@ -670,7 +676,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
670
676
  alts.add("\\$HOME/" + escapeRegExp(rel));
671
677
  }
672
678
  // $PI_CODING_AGENT_DIR 变体:词法与 realpath 两种基名列举(符号链接目录容忍)
673
- for (const base of new Set([agentDir, tryRealpath(agentDir)])) {
679
+ for (const base of new Set(baseForms(agentDir))) {
674
680
  if (f.startsWith(base + path.sep)) {
675
681
  alts.add("\\$PI_CODING_AGENT_DIR/" + escapeRegExp(f.slice(base.length + 1)));
676
682
  }
@@ -687,7 +693,7 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
687
693
  * nearest existing ancestor, #20) */
688
694
  export function isProtectedWritePath(rawPath: string, cwd: string, prot: ProtectedSet): boolean {
689
695
  if (!rawPath) return false;
690
- for (const c of targetForms(path.resolve(cwd, expandHome(rawPath)))) {
696
+ for (const c of rebuiltForms(path.resolve(cwd, expandHome(rawPath)))) {
691
697
  if (prot.exact.includes(c)) return true;
692
698
  for (const p of prot.prefixes) {
693
699
  if (c === p || c.startsWith(p + path.sep)) return true;
@@ -697,7 +703,7 @@ export function isProtectedWritePath(rawPath: string, cwd: string, prot: Protect
697
703
  }
698
704
 
699
705
  /** 自保护层裁决(第 0 层,先于一切):触碰门禁自身文件 → 不可豁免的 deny;其余 null 交后续层 */
700
- export function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
706
+ function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
701
707
  switch (toolName) {
702
708
  case "write":
703
709
  case "edit":
@@ -727,7 +733,7 @@ function takeSnapshots(bases: Array<{ file: string; kind: WatchKind }>): Array<{
727
733
  const out: Array<{ file: string; kind: WatchKind; content: Buffer | null }> = [];
728
734
  const seen = new Set<string>();
729
735
  for (const b of bases) {
730
- for (const f of pathForms(b.file)) {
736
+ for (const f of baseForms(b.file)) {
731
737
  if (seen.has(f)) continue;
732
738
  seen.add(f);
733
739
  let content: Buffer | null = null;
@@ -742,6 +748,68 @@ function takeSnapshots(bases: Array<{ file: string; kind: WatchKind }>): Array<{
742
748
  return out;
743
749
  }
744
750
 
751
+ /**
752
+ * 变更检测(ADR-0001 一期)的会话实例:基线快照 + 篡改旗标。处置差分(config-only
753
+ * 且有 UI → select 双选)由扩展 handler 编排——本类零 UI;restoreAndFailClose 只做
754
+ * 还原与置位,通知由调用方按返回的文件清单/原因拼装。
755
+ */
756
+ class IntegrityWatch {
757
+ private snapshots: Array<{ file: string; kind: WatchKind; content: Buffer | null }>;
758
+ private _tampered = false;
759
+
760
+ constructor(private watchBases: Array<{ file: string; kind: WatchKind }>) {
761
+ this.snapshots = takeSnapshots(watchBases);
762
+ }
763
+
764
+ get tampered(): boolean {
765
+ return this._tampered;
766
+ }
767
+
768
+ /** 复核受保护文件,返回变化清单(不处置——处置按 kind 差分,ADR-0001 定稿 D) */
769
+ detect(): Array<{ file: string; kind: WatchKind }> {
770
+ const hit: Array<{ file: string; kind: WatchKind }> = [];
771
+ for (const s of this.snapshots) {
772
+ let current: Buffer | null = null;
773
+ try {
774
+ current = fs.readFileSync(s.file);
775
+ } catch {
776
+ /* 不存在 */
777
+ }
778
+ const same = (a: Buffer | null, b: Buffer | null): boolean => (a === null || b === null ? a === b : a.equals(b));
779
+ if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
780
+ }
781
+ return hit;
782
+ }
783
+
784
+ /** 重建基线(Accept 路径与 session_start 共用;不动篡改旗标) */
785
+ rebaseline(): void {
786
+ this.snapshots = takeSnapshots(this.watchBases);
787
+ }
788
+
789
+ /** 会话重置:重建基线 + 清篡改旗标 */
790
+ startSession(): void {
791
+ this.rebaseline();
792
+ this._tampered = false;
793
+ }
794
+
795
+ /** 从快照回写变化文件(扩展进程自身执行,不经门禁)+ fail-closed 置位 */
796
+ restoreAndFailClose(changed: Array<{ file: string }>, cause: string): { reason: string; files: string } {
797
+ for (const c of changed) {
798
+ const s = this.snapshots.find((x) => x.file === c.file);
799
+ if (s && s.content !== null) {
800
+ try {
801
+ fs.writeFileSync(s.file, s.content);
802
+ } catch {
803
+ /* 还原失败:仍 fail-closed */
804
+ }
805
+ }
806
+ }
807
+ this._tampered = true;
808
+ const files = [...new Set(changed.map((c) => c.file))].join(", ");
809
+ return { reason: `[auto-mode] self-protection: tamper detected${cause ? ` (${cause})` : ""} and restored (${files}); fail-closed until restart`, files };
810
+ }
811
+ }
812
+
745
813
  /**
746
814
  * Tool call → rule-layer verdict. Order (#12; ADR-0001 adds layer 0; ADR-0002 inserts denyPaths):
747
815
  * 0. self-protection — deny is terminal (no config exempts it, not even builtinDenyFloor:false)
@@ -851,15 +919,19 @@ function toolCallLine(name: string, args: Record<string, unknown>): string {
851
919
  return `${name}: ${transcriptSafe(JSON.stringify(args))}`;
852
920
  }
853
921
 
922
+ /** 判定管线对宿主会话的最小结构需求(转录源 + 会话 id)——adjudicate 不接完整
923
+ * ExtensionContext,测试只喂这两个成员即可 */
924
+ export type PipelineHost = Pick<ExtensionContext["sessionManager"], "getBranch" | "getSessionId">;
925
+
854
926
  /**
855
927
  * 从会话分支收集精简转录原料:user 消息行与 assistant 工具调用行。
856
928
  * 丢弃 assistant 叙述/thinking 与 toolResult(注入面与 token 大头)。
857
929
  * 影子缓存的 contextKey 与 buildTranscript 同源(同一批 user 行),保证键与模型输入一致。
858
930
  */
859
- function collectTranscriptParts(ctx: ExtensionContext): { userLines: string[]; toolLines: string[] } {
931
+ function collectTranscriptParts(host: PipelineHost): { userLines: string[]; toolLines: string[] } {
860
932
  const userLines: string[] = [];
861
933
  const toolLines: string[] = [];
862
- for (const entry of ctx.sessionManager.getBranch()) {
934
+ for (const entry of host.getBranch()) {
863
935
  if (entry.type !== "message") continue;
864
936
  const msg = entry.message;
865
937
  if (msg.role === "user") {
@@ -875,8 +947,8 @@ function collectTranscriptParts(ctx: ExtensionContext): { userLines: string[]; t
875
947
  }
876
948
 
877
949
  /** 精简转录:最近 user 消息 + 最近工具调用,待审查动作固定为最后一行(位置约定,借鉴 CC) */
878
- function buildTranscript(ctx: ExtensionContext, actionLine: string): string {
879
- const { userLines, toolLines } = collectTranscriptParts(ctx);
950
+ function buildTranscript(host: PipelineHost, actionLine: string): string {
951
+ const { userLines, toolLines } = collectTranscriptParts(host);
880
952
  const lines = [...userLines.slice(-MAX_USER_MESSAGES), ...toolLines.slice(-MAX_TOOL_CALLS)];
881
953
  lines.push(actionLine);
882
954
  return lines.join("\n");
@@ -949,18 +1021,22 @@ function completionFor(registry: { complete?: unknown }, compatLoader?: CompatLo
949
1021
  return fn;
950
1022
  }
951
1023
 
1024
+ /** 分类器思考级别(pi 原生词表;后缀语法对齐 pi --model provider/id:thinking) */
1025
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
1026
+
952
1027
  /** 单次分类器调用:显式 reasoning:"off"(见下方注释),失败返回错误串而非抛出 */
953
1028
  async function callClassifierOnce(
954
- ctx: ExtensionContext,
1029
+ host: PipelineHost,
1030
+ signal: AbortSignal | undefined,
955
1031
  complete: CompletionFn,
956
1032
  model: NonNullable<ExtensionContext["model"]>,
957
1033
  userMessage: string,
958
1034
  maxTokens: number,
959
- thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
1035
+ thinking: ThinkingLevel = "off",
960
1036
  systemPrompt: string = CLASSIFIER_SYSTEM,
961
1037
  ): Promise<{ ok: true; text: string; stopReason: string } | { ok: false; error: string }> {
962
1038
  const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
963
- if (ctx.signal) signals.push(ctx.signal);
1039
+ if (signal) signals.push(signal);
964
1040
  try {
965
1041
  const response = await complete(
966
1042
  model,
@@ -992,7 +1068,7 @@ async function callClassifierOnce(
992
1068
  reasoning: thinking === "minimal" ? ("low" as const) : thinking,
993
1069
  }),
994
1070
  cacheRetention: "short",
995
- sessionId: ctx.sessionManager.getSessionId(),
1071
+ sessionId: host.getSessionId(),
996
1072
  },
997
1073
  );
998
1074
  const text = response.content
@@ -1012,21 +1088,22 @@ async function callClassifierOnce(
1012
1088
  * 两档皆失败 → fail-closed deny(理由含两次诊断)。
1013
1089
  */
1014
1090
  async function classifyWithModel(
1015
- ctx: ExtensionContext,
1091
+ host: PipelineHost,
1092
+ signal: AbortSignal | undefined,
1016
1093
  complete: CompletionFn,
1017
1094
  model: NonNullable<ExtensionContext["model"]>,
1018
1095
  actionLine: string,
1019
- thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
1096
+ thinking: ThinkingLevel = "off",
1020
1097
  denyPathsActive = false,
1021
1098
  ): Promise<ClassifierOutcome> {
1022
- const transcript = buildTranscript(ctx, actionLine);
1099
+ const transcript = buildTranscript(host, actionLine);
1023
1100
  const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
1024
1101
  const systemPrompt = denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
1025
1102
  const attempts: Array<[number, number]> = [[1, CLASSIFIER_MAX_TOKENS], [2, CLASSIFIER_RETRY_MAX_TOKENS]];
1026
1103
  const failures: string[] = [];
1027
1104
  for (const [n, maxTokens] of attempts) {
1028
- if (ctx.signal?.aborted) break; // 用户已取消,不再重试
1029
- const r = await callClassifierOnce(ctx, complete, model, userMessage, maxTokens, thinking, systemPrompt);
1105
+ if (signal?.aborted) break; // 用户已取消,不再重试
1106
+ const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt);
1030
1107
  if (r.ok) {
1031
1108
  const diag = `stopReason=${r.stopReason}, model=${model.id}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
1032
1109
  if (r.stopReason !== "error" && r.stopReason !== "aborted") {
@@ -1151,8 +1228,8 @@ function shadowCommandKey(toolName: string, input: Record<string, unknown>, cwd:
1151
1228
  return fnv1a(`${toolName}\u0000${JSON.stringify(input)}\u0000${cwd}`);
1152
1229
  }
1153
1230
 
1154
- function shadowContextKey(ctx: ExtensionContext): string {
1155
- const { userLines } = collectTranscriptParts(ctx);
1231
+ function shadowContextKey(host: PipelineHost): string {
1232
+ const { userLines } = collectTranscriptParts(host);
1156
1233
  return fnv1a(userLines.slice(-MAX_USER_MESSAGES).join("\u0000"));
1157
1234
  }
1158
1235
 
@@ -1162,6 +1239,121 @@ function shadowTag(probe: ShadowProbe): string {
1162
1239
  return `(shadow cache: miss:no-entry)`;
1163
1240
  }
1164
1241
 
1242
+ // ============================================================================
1243
+ // 会话态:判定管线的会话期状态(复位清单集中一处)
1244
+ // ============================================================================
1245
+
1246
+ /**
1247
+ * 判定管线的会话期状态。session_start 的复位清单归 reset() 拥有——新增会话态只改
1248
+ * 这里,install 与 session_start 不再各持一份初始化点。prot 源自安装路径而非配置,
1249
+ * 构造期定,不参与 reset。导出仅为测试(内部 seam 的测试面,与 adjudicate 同组)。
1250
+ */
1251
+ export class SessionState {
1252
+ readonly prot: ProtectedSet;
1253
+ readonly shadow = new ShadowCache();
1254
+ userRules: UserRules;
1255
+ private denyPathBases: string[] | null = null;
1256
+
1257
+ constructor(prot: ProtectedSet, userRules: UserRules = loadUserRules().rules) {
1258
+ this.prot = prot;
1259
+ this.userRules = userRules;
1260
+ }
1261
+
1262
+ /** 会话重置:重载用户规则(配置改动新会话生效)+ 按会话 cwd 重锚 denyPaths
1263
+ * (ADR-0002: 每会话锚定一次)+ 清影子缓存;返回加载报告供表现层通知 */
1264
+ reset(cwd: string): { skipped: string[]; shortcutWarning: string | null } {
1265
+ const loaded = loadUserRules();
1266
+ this.userRules = loaded.rules;
1267
+ this.denyPathBases = anchorDenyPaths(loaded.rules.denyPaths, cwd); // anchored to the session cwd, once (ADR-0002)
1268
+ this.shadow.reset();
1269
+ return { skipped: loaded.skipped, shortcutWarning: loaded.shortcutWarning };
1270
+ }
1271
+
1272
+ /** denyPaths 基址:session_start 已锚定;此惰性回退仅守护乱序的首次 tool_call
1273
+ * (pi 正常次序 session_start 先行),一旦锚定不再重derive。 */
1274
+ anchoredDenyPathBases(cwd: string): string[] {
1275
+ if (this.denyPathBases === null) this.denyPathBases = anchorDenyPaths(this.userRules.denyPaths, cwd);
1276
+ return this.denyPathBases;
1277
+ }
1278
+ }
1279
+
1280
+ // ============================================================================
1281
+ // 判定管线(adjudicate):tool_call → Verdict 的唯一裁决入口,零 UI 依赖
1282
+ // ============================================================================
1283
+
1284
+ /** 裁决来源:呈现模板的键之一(与 degraded 正交分解)。rule = 规则层(含自保护层
1285
+ * ——同走规则呈现模板);protected-path = denyPaths 命中;classifier = 灰区分类器
1286
+ * 结果(含其 fail-closed——呈现模板相同);fail-closed = 无可用分类器模型 */
1287
+ export type VerdictSource = "rule" | "protected-path" | "classifier" | "fail-closed";
1288
+
1289
+ /** 判定管线的输出值对象:一次 tool_call 的完整裁决。detail 为 UI-only 明文(受保护
1290
+ * 路径仅入本地确认框,ADR-0002 零泄漏承诺——reason 与通知永不携带);degraded 标记
1291
+ * ask 在无 UI 会话的降级产物;shadow 为影子缓存标注(仅 debug 呈现拼接用)。 */
1292
+ export interface Verdict {
1293
+ verdict: "allow" | "ask" | "deny";
1294
+ reason: string;
1295
+ detail?: string;
1296
+ source: VerdictSource;
1297
+ degraded: boolean;
1298
+ shadow?: string;
1299
+ }
1300
+
1301
+ /** 逐调用环境:呈现无关的宿主能力。model 经 getModel 惰性求值——保持「仅灰区才
1302
+ * 解析」的原行为(回退警告不会出现在规则已裁决的调用上);null → fail-closed。 */
1303
+ export interface AdjudicateEnv {
1304
+ cwd: string;
1305
+ hasUI: boolean;
1306
+ getModel: () => { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null;
1307
+ complete: CompletionFn;
1308
+ host: PipelineHost;
1309
+ signal?: AbortSignal;
1310
+ }
1311
+
1312
+ /**
1313
+ * 判定管线(CONTEXT.md「判定管线」词条的实现):自保护 → 内置 floor → 用户 deny →
1314
+ * denyPaths ask → 用户 allow → 灰区分类器;ask 降级(无 UI → deny)与 fail-closed
1315
+ * 内建于此,两处重复的降级实现自此唯一。零 UI:表现(notify/confirm/select)由扩展
1316
+ * handler 按 source × degraded 模板呈现;变更检测(IntegrityWatch)是管线前置的
1317
+ * 独立关注点,不在 adjudicate 内。导出仅为测试(内部 seam 的测试面,#35 既有模式)。
1318
+ */
1319
+ export async function adjudicate(
1320
+ state: SessionState,
1321
+ call: { toolName: string; input: Record<string, unknown> },
1322
+ env: AdjudicateEnv,
1323
+ ): Promise<Verdict> {
1324
+ const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
1325
+ if (rule.verdict === "allow") return { verdict: "allow", reason: rule.reason ?? "", source: "rule", degraded: false };
1326
+ if (rule.verdict === "deny") return { verdict: "deny", reason: rule.reason ?? "", source: "rule", degraded: false };
1327
+ if (rule.verdict === "ask") {
1328
+ // denyPaths 命中 → ask 终局(ADR-0002):声明者本人裁决例外;无 UI 降级为 deny
1329
+ return { verdict: env.hasUI ? "ask" : "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: !env.hasUI };
1330
+ }
1331
+
1332
+ // 灰区 → 分类器;无可用模型 → fail-closed
1333
+ const resolved = env.getModel();
1334
+ if (!resolved) return { verdict: "deny", reason: "no classifier model available (fail-closed)", source: "fail-closed", degraded: false };
1335
+
1336
+ // 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
1337
+ const cmdKey = shadowCommandKey(call.toolName, call.input, env.cwd);
1338
+ const ctxKey = shadowContextKey(env.host);
1339
+ const probe = state.shadow.probe(cmdKey, ctxKey);
1340
+
1341
+ const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, toolCallLine(call.toolName, call.input), resolved.thinking, state.userRules.denyPaths.length > 0);
1342
+
1343
+ // 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
1344
+ // 命中且本次为可缓存裁决时,对比反事实一致性
1345
+ if (outcome.source === "model" && outcome.verdict !== "ask") {
1346
+ if (probe.result === "hit") state.shadow.countDivergence(probe.entry.verdict, outcome.verdict);
1347
+ state.shadow.record(cmdKey, ctxKey, outcome.verdict);
1348
+ }
1349
+
1350
+ const shadow = shadowTag(probe);
1351
+ if (outcome.verdict === "allow") return { verdict: "allow", reason: outcome.reason, source: "classifier", degraded: false, shadow };
1352
+ if (outcome.verdict === "deny") return { verdict: "deny", reason: outcome.reason, source: "classifier", degraded: false, shadow };
1353
+ // ask:无 UI 降级为 deny(ask 降级,CONTEXT.md 词条)
1354
+ return { verdict: env.hasUI ? "ask" : "deny", reason: outcome.reason, source: "classifier", degraded: !env.hasUI, shadow };
1355
+ }
1356
+
1165
1357
  // ============================================================================
1166
1358
  // 扩展主体
1167
1359
  // ============================================================================
@@ -1178,56 +1370,58 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1178
1370
 
1179
1371
  let enabled = pi.getFlag("auto-mode") !== false;
1180
1372
  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;
1373
+ // 会话态与门禁完整性监视:复位清单各归 SessionState.reset / IntegrityWatch.startSession
1374
+ const state = new SessionState(buildProtectedSet(agentDirPath(), OWN_FILE_PATH));
1375
+ const integrity = new IntegrityWatch(state.prot.watchBases);
1376
+
1377
+ /** 篡改处置呈现:还原 + fail-closed 的本地通知(含文件清单与原因) */
1378
+ function presentTamper(changed: Array<{ file: string; kind: WatchKind }>, ctx: ExtensionContext, cause: string): { block: true; reason: string } {
1379
+ const r = integrity.restoreAndFailClose(changed, cause);
1380
+ ctx.ui.notify(`🛡️ pi-verdict TAMPER DETECTED${cause ? ` (${cause})` : ""}: ${r.files} modified bypassing the gate; restored from session snapshot where possible. Fail-closed for the rest of this session — review the file(s) and restart the session.`, "warning");
1381
+ return { block: true, reason: r.reason };
1382
+ }
1198
1383
 
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
- /* 不存在 */
1384
+ /** Verdict → UI(本扩展唯一的裁决呈现点):按 source × degraded 查模板,文案与
1385
+ * 重构前逐字节一致。受保护路径分支的通知永不携带路径明文与 action
1386
+ * (ADR-0002 story 11:通知与 block reason 回流 agent context)。 */
1387
+ async function presentVerdict(v: Verdict, action: string, ctx: ExtensionContext): Promise<{ block: true; reason: string } | undefined> {
1388
+ if (v.verdict === "allow") {
1389
+ if (debug) {
1390
+ if (v.source === "rule") ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
1391
+ else if (v.source === "protected-path") ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
1392
+ else ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}${v.shadow ? " " + v.shadow : ""}`, "info");
1208
1393
  }
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 });
1394
+ return undefined;
1211
1395
  }
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
- }
1396
+ if (v.verdict === "deny") {
1397
+ if (v.source === "protected-path") {
1398
+ // 无 action 行:action 串可内嵌被触路径,通知不得携带受保护路径明文
1399
+ ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, protected-path ask→deny): ${v.reason}`, "warning");
1400
+ return { block: true, reason: `[auto-mode] protected-path ask degraded to block in non-interactive mode: ${v.reason}` };
1401
+ }
1402
+ if (v.source === "fail-closed") {
1403
+ ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
1404
+ return { block: true, reason: `[auto-mode] ${v.reason}` };
1405
+ }
1406
+ if (v.source === "rule") {
1407
+ ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
1408
+ return { block: true, reason: `[auto-mode rule block] ${v.reason}` };
1225
1409
  }
1410
+ ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}${debug && v.shadow ? " " + v.shadow : ""}`, "warning");
1411
+ return { block: true, reason: `[auto-mode classifier block] ${v.reason}` };
1226
1412
  }
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` };
1413
+ // ask → 人工确认;非交互已在管线内降级,能走到这里的必有 UI
1414
+ if (v.source === "protected-path") {
1415
+ const ok = await ctx.ui.confirm("🛡️ Auto Mode: protected path", `${action}\n\n${v.reason}\n\nProtected path: ${v.detail ?? "(see pi-verdict.json)"}\n\nAllow this access?`);
1416
+ if (ok) {
1417
+ // debug notify 不带 action 行:同上,通知不得携带受保护路径明文
1418
+ if (debug) ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
1419
+ return undefined;
1420
+ }
1421
+ return { block: true, reason: "[auto-mode] user declined protected-path access" };
1422
+ }
1423
+ const ok = await ctx.ui.confirm("🛡️ Auto Mode confirmation", `${action}\n\nClassifier opinion: ${v.reason}\n\nAllow execution?`);
1424
+ return ok ? undefined : { block: true, reason: "[auto-mode] user declined" };
1231
1425
  }
1232
1426
 
1233
1427
  function refreshStatus(ctx: ExtensionContext) {
@@ -1245,23 +1439,19 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1245
1439
  // session_start:重置影子缓存(会话内存态,#5 定案)+ 重载用户规则(配置改动新会话生效)
1246
1440
  // + 重建自保护基线(ADR-0001:受保护文件的会话启动快照)
1247
1441
  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");
1442
+ const report = state.reset(ctx.cwd);
1443
+ integrity.startSession();
1444
+ if (report.skipped.length > 0) {
1445
+ ctx.ui.notify(`pi-verdict: skipped ${report.skipped.length} invalid config value(s) in config (${userConfigPath()}): ${report.skipped.join(", ")}`, "warning");
1256
1446
  }
1257
- if (loaded.shortcutWarning) ctx.ui.notify(`pi-verdict: ${loaded.shortcutWarning}`, "warning");
1447
+ if (report.shortcutWarning) ctx.ui.notify(`pi-verdict: ${report.shortcutWarning}`, "warning");
1258
1448
  refreshStatus(ctx);
1259
1449
  });
1260
1450
 
1261
1451
  // 主开关 toggle 快捷键(#15):键位取首次加载的用户规则(会话内固定——改配置后
1262
1452
  // /reload 重载扩展或新会话生效);handler 与 /automode 语义等价,静默切换,
1263
1453
  // footer 始终显示是唯一反馈
1264
- const registeredToggleKey = userRules.toggleShortcut;
1454
+ const registeredToggleKey = state.userRules.toggleShortcut;
1265
1455
  if (registeredToggleKey) {
1266
1456
  // KeyId 是 pi 的编译期联合类型(运行时即 string);用户配置键位经 KEY_COMBO_RE
1267
1457
  // 运行时校验后断言转入,零依赖约束下不引入 pi 内部类型路径
@@ -1274,7 +1464,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1274
1464
  /** Usage 行的 toggle 提示(#15):无注册键位时不显示;显示注册时固定的键 */
1275
1465
  const toggleHint = () => (registeredToggleKey ? ` · toggle: ${registeredToggleKey}` : "");
1276
1466
  /** Status line denyPaths count (ADR-0002): shown only when configured */
1277
- const denyPathsHint = () => (userRules.denyPaths.length > 0 ? `\ndenyPaths: ${userRules.denyPaths.length} active` : "");
1467
+ const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
1278
1468
 
1279
1469
  pi.registerCommand("automode", {
1280
1470
  description: "Show Auto Mode status and shadow-cache stats, or set it: /automode on|off",
@@ -1282,7 +1472,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1282
1472
  const arg = args.trim().toLowerCase();
1283
1473
  // 裸调用:只读状态展示,无副作用(含影子缓存统计行)
1284
1474
  if (arg === "") {
1285
- ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${shadow.summary()}${denyPathsHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
1475
+ ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
1286
1476
  return;
1287
1477
  }
1288
1478
  // 幂等设定:与现值相同不翻转,仅确认
@@ -1293,7 +1483,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1293
1483
  const head = next
1294
1484
  ? `🛡️ Auto Mode enabled${changed ? "" : " (unchanged)"}: tool calls adjudicated by rules + classifier`
1295
1485
  : `Auto Mode disabled${changed ? "" : " (unchanged)"}: tool calls execute directly`;
1296
- ctx.ui.notify(`${head}\n${shadow.summary()}`, "info");
1486
+ ctx.ui.notify(`${head}\n${state.shadow.summary()}`, "info");
1297
1487
  return;
1298
1488
  }
1299
1489
  // 未知参数:严格拒绝并列出用法(大小写已归一化)
@@ -1319,28 +1509,29 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1319
1509
  return { specPart: raw, level: null };
1320
1510
  }
1321
1511
 
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) > 自省(会话模型)
1512
+ /** 解析分类器模型与思考级别:CLI flag > 环境变量 > 配置文件(classifierModel) >
1513
+ * 自省(会话模型)。不可用回退会话模型并警告一次;null = 连会话模型都没有
1514
+ * fail-closed。经 AdjudicateEnv.getModel 惰性调用(仅灰区),回退警告不会出现在
1515
+ * 规则已裁决的调用上。 */
1516
+ function resolveClassifier(ctx: ExtensionContext): { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null {
1327
1517
  const raw =
1328
- (pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? userRules.classifierModel;
1329
- classifierThinking = "off";
1518
+ (pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
1519
+ let thinking: ThinkingLevel = "off";
1330
1520
  if (raw) {
1331
1521
  const { specPart, level } = parseModelSpec(raw, ctx);
1332
- if (level) classifierThinking = level as typeof classifierThinking;
1522
+ thinking = (level ?? "off") as ThinkingLevel;
1333
1523
  const slash = specPart.indexOf("/");
1334
1524
  if (slash > 0) {
1335
1525
  const model = ctx.modelRegistry.find(specPart.slice(0, slash), specPart.slice(slash + 1));
1336
- if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return model;
1526
+ if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return { model, thinking };
1337
1527
  }
1338
1528
  if (!warnedClassifierModel) {
1339
1529
  warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
1340
1530
  ctx.ui.notify(`pi-verdict: classifier model "${raw}" unavailable (not found or no configured auth), falling back to session model (self-reflection)`, "warning");
1341
1531
  }
1342
1532
  }
1343
- return ctx.model ?? null; // 自省:继承当前会话模型
1533
+ // 自省:继承当前会话模型;显式指定的思考级别在回退时仍生效(原语义)
1534
+ return ctx.model ? { model: ctx.model, thinking } : null;
1344
1535
  }
1345
1536
 
1346
1537
  function describeAction(toolName: string, input: Record<string, unknown>): string {
@@ -1354,11 +1545,11 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1354
1545
  const action = describeAction(event.toolName, input);
1355
1546
 
1356
1547
  // 第 0 层前置:变更检测(ADR-0001)——篡改后本会话恒 deny(fail-closed)
1357
- if (tampered) {
1548
+ if (integrity.tampered) {
1358
1549
  ctx.ui.notify(`🛡️ Auto Mode blocked: self-protection fail-closed (tamper detected this session; restart to reset)\n ${action}`, "warning");
1359
1550
  return { block: true, reason: "[auto-mode] self-protection: fail-closed until session restart (protected file was tampered with)" };
1360
1551
  }
1361
- const changed = detectTamper();
1552
+ const changed = integrity.detect();
1362
1553
  if (changed.length > 0) {
1363
1554
  // 差分处置(ADR-0001 定稿 D):仅 config 变化且有 UI → select 双选(选项即动作);
1364
1555
  // 扩展副本被改 / 无 UI → 一律还原 + fail-closed。
@@ -1373,82 +1564,25 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1373
1564
  [CONFIG_ACCEPT_CHOICE, CONFIG_DECLINE_CHOICE],
1374
1565
  );
1375
1566
  if (choice === CONFIG_ACCEPT_CHOICE) {
1376
- snapshots = takeSnapshots(prot.watchBases); // 重建基线
1567
+ integrity.rebaseline(); // 重建基线
1377
1568
  ctx.ui.notify("pi-verdict: config change accepted — new baseline taken; applies to new sessions as usual", "info");
1378
1569
  } else {
1379
- return restoreAndFailClose(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
1570
+ return presentTamper(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
1380
1571
  }
1381
1572
  } else {
1382
- return restoreAndFailClose(changed, ctx, "");
1383
- }
1384
- }
1385
-
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;
1573
+ return presentTamper(changed, ctx, "");
1411
1574
  }
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
1575
  }
1435
1576
 
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" };
1577
+ // 判定管线( UI)→ 呈现(source × degraded 模板)
1578
+ const verdict = await adjudicate(state, { toolName: event.toolName, input }, {
1579
+ cwd: ctx.cwd,
1580
+ hasUI: !!ctx.hasUI,
1581
+ getModel: () => resolveClassifier(ctx),
1582
+ complete: completionFor(ctx.modelRegistry, deps.compatLoader),
1583
+ host: ctx.sessionManager,
1584
+ signal: ctx.signal,
1585
+ });
1586
+ return presentVerdict(verdict, action, ctx);
1453
1587
  });
1454
1588
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "A minimal permission gate for Pi in the style of Claude Code's auto mode",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",
7
- "main": "extensions/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"