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 +3 -3
- package/README.zh-CN.md +3 -3
- package/extensions/{auto-mode.ts → pi-verdict.ts} +359 -225
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ pi install npm:pi-verdict
|
|
|
54
54
|
omp plugin install npm:pi-verdict
|
|
55
55
|
|
|
56
56
|
# or directly from git — try it once
|
|
57
|
-
pi --extension ./extensions/
|
|
57
|
+
pi --extension ./extensions/pi-verdict.ts
|
|
58
58
|
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -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/
|
|
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/
|
|
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/
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
|
|
462
|
-
const fold = (s: string): string => (CASE_INSENSITIVE_FS ? s.toLowerCase() : s);
|
|
463
|
-
const pathEquals = (a: string, b: string): boolean => fold(a) === fold(b);
|
|
464
|
-
const pathStartsWith = (child: string, base: string): boolean => fold(child).startsWith(fold(base) + path.sep);
|
|
465
|
-
|
|
466
|
-
/** Normalized forms of one path (lexical + realpath when it exists) for denyPaths comparison */
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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'
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
931
|
+
function collectTranscriptParts(host: PipelineHost): { userLines: string[]; toolLines: string[] } {
|
|
860
932
|
const userLines: string[] = [];
|
|
861
933
|
const toolLines: string[] = [];
|
|
862
|
-
for (const entry of
|
|
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(
|
|
879
|
-
const { userLines, toolLines } = collectTranscriptParts(
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
1091
|
+
host: PipelineHost,
|
|
1092
|
+
signal: AbortSignal | undefined,
|
|
1016
1093
|
complete: CompletionFn,
|
|
1017
1094
|
model: NonNullable<ExtensionContext["model"]>,
|
|
1018
1095
|
actionLine: string,
|
|
1019
|
-
thinking:
|
|
1096
|
+
thinking: ThinkingLevel = "off",
|
|
1020
1097
|
denyPathsActive = false,
|
|
1021
1098
|
): Promise<ClassifierOutcome> {
|
|
1022
|
-
const transcript = buildTranscript(
|
|
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 (
|
|
1029
|
-
const r = await callClassifierOnce(
|
|
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(
|
|
1155
|
-
const { userLines } = collectTranscriptParts(
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
};
|
|
1192
|
-
|
|
1193
|
-
// Self-protection layer (ADR-0001): self-anchored protected set + tamper
|
|
1194
|
-
// baseline (in-memory, per session)
|
|
1195
|
-
const prot = buildProtectedSet(agentDirPath(), OWN_FILE_PATH);
|
|
1196
|
-
let snapshots = takeSnapshots(prot.watchBases);
|
|
1197
|
-
let tampered = false;
|
|
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
|
-
/**
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1210
|
-
if (!same(current, s.content)) hit.push({ file: s.file, kind: s.kind });
|
|
1394
|
+
return undefined;
|
|
1211
1395
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
denyPathBases = anchorDenyPaths(userRules.denyPaths, ctx.cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1253
|
-
snapshots = takeSnapshots(prot.watchBases);
|
|
1254
|
-
if (loaded.skipped.length > 0) {
|
|
1255
|
-
ctx.ui.notify(`pi-verdict: skipped ${loaded.skipped.length} invalid config value(s) in config (${userConfigPath()}): ${loaded.skipped.join(", ")}`, "warning");
|
|
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 (
|
|
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
|
-
/**
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1570
|
+
return presentTamper(changed, ctx, choice === undefined ? "config dialog dismissed" : "config change declined by user");
|
|
1380
1571
|
}
|
|
1381
1572
|
} else {
|
|
1382
|
-
return
|
|
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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
ctx.
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A minimal permission gate for Pi in the style of Claude Code's auto mode",
|
|
5
5
|
"author": "Jesset (https://github.com/jesset)",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"main": "extensions/
|
|
7
|
+
"main": "extensions/pi-verdict.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"extensions/
|
|
9
|
+
"extensions/pi-verdict.ts",
|
|
10
10
|
"README.md",
|
|
11
11
|
"README.zh-CN.md",
|
|
12
12
|
"LICENSE"
|