pi-verdict 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -3
- package/README.zh-CN.md +13 -2
- package/extensions/pi-verdict.ts +250 -57
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ pi-verdict 0.13+ requires **pi ≥ 0.99** and runs on pi only (native classifier
|
|
|
119
119
|
|
|
120
120
|
- `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
|
|
121
121
|
- `denyPaths` are plain paths you declare **protected** — touches trigger a terminal ask you adjudicate (non-interactive → deny); the classifier never learns the paths themselves, only that they exist. `grep`/`find`/`ls` compare their whole **search scope**: an omitted `path` (pi's default: the current directory) or a parent directory of a declared path triggers the ask as well. A fresh install pre-fills a **starter list** (`~/.ssh/`, `~/.gnupg`, `~/.mc`, shell rc/profile files)
|
|
122
|
-
- `ignoreTools` names uncovered tools (`todo`, `web_search`, MCP/custom tools) that skip adjudication — **allow with zero model calls**; entries naming covered tools (`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`) are inert: those stay governed by the deny floor and your allow/deny rules, and the self-protection layer always runs first. A fresh install pre-fills a **starter list** (`todo`, `ask_user_question`, `memory_write`, `memory_search` — observed harmless across the 1265-verdict production audit). Caveat: an exempted tool loses the classifier's `denyPaths` existence-hint vigilance (uncovered tools never hit the path extractor anyway)
|
|
122
|
+
- `ignoreTools` names uncovered tools (`todo`, `web_search`, MCP/custom tools) that skip adjudication — **allow with zero model calls**; entries naming covered tools (`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`) are inert: those stay governed by the deny floor and your allow/deny rules, and the self-protection layer always runs first. A fresh install pre-fills a **starter list** (`todo`, `ask_user_question`, `memory_write`, `memory_search` — observed harmless across the 1265-verdict production audit). Caveat: an exempted tool loses the classifier's `denyPaths` existence-hint vigilance (uncovered tools never hit the path extractor anyway); MCP tool names are normalized before matching — see [codemode & MCP](#pi-099-codemode--mcp-indirect-calls-are-still-gated)
|
|
123
123
|
- `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
|
|
124
124
|
- `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
|
|
125
125
|
- `classifierModel: "typesafe/jev-latest"` opts into the **native jev classifier** — one structured `classify()` call per gray-zone verdict via pi's built-in classifier catalog (TypeSafe direct, or Jev on OpenRouter/OpenCode/Cloudflare/Vercel); see [ADR-0005](docs/adr/0005-native-classifier-migration.md)
|
|
@@ -141,13 +141,23 @@ No built-in allowlist — every "always allow" claim is yours ([why](docs/config
|
|
|
141
141
|
- or try it once: `PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
|
|
142
142
|
|
|
143
143
|
**Notes**:
|
|
144
|
-
- Verdicts are structured `classify()` answers (choice + probabilities + confidence); the reason line keeps the historical `jev:`
|
|
144
|
+
- Verdicts are structured `classify()` answers (choice + probabilities + confidence); the reason line keeps the historical `jev:` probability breakdown (tag-free — the `<verdict>` prefix survives only in the audit record's rawResponse), other classifier APIs render `classifier:`
|
|
145
145
|
- Classifier specs resolve through pi's classifier catalog first (`findOfType`), chat registry second; on same-id dual listings (llama.cpp) the native entry wins; thinking suffixes on a classifier spec warn once and drop (recorded `thinking: null`)
|
|
146
146
|
- Custom endpoints: override the provider's `baseUrl` in models.json (the 0.12 `PI_VERDICT_JEV_URL` escape hatch is gone, as is `PI_VERDICT_JEV_TRANSPORT` — transport choice is now the spec itself)
|
|
147
147
|
- Carried-over limit: the denyPaths existence hint still does not reach classifier-typed models ([ADR-0005](docs/adr/0005-native-classifier-migration.md)); on the TypeSafe direct transport per-call cost shows $0 (its API does not report it)
|
|
148
148
|
|
|
149
149
|
jev's calibrated confidence is exactly what the confidence floor keys on — pair it with a second layer (`"classifierMinConfidence", "classifierFallbackModel"`) so its low-confidence calls go to a deeper model instead of standing ([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)).
|
|
150
150
|
|
|
151
|
+
### pi 0.99 codemode & MCP: indirect calls are still gated
|
|
152
|
+
|
|
153
|
+
pi 0.99 can run model-written JavaScript in a QuickJS sandbox (`codemode`) that calls pi's tools, and MCP servers register tools as `mcp__<server>__<tool>`. Neither surface bypasses this gate:
|
|
154
|
+
|
|
155
|
+
- **Nested calls are gated exactly like direct ones** — pi routes every tool call a codemode script makes through the same `tool_call` pipeline (tagged `parentToolCallId`, ids `<parent>/<n>`); a blocked call returns as an error to the script, which the model sees
|
|
156
|
+
- **MCP tools land in the gray zone** — the rule layer covers the built-in command/file tools only; each `mcp__*` call is classified, fail-closed included
|
|
157
|
+
- **`ignoreTools` and MCP names**: tool names are normalized — every character outside `[A-Za-z0-9_]` becomes `_` (`mcp__dev-radius__x` → `mcp__dev_radius__x`); exemption entries must use the normalized form
|
|
158
|
+
- **Cost amplification**: one script may issue up to 256 nested calls; gray-zone calls classify one by one, so a slow LLM classifier multiplies per-call latency
|
|
159
|
+
- **Exposure boundary**: adding an MCP server auto-enables codemode, and `pi --no-extensions -e builtin:mcp` runs MCP tools with no extensions loaded — i.e. without this gate. The gate is itself an extension, so it cannot be active in a session that loads none; the boundary is inherent to pi's extension model, stated here rather than papered over
|
|
160
|
+
|
|
151
161
|
### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
152
162
|
|
|
153
163
|
The gate's own files — the config and the installed extension copy — are **user-editable only**: writes from inside the gate hard-deny (reads pass); your editor never passes through the gate, the sudoers/visudo precedent.
|
|
@@ -195,7 +205,9 @@ tool_call
|
|
|
195
205
|
├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
|
|
196
206
|
│ ├─ input: CC-style <transcript> — recent user intent + tool calls,
|
|
197
207
|
│ │ action under review always last
|
|
198
|
-
│ └─ output
|
|
208
|
+
│ └─ output: native classify() answers (choice + probabilities + confidence;
|
|
209
|
+
│ tag-free reason, full contract line in the audit rawResponse) or,
|
|
210
|
+
│ on the chat path, <verdict>…</verdict> prefix-anchored free text
|
|
199
211
|
│
|
|
200
212
|
└─ 3. Three-state adjudication
|
|
201
213
|
├─ allow → pass
|
|
@@ -223,6 +235,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
|
|
|
223
235
|
- no built-in allowlist by design (see the [bypass writeup](research/rule-layer-security-audit.md)); with an empty `allow` config most commands go to the classifier — point `--auto-mode-model` at a fast model if per-call latency matters
|
|
224
236
|
- the path sensitivity floor applies to file tools only: bash command strings are matched by the danger regexes alone, so e.g. `cat ~/.ssh/id_rsa` goes to the classifier rather than the deterministic S0 deny (the file-tool spelling `read ~/.ssh/id_rsa` does deny)
|
|
225
237
|
- on Windows the built-in floor covers bash-shaped patterns only — PowerShell-native dangerous commands (`Remove-Item -Recurse -Force`, `Invoke-Expression`, `Set-ExecutionPolicy`, …) rely on the classifier (fail-closed)
|
|
238
|
+
- on macOS the per-user temp tree (`$TMPDIR`, the `/var/folders/…/T` confstr dir) is exempt from the system-directory floor: reads allow at zero cost, writes adjudicate as ordinary outside-project writes (classifier). The exemption anchors to the runtime-resolved confstr family — a hand-set `TMPDIR` lifts nothing — and `/var/tmp` (POSIX shared temp) stays denied; symlink spellings whose real form escapes the temp tree still hit S1
|
|
226
239
|
- AGENTS.md is not passed to the classifier as downweighted intent evidence (Claude Code does this)
|
|
227
240
|
- parallel gray-zone calls are adjudicated serially
|
|
228
241
|
- 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)
|
package/README.zh-CN.md
CHANGED
|
@@ -119,7 +119,7 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
|
|
|
119
119
|
|
|
120
120
|
- `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
|
|
121
121
|
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件)
|
|
122
|
-
- `ignoreTools` 列出规则未覆盖的工具(`todo`、`web_search`、MCP/自定义工具):**直接放行、零模型调用**;列出已覆盖工具(`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`)的条目无效:它们仍受 deny floor 与你的 allow/deny 规则约束,自保护层也永远先行。全新安装会预填一份**入门列表**(`todo`、`ask_user_question`、`memory_write`、`memory_search`——来自项目 1265 条生产审计的观察)。注意:被豁免的工具失去分类器对 `denyPaths` 的存在性话术警戒(未覆盖工具本就不进路径提取器)
|
|
122
|
+
- `ignoreTools` 列出规则未覆盖的工具(`todo`、`web_search`、MCP/自定义工具):**直接放行、零模型调用**;列出已覆盖工具(`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`)的条目无效:它们仍受 deny floor 与你的 allow/deny 规则约束,自保护层也永远先行。全新安装会预填一份**入门列表**(`todo`、`ask_user_question`、`memory_write`、`memory_search`——来自项目 1265 条生产审计的观察)。注意:被豁免的工具失去分类器对 `denyPaths` 的存在性话术警戒(未覆盖工具本就不进路径提取器);MCP 工具名匹配前会归一化——见 [codemode 与 MCP](#pi-099-codemode-与-mcp间接调用同样受门禁)
|
|
123
123
|
- `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
|
|
124
124
|
- `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
|
|
125
125
|
- `classifierModel: "typesafe/jev-latest"` 启用**原生 jev 分类器**——每次灰区裁决经 pi 内置分类器目录发一次结构化 `classify()` 调用(TypeSafe 直连,或 OpenRouter/OpenCode/Cloudflare/Vercel 上的 Jev);详见 [ADR-0005](docs/adr/0005-native-classifier-migration.md)
|
|
@@ -141,13 +141,23 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
|
|
|
141
141
|
- 或者临时试用一次:`PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
|
|
142
142
|
|
|
143
143
|
**说明**:
|
|
144
|
-
- 裁决为结构化 `classify()` 应答(choice + probabilities + confidence);reason 行保留历史 `jev:`
|
|
144
|
+
- 裁决为结构化 `classify()` 应答(choice + probabilities + confidence);reason 行保留历史 `jev:` 概率分解(无标签——`<verdict>` 前缀只存活于审计记录的 rawResponse),其余分类器 API 渲染 `classifier:`
|
|
145
145
|
- 分类器 spec 先经 pi 分类器目录解析(`findOfType`)、chat 注册表兜底;同 id 双型并存(llama.cpp)时原生条目优先;分类器 spec 上的思考后缀警告一次后丢弃(审计 `thinking` 记为 `null`)
|
|
146
146
|
- 自定义端点:在 models.json 覆盖 provider 的 `baseUrl`(0.12 的 `PI_VERDICT_JEV_URL` 逃生口与 `PI_VERDICT_JEV_TRANSPORT` 均已移除——transport 选择即 spec 本身)
|
|
147
147
|
- 沿袭限制:denyPaths 存在性话术仍不达分类器形态模型([ADR-0005](docs/adr/0005-native-classifier-migration.md));TypeSafe 直连的单次成本显示 $0(其 API 不返回 cost)
|
|
148
148
|
|
|
149
149
|
jev 的校准 confidence 正是置信地板的判定依据——搭配第二层使用(`"classifierMinConfidence", "classifierFallbackModel"`),让低置信调用交由更深的模型复裁,而非就地生效([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))。
|
|
150
150
|
|
|
151
|
+
### pi 0.99 codemode 与 MCP:间接调用同样受门禁
|
|
152
|
+
|
|
153
|
+
pi 0.99 可以在 QuickJS 沙箱(`codemode`)里运行模型写的 JavaScript 去调用 pi 的工具,MCP 服务器则以 `mcp__<server>__<tool>` 注册工具。这两个新增面都不会绕过本门禁:
|
|
154
|
+
|
|
155
|
+
- **嵌套调用与直接调用同样过门**——pi 把 codemode 脚本发起的每一次工具调用都路由到同一条 `tool_call` 管线(带 `parentToolCallId`,id 形如 `<父id>/<n>`);被拦截的调用以错误形式回传脚本,模型可见
|
|
156
|
+
- **MCP 工具落入灰区**——规则层只覆盖内置命令/文件工具;每次 `mcp__*` 调用都走分类器,含 fail-closed
|
|
157
|
+
- **`ignoreTools` 与 MCP 工具名**:工具名会归一化——`[A-Za-z0-9_]` 之外的字符统一变 `_`(`mcp__dev-radius__x` → `mcp__dev_radius__x`);豁免条目必须写归一化后的形态
|
|
158
|
+
- **成本放大**:单个脚本最多可发 256 次嵌套调用,灰区调用逐个分类,慢的 LLM 分类器会把单次延迟成倍放大
|
|
159
|
+
- **暴露边界**:添加 MCP 服务器会自动开启 codemode,而 `pi --no-extensions -e builtin:mcp` 可在不加载任何扩展(即无本门禁)的情况下启用 MCP 工具。门禁自身即扩展,在完全不加载扩展的会话中无法生效;此边界为 pi 扩展模型的固有属性,此处显式陈述而非掩饰
|
|
160
|
+
|
|
151
161
|
### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
152
162
|
|
|
153
163
|
门禁自身的文件——配置与扩展安装副本——**仅用户可改**:门禁之内的写入一律硬 deny(读放行);你的编辑器修改不经门禁,同类先例是 sudoers 必须经 visudo。
|
|
@@ -223,6 +233,7 @@ tool_call
|
|
|
223
233
|
- 设计上无内置白名单(见[绕过测试](research/rule-layer-security-audit.md)与[用户自定义规则](#用户自定义规则pi-verdictjson));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
|
|
224
234
|
- 路径敏感度 floor 只作用于文件类工具:bash 命令串仅匹配危险正则——`cat ~/.ssh/id_rsa` 走分类器而非确定性 S0 拦截(文件工具拼写 `read ~/.ssh/id_rsa` 会拦截)
|
|
225
235
|
- Windows 下内置 floor 仅覆盖 bash 形态模式——PowerShell 原生危险命令(`Remove-Item -Recurse -Force`、`Invoke-Expression`、`Set-ExecutionPolicy` 等)依赖分类器兜底(fail-closed)
|
|
236
|
+
- macOS 下 per-user 临时目录(`$TMPDIR`,`/var/folders/…/T` confstr 目录)豁免于系统目录 floor:读零成本放行,写按普通项目外写交分类器裁决。豁免锚定运行时解析的 confstr 族——手工设置 `TMPDIR` 不会解除任何保护;`/var/tmp`(POSIX 共享临时目录)维持拦截;真实形态逃逸临时树的符号链接拼写仍命中 S1
|
|
226
237
|
- AGENTS.md 未作为降权意图证据传入分类器(Claude Code 有此设计)
|
|
227
238
|
- 并行灰区调用串行裁决
|
|
228
239
|
- 自省意味着会话模型自身裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
|
package/extensions/pi-verdict.ts
CHANGED
|
@@ -461,7 +461,62 @@ const S0_SECRET = [
|
|
|
461
461
|
];
|
|
462
462
|
// /private prefixes: macOS firmlinks — /etc, /var are really /private/etc,
|
|
463
463
|
// /private/var, and realpath'd toolchain output uses the real spelling (#21)
|
|
464
|
-
|
|
464
|
+
// Split into two families (#83): the directory-prefix family (what the per-user
|
|
465
|
+
// temp exemption may lift on macOS) and the basename family (authorized_keys —
|
|
466
|
+
// staging it in a temp dir must stay denied; defense in depth for the
|
|
467
|
+
// stage-then-copy chain).
|
|
468
|
+
const S1_SYSTEM_DIRS = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$)/i, /^\/var(\/|$)/i, /^\/System(\/|$)/i];
|
|
469
|
+
const S1_SYSTEM_FILES = [/(^|\/)authorized_keys$/i];
|
|
470
|
+
const S1_SYSTEM = [...S1_SYSTEM_DIRS, ...S1_SYSTEM_FILES];
|
|
471
|
+
|
|
472
|
+
/** #83: the per-user temp exemption bases — macOS confstr family only, at the
|
|
473
|
+
* confstr DEPTH (two segments below the family root: /var/folders/<xx>/<yy>/…),
|
|
474
|
+
* so the family root itself or a stray one-level child can never widen the
|
|
475
|
+
* exemption. The guard is deliberate: os.tmpdir() reads the process env, so
|
|
476
|
+
* trusting it verbatim would let a hand-set TMPDIR (e.g. TMPDIR=/etc, or the
|
|
477
|
+
* family root) lift the floor over S1 trees — narrowing the never-config-exemptible
|
|
478
|
+
* floor must go through code review, not an env accident. Another user's confstr
|
|
479
|
+
* subtree technically passes the depth check but is macOS-permission-guarded
|
|
480
|
+
* (same-user threat only). /var/tmp (POSIX shared temp) intentionally stays S1.
|
|
481
|
+
* Returns [] (inert) on every other platform — their tmpdirs never match S1. */
|
|
482
|
+
export function computeTmpdirBases(
|
|
483
|
+
platform: NodeJS.Platform,
|
|
484
|
+
tmp: string,
|
|
485
|
+
realpath: (p: string) => string | null,
|
|
486
|
+
): string[] {
|
|
487
|
+
if (platform !== "darwin" || !tmp) return [];
|
|
488
|
+
const lexical = path.resolve(tmp);
|
|
489
|
+
const real = realpath(lexical);
|
|
490
|
+
const bases = real === null ? [lexical] : [lexical, real];
|
|
491
|
+
const confstrFamily = /^(?:\/private)?\/var\/folders\/[^/]+\/[^/]+(?:\/|$)/;
|
|
492
|
+
return bases.every((b) => confstrFamily.test(b)) ? bases : [];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** The session-constant exemption bases (os.tmpdir() is stable for a process
|
|
496
|
+
* lifetime); test-overridable via the exported seam below. */
|
|
497
|
+
function defaultTmpdirBases(): string[] {
|
|
498
|
+
return computeTmpdirBases(process.platform, os.tmpdir(), (p) => {
|
|
499
|
+
try {
|
|
500
|
+
return fs.realpathSync(p);
|
|
501
|
+
} catch {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let tmpdirExemptBases = defaultTmpdirBases();
|
|
508
|
+
|
|
509
|
+
/** Test seam for tmpdirExemptBases — exported for tests only (the internal-seam
|
|
510
|
+
* surface, the standing #35 pattern); null restores the production bases. */
|
|
511
|
+
export function setTmpdirBasesForTests(bases: string[] | null): void {
|
|
512
|
+
tmpdirExemptBases = bases ?? defaultTmpdirBases();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** #83: is EVERY canonical form of the target under the per-user temp tree?
|
|
516
|
+
* Intersection semantics (#20 discipline): a lexical temp spelling whose real
|
|
517
|
+
* form escapes (symlink to /etc) is NOT exempt — the S1 grading still applies. */
|
|
518
|
+
const tmpdirExempt = (forms: string[]): boolean =>
|
|
519
|
+
tmpdirExemptBases.length > 0 && forms.every((f) => tmpdirExemptBases.some((b) => f === b || f.startsWith(b + path.sep)));
|
|
465
520
|
const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/i, /crontab/i, /Library\/LaunchAgents(\/|$)/i, /\.config\/systemd(\/|$)/i];
|
|
466
521
|
const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/i, /(^|\/)\.gitmodules$/i];
|
|
467
522
|
|
|
@@ -479,11 +534,17 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
|
|
|
479
534
|
: (reason: string): RuleResult => ({ verdict: "gray", reason });
|
|
480
535
|
|
|
481
536
|
if (hit(S0_SECRET)) return D(`S0 secrets/credential path: ${rawPath}`);
|
|
537
|
+
// #83: under the per-user temp tree the DIRECTORY-prefix family of S1 lifts (a
|
|
538
|
+
// macOS confstr temp dir is per-user scratch, not a system directory); the
|
|
539
|
+
// basename family (authorized_keys) still applies, and the fall-through keeps the
|
|
540
|
+
// deny-only floor philosophy — reads regain the ordinary read allow, writes grade
|
|
541
|
+
// as ordinary outside-project writes (gray, classifier-adjudicated).
|
|
542
|
+
const s1Rules = tmpdirExempt(forms) ? S1_SYSTEM_FILES : S1_SYSTEM;
|
|
482
543
|
if (!isWrite) {
|
|
483
|
-
if (hit(
|
|
544
|
+
if (hit(s1Rules)) return { verdict: "gray", reason: `read system config path: ${rawPath}` };
|
|
484
545
|
return { verdict: "allow" };
|
|
485
546
|
}
|
|
486
|
-
if (hit(
|
|
547
|
+
if (hit(s1Rules)) return D(`write to system directory: ${rawPath}`);
|
|
487
548
|
if (hit(S3_GIT_META)) return D(`write to .git metadata (executable code entry point): ${rawPath}` );
|
|
488
549
|
if (hit(S2_USER_RC)) return { verdict: "gray", reason: `write to user config/persistence entry point: ${rawPath}` };
|
|
489
550
|
// In-cwd write allowance (#20): every canonical form must sit inside the cwd
|
|
@@ -556,10 +617,126 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
|
|
|
556
617
|
// only ever sees a fixed existence hint — zero path plaintext.
|
|
557
618
|
// ============================================================================
|
|
558
619
|
|
|
559
|
-
/** Path-like tokens in a shell command string: ~/…, $HOME/…, absolute /…, ./… / ../…, and word/word relative forms. URL path segments can match the absolute branch — harmless: resolution against denyPaths prefixes is what decides, false positives ask (safe direction)
|
|
560
|
-
|
|
620
|
+
/** Path-like tokens in a shell command string: ~/…, $HOME/…, absolute /…, ./… / ../…, and word/word relative forms. URL path segments can match the absolute branch — harmless: resolution against denyPaths prefixes is what decides, false positives ask (safe direction).
|
|
621
|
+
*
|
|
622
|
+
* Exported as the SEMANTIC ORACLE for #32's linear tokenizer (bashPathTokens) — the
|
|
623
|
+
* production path never runs this regex: its four alternatives backtrack
|
|
624
|
+
* quadratically on long failure searches (a 200k separator-free run takes ~28s,
|
|
625
|
+
* issue #32), and unlike the danger regexes (#25's 8192 cap) it cannot be capped —
|
|
626
|
+
* truncation would let a protected-path spelling beyond the cap silently escape
|
|
627
|
+
* the deterministic ask (ADR-0002's never-silently-passed contract). */
|
|
628
|
+
export const BASH_PATH_TOKENS =
|
|
561
629
|
/(?:~|\$HOME)(?:\/[\w.@*-]+)*|\/(?:[\w.@*-]+\/)*[\w.@*-]*|\.{1,2}(?:\/[\w.@*-]+)+|[\w.-]+(?:\/[\w.-]+)+/g;
|
|
562
630
|
|
|
631
|
+
/** ASCII class membership for the tokenizer (JS \w is ASCII-only; non-ASCII code
|
|
632
|
+
* points simply fall outside the classes, matching the regex). */
|
|
633
|
+
const TOKEN_W2 = new Uint8Array(128); // [\w.@*-]
|
|
634
|
+
const TOKEN_W4 = new Uint8Array(128); // [\w.-]
|
|
635
|
+
for (let c = 0; c < 128; c++) {
|
|
636
|
+
const ch = String.fromCharCode(c);
|
|
637
|
+
if (/[a-zA-Z0-9_]/.test(ch) || ".@*-".includes(ch)) TOKEN_W2[c] = 1;
|
|
638
|
+
if (/[a-zA-Z0-9_]/.test(ch) || ".-".includes(ch)) TOKEN_W4[c] = 1;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const isW2 = (s: string, i: number): boolean => i < s.length && s.charCodeAt(i) < 128 && TOKEN_W2[s.charCodeAt(i)] === 1;
|
|
642
|
+
const isW4 = (s: string, i: number): boolean => i < s.length && s.charCodeAt(i) < 128 && TOKEN_W4[s.charCodeAt(i)] === 1;
|
|
643
|
+
|
|
644
|
+
/** #32: linear tokenizer for BASH_PATH_TOKENS — one deterministic pass, provably
|
|
645
|
+
* O(n): each alternative parses greedily with at most a bounded (≤ 2) retry, and
|
|
646
|
+
* the scan position only advances. The regex oracle's matchAll semantics are
|
|
647
|
+
* reproduced exactly (alternation priority included; equivalence pinned by a
|
|
648
|
+
* fuzz test against the oracle). Derivation per alternative:
|
|
649
|
+
* - alt1 `(~|$HOME)(\/W2+)*`: the star never fails — prefix + maximal (/ + W2-run)
|
|
650
|
+
* repetitions; a bare ~ / $HOME is a legal zero-iteration match.
|
|
651
|
+
* - alt2 `\/(W2+\/)*W2*`: pairs stop at the first word-run not followed by a slash;
|
|
652
|
+
* the trailing star always succeeds, so the greedy parse is THE match (a lone
|
|
653
|
+
* "/" is a legal zero-pair, empty-tail match).
|
|
654
|
+
* - alt3 `\.{1,2}(\/W2+)+`: dots are tried greedily (2 then 1 — the regex's DFS
|
|
655
|
+
* order); the plus needs one '/'-then-W2 continuation, else the alternative fails.
|
|
656
|
+
* - alt4 `W4+(\/W4+)+`: the leading run is maximal [p, e); a continuation is viable
|
|
657
|
+
* ONLY at a '/' (a literal) immediately followed by a W4 char, and once viable
|
|
658
|
+
* the greedy inner always completes — so the DFS-first match takes the LARGEST
|
|
659
|
+
* viable '/' at or before e and extends greedily. This is exactly where the
|
|
660
|
+
* regex paid O(n) per start position on failure; the scan computes it in O(1)
|
|
661
|
+
* amortized. */
|
|
662
|
+
export function bashPathTokens(command: string): string[] {
|
|
663
|
+
const s = command;
|
|
664
|
+
const n = s.length;
|
|
665
|
+
// Right-to-left precompute of maximal-run ends — the single pass that makes every
|
|
666
|
+
// position O(1): runEndX[i] = first index >= i not in class X (i when s[i] itself
|
|
667
|
+
// is out of class; n at the end of string).
|
|
668
|
+
const runEnd2 = new Int32Array(n + 1);
|
|
669
|
+
const runEnd4 = new Int32Array(n + 1);
|
|
670
|
+
runEnd2[n] = n;
|
|
671
|
+
runEnd4[n] = n;
|
|
672
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
673
|
+
runEnd2[i] = isW2(s, i) ? runEnd2[i + 1] : i;
|
|
674
|
+
runEnd4[i] = isW4(s, i) ? runEnd4[i + 1] : i;
|
|
675
|
+
}
|
|
676
|
+
const out: string[] = [];
|
|
677
|
+
let p = 0;
|
|
678
|
+
while (p < n) {
|
|
679
|
+
const c = s[p];
|
|
680
|
+
let m = 0; // match end (exclusive); 0 = no match at p
|
|
681
|
+
if (c === "~" || s.startsWith("$HOME", p)) {
|
|
682
|
+
// alt1: deterministic greedy (/ + W2-run) repetitions
|
|
683
|
+
let q = c === "~" ? p + 1 : p + 5;
|
|
684
|
+
for (;;) {
|
|
685
|
+
if (s[q] === "/" && isW2(s, q + 1)) q = runEnd2[q + 1];
|
|
686
|
+
else break;
|
|
687
|
+
}
|
|
688
|
+
m = q;
|
|
689
|
+
} else if (c === "/") {
|
|
690
|
+
// alt2: (W2-run + /) pairs while possible, then the trailing W2-run
|
|
691
|
+
let q = p + 1;
|
|
692
|
+
for (;;) {
|
|
693
|
+
if (!isW2(s, q)) break; // empty tail — the match is the consumed prefix
|
|
694
|
+
const r = runEnd2[q];
|
|
695
|
+
if (s[r] !== "/") {
|
|
696
|
+
q = r; // tail run consumes through r
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
q = r + 1; // pair complete — another may follow
|
|
700
|
+
}
|
|
701
|
+
m = q;
|
|
702
|
+
} else if (c === ".") {
|
|
703
|
+
// alt3: dots greedy 2 then 1; inner = maximal (/ + W2-run) repetitions, >= 1 required
|
|
704
|
+
const innerEnd = (q: number): number | null => {
|
|
705
|
+
if (s[q] !== "/" || !isW2(s, q + 1)) return null;
|
|
706
|
+
let r = q;
|
|
707
|
+
for (;;) {
|
|
708
|
+
if (s[r] === "/" && isW2(s, r + 1)) r = runEnd2[r + 1];
|
|
709
|
+
else break;
|
|
710
|
+
}
|
|
711
|
+
return r;
|
|
712
|
+
};
|
|
713
|
+
if (s[p + 1] === ".") m = innerEnd(p + 2) ?? 0;
|
|
714
|
+
if (m === 0) m = innerEnd(p + 1) ?? 0;
|
|
715
|
+
}
|
|
716
|
+
if (m === 0 && isW4(s, p)) {
|
|
717
|
+
// alt4: the maximal leading run is [p, e). '/' is not in W4, so the run
|
|
718
|
+
// itself contains no slash and the ONLY viable continuation split is at e
|
|
719
|
+
// — the O(1) step that replaces the regex's O(n)-per-position backtrack.
|
|
720
|
+
const e = runEnd4[p];
|
|
721
|
+
if (s[e] === "/" && isW4(s, e + 1)) {
|
|
722
|
+
let q = e;
|
|
723
|
+
for (;;) {
|
|
724
|
+
if (s[q] === "/" && isW4(s, q + 1)) q = runEnd4[q + 1];
|
|
725
|
+
else break;
|
|
726
|
+
}
|
|
727
|
+
m = q;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (m > p) {
|
|
731
|
+
out.push(s.slice(p, m));
|
|
732
|
+
p = m; // matchAll semantics: continue after the match
|
|
733
|
+
} else {
|
|
734
|
+
p++;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
|
|
563
740
|
/** Normalized forms of one path for denyPaths comparison: base tier only (ADR-0002) —
|
|
564
741
|
* no ancestor rebuild; a nonexistent target under a symlinked dir falls to the
|
|
565
742
|
* classifier + existence hint instead (pinned by a regression test). */
|
|
@@ -578,7 +755,7 @@ const anchorDenyPaths = (paths: string[], cwd: string): string[] => paths.flatMa
|
|
|
578
755
|
* IS the cwd subtree (#48). */
|
|
579
756
|
function denyPathCandidates(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
|
|
580
757
|
const kind = toolKind(toolName);
|
|
581
|
-
if (kind === "command") return
|
|
758
|
+
if (kind === "command") return bashPathTokens(String(input.command ?? "")); // #32: linear — the regex stays as the test oracle
|
|
582
759
|
if (kind === "file") {
|
|
583
760
|
const p = typeof input.path === "string" && input.path ? input.path : null;
|
|
584
761
|
if (!p) return isScopeTool(toolName) ? [cwd] : [];
|
|
@@ -1040,12 +1217,19 @@ export interface NativeClassifierSpec {
|
|
|
1040
1217
|
provider?: string;
|
|
1041
1218
|
}
|
|
1042
1219
|
|
|
1043
|
-
/** The resolved classifier
|
|
1044
|
-
* (floor-capable by construction), chat = LLM prompt path
|
|
1045
|
-
|
|
1220
|
+
/** The resolved classifier spec (ADR-0005): native = classify() protocol path
|
|
1221
|
+
* (floor-capable by construction), chat = LLM prompt path — what spec resolution
|
|
1222
|
+
* returns, before the thinking level is attached. */
|
|
1223
|
+
export type ResolvedSpec =
|
|
1046
1224
|
| { kind: "native"; model: NativeClassifierSpec }
|
|
1047
1225
|
| { kind: "chat"; model: NonNullable<ExtensionContext["model"]> };
|
|
1048
1226
|
|
|
1227
|
+
/** A fully resolved classifier layer: the spec plus its thinking level. Native
|
|
1228
|
+
* layers do not consume the level (classifier models carry no reasoning — a
|
|
1229
|
+
* suffix warns once and the audit records null), but the parsed value stays on
|
|
1230
|
+
* the layer; chat layers pass it through to the completion call. */
|
|
1231
|
+
export type ResolvedLayer = ResolvedSpec & { thinking: ThinkingLevel };
|
|
1232
|
+
|
|
1049
1233
|
export interface ClassifierAnswerShape {
|
|
1050
1234
|
type: string;
|
|
1051
1235
|
choice?: string;
|
|
@@ -1117,12 +1301,15 @@ export function confidencePercent(conf: number): number {
|
|
|
1117
1301
|
return Math.floor(conf * 100 + 1e-9);
|
|
1118
1302
|
}
|
|
1119
1303
|
|
|
1120
|
-
/** Validates the verdict answer and synthesizes the
|
|
1121
|
-
* (
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1304
|
+
/** Validates the verdict answer and synthesizes the human-readable reason line
|
|
1305
|
+
* (probability breakdown, plain percentages, no internal notation). Any malformed
|
|
1306
|
+
* shape throws — the caller's fail-closed path owns the fallout. Confidence is
|
|
1307
|
+
* hard-required (#63 carried over): the decisions contract guarantees it on choice
|
|
1308
|
+
* answers, so absence is contract drift and drift fails closed. The historical
|
|
1309
|
+
* `<verdict>…</verdict>` prefix is NOT part of the reason anymore (see the ADR-0005
|
|
1310
|
+
* amendment): it existed to satisfy the LLM path's parseVerdict contract, which the
|
|
1311
|
+
* native path never needed — the full contract line lives on in the audit record's
|
|
1312
|
+
* rawResponse only. */
|
|
1126
1313
|
export function composeVerdictLine(answer: ClassifierAnswerShape, api: string): string {
|
|
1127
1314
|
const choice = String(answer.choice ?? "").trim().toLowerCase();
|
|
1128
1315
|
if (!VERDICTS.includes(choice as VerdictChoice)) {
|
|
@@ -1138,7 +1325,7 @@ export function composeVerdictLine(answer: ClassifierAnswerShape, api: string):
|
|
|
1138
1325
|
.map((v) => `${v} ${pct(probs[v])}`)
|
|
1139
1326
|
.join(", ");
|
|
1140
1327
|
const prefix = SYSTEM_ONE_APIS.has(api) ? "jev" : "classifier";
|
|
1141
|
-
return
|
|
1328
|
+
return `${prefix}: ${choice} ${pct(probs[choice])} (confidence ${confidencePercent(conf)}%; ${rest})`;
|
|
1142
1329
|
}
|
|
1143
1330
|
|
|
1144
1331
|
const MAX_USER_MESSAGES = 5;
|
|
@@ -1419,12 +1606,16 @@ export async function classifyNative(
|
|
|
1419
1606
|
if (answer.type !== "choice") return fail(`malformed verdict answer (type=${JSON.stringify(answer.type)})`);
|
|
1420
1607
|
try {
|
|
1421
1608
|
const line = composeVerdictLine(answer, model.api);
|
|
1609
|
+
const verdict = String(answer.choice).trim().toLowerCase() as ClassifierOutcome["verdict"];
|
|
1422
1610
|
return {
|
|
1423
|
-
verdict
|
|
1611
|
+
verdict,
|
|
1424
1612
|
reason: line,
|
|
1425
1613
|
source: "model",
|
|
1426
1614
|
confidence: confidencePercent(answer.confidence as number),
|
|
1427
|
-
|
|
1615
|
+
// The audit keeps the full contract line (tag included) in rawResponse — "what
|
|
1616
|
+
// the protocol said" — mirroring the LLM path's rawResponse (the model's full
|
|
1617
|
+
// output, tag included). The reason field is human-facing and tag-free.
|
|
1618
|
+
auditRaw: { transcript, rawResponse: `<verdict>${verdict}</verdict> ${line}`, modelId: model.id, thinking: null },
|
|
1428
1619
|
};
|
|
1429
1620
|
} catch (error) {
|
|
1430
1621
|
return fail(`${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1432,27 +1623,23 @@ export async function classifyNative(
|
|
|
1432
1623
|
}
|
|
1433
1624
|
|
|
1434
1625
|
async function classifyWithModel(
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
classify: ClassifyFn | undefined,
|
|
1439
|
-
model: ResolvedModel,
|
|
1440
|
-
actionLine: string,
|
|
1441
|
-
thinking: ThinkingLevel = "off",
|
|
1442
|
-
denyPathsActive = false,
|
|
1443
|
-
timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
|
|
1626
|
+
env: Pick<AdjudicateEnv, "host" | "signal" | "complete" | "classify">,
|
|
1627
|
+
resolved: ResolvedLayer,
|
|
1628
|
+
call: { actionLine: string; denyPathsActive: boolean; timeoutMs?: number },
|
|
1444
1629
|
): Promise<ClassifierOutcome> {
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
const
|
|
1630
|
+
const timeoutMs = call.timeoutMs ?? CLASSIFIER_TIMEOUT_MS;
|
|
1631
|
+
if (resolved.kind === "native") return classifyNative(env.classify, resolved.model, env.host, call.actionLine, env.signal, timeoutMs);
|
|
1632
|
+
const chat = resolved.model;
|
|
1633
|
+
const thinking = resolved.thinking;
|
|
1634
|
+
const transcript = buildTranscript(env.host, call.actionLine);
|
|
1448
1635
|
const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
|
|
1449
|
-
const systemPrompt = denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
|
|
1636
|
+
const systemPrompt = call.denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
|
|
1450
1637
|
const attempts: Array<[number, number]> = [[1, CLASSIFIER_MAX_TOKENS], [2, CLASSIFIER_RETRY_MAX_TOKENS]];
|
|
1451
1638
|
const failures: string[] = [];
|
|
1452
1639
|
let rawResponse = ""; // #54: raw output of the last attempt ("" for exception attempts — diagnostics already live in failures)
|
|
1453
1640
|
for (const [n, maxTokens] of attempts) {
|
|
1454
|
-
if (signal?.aborted) break; // 用户已取消,不再重试
|
|
1455
|
-
const r = await callClassifierOnce(host, signal, complete, chat, userMessage, maxTokens, thinking, systemPrompt, timeoutMs);
|
|
1641
|
+
if (env.signal?.aborted) break; // 用户已取消,不再重试
|
|
1642
|
+
const r = await callClassifierOnce(env.host, env.signal, env.complete, chat, userMessage, maxTokens, thinking, systemPrompt, timeoutMs);
|
|
1456
1643
|
if (r.ok) {
|
|
1457
1644
|
rawResponse = r.text;
|
|
1458
1645
|
const diag = `stopReason=${r.stopReason}, model=${chat.id}, errorMessage=${JSON.stringify(r.errorMessage ?? null)}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
|
|
@@ -1704,14 +1891,14 @@ export interface Verdict {
|
|
|
1704
1891
|
export interface AdjudicateEnv {
|
|
1705
1892
|
cwd: string;
|
|
1706
1893
|
hasUI: boolean;
|
|
1707
|
-
getModel: () =>
|
|
1894
|
+
getModel: () => ResolvedLayer | null;
|
|
1708
1895
|
complete: CompletionFn;
|
|
1709
1896
|
/** Native classify() seam (ADR-0005); absent on hosts without the capability —
|
|
1710
1897
|
* the native path fail-closes, never silently falls back to the chat path. */
|
|
1711
1898
|
classify?: ClassifyFn;
|
|
1712
1899
|
host: PipelineHost;
|
|
1713
1900
|
signal?: AbortSignal;
|
|
1714
|
-
getFallbackModel?: () =>
|
|
1901
|
+
getFallbackModel?: () => ResolvedLayer | null;
|
|
1715
1902
|
}
|
|
1716
1903
|
|
|
1717
1904
|
/** #67 (0.13, ADR-0005): the floor gates on protocol-native confidence — set only
|
|
@@ -1777,10 +1964,10 @@ async function runConfidenceCascade(
|
|
|
1777
1964
|
};
|
|
1778
1965
|
const resolved = getFb();
|
|
1779
1966
|
if (!resolved) return failed(rules.classifierFallbackModel, "fallback model unresolvable (not found or no configured auth)");
|
|
1780
|
-
const outcome = await classifyWithModel(env
|
|
1781
|
-
if (outcome.source !== "model") return failed(resolved.model.
|
|
1967
|
+
const outcome = await classifyWithModel(env, resolved, { actionLine, denyPathsActive, timeoutMs: FALLBACK_TIMEOUT_MS });
|
|
1968
|
+
if (outcome.source !== "model") return failed(resolved.model.id, outcome.reason);
|
|
1782
1969
|
state.fallback.note(first?.verdict ?? null, outcome.verdict);
|
|
1783
|
-
const fb: FallbackAudit = { ...base, model: resolved.model.
|
|
1970
|
+
const fb: FallbackAudit = { ...base, model: resolved.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs: Date.now() - start, error: null };
|
|
1784
1971
|
if (mode === "shadow") return { ...demotedMark, fb, ...shadowApplied };
|
|
1785
1972
|
// The carve-outs on second-layer authority (#71): it may not auto-relax a negative
|
|
1786
1973
|
// first-layer verdict — a demoted deny OR ask that the fallback would allow goes to
|
|
@@ -1868,7 +2055,7 @@ export async function adjudicate(
|
|
|
1868
2055
|
return { verdict: "deny", reason, source: "fail-closed", degraded: false };
|
|
1869
2056
|
}
|
|
1870
2057
|
|
|
1871
|
-
const outcome = await classifyWithModel(env
|
|
2058
|
+
const outcome = await classifyWithModel(env, resolved, { actionLine, denyPathsActive: state.userRules.denyPaths.length > 0 });
|
|
1872
2059
|
|
|
1873
2060
|
// #67 cascade: a confidence-floor demotion, or a classifier fail-closed outcome
|
|
1874
2061
|
// (the first layer produced no verdict)
|
|
@@ -2104,6 +2291,16 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2104
2291
|
return id.includes("jev");
|
|
2105
2292
|
}
|
|
2106
2293
|
|
|
2294
|
+
/** Shared "spec unavailable" wording: jev specs get the specialized causes,
|
|
2295
|
+
* anything else the generic miss; `tail` carries the layer's fallback
|
|
2296
|
+
* semantics with its leading separator. (Extracted from two previously
|
|
2297
|
+
* lockstep-synchronized ternaries — see CHANGELOG [Unreleased].) */
|
|
2298
|
+
function unavailableNotice(subject: string, raw: string, specPart: string, tail: string): string {
|
|
2299
|
+
return isJevSpec(specPart)
|
|
2300
|
+
? `pi-verdict: ${subject} "${raw}" unavailable — ${JEV_UNAVAILABLE_HINT}${tail}`
|
|
2301
|
+
: `pi-verdict: ${subject} "${raw}" unavailable (not found or no configured auth)${tail}`;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2107
2304
|
/** ADR-0005: shared spec→model resolution for both classifier layers. Native
|
|
2108
2305
|
* classifier entries are looked up first (findOfType) and WIN on same-id dual
|
|
2109
2306
|
* listings (llama.cpp chat+classifier share ids) — the native path is the point
|
|
@@ -2113,7 +2310,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2113
2310
|
* chat path the suffix stays effective and must not be called ignored). Returns
|
|
2114
2311
|
* null when the spec does not resolve; the layer's fallback semantics stay with
|
|
2115
2312
|
* the caller. */
|
|
2116
|
-
function findSpecModel(ctx: ExtensionContext, specPart: string, level: string | null, layer: "classifier" | "fallback"):
|
|
2313
|
+
function findSpecModel(ctx: ExtensionContext, specPart: string, level: string | null, layer: "classifier" | "fallback"): ResolvedSpec | null {
|
|
2117
2314
|
const slash = specPart.indexOf("/");
|
|
2118
2315
|
if (slash <= 0) return null;
|
|
2119
2316
|
const provider = specPart.slice(0, slash);
|
|
@@ -2141,7 +2338,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2141
2338
|
* model and self-reflection fallback alike; a purely rule-adjudicated session never
|
|
2142
2339
|
* sees it — lazy via getModel). Neutral wording: the fact, never a judgment on the
|
|
2143
2340
|
* config (users may pre-set the floor for a future classifier switch). */
|
|
2144
|
-
function warnFloorInert(ctx: ExtensionContext, model:
|
|
2341
|
+
function warnFloorInert(ctx: ExtensionContext, model: ResolvedSpec): void {
|
|
2145
2342
|
if (warnedFloorInert || state.userRules.classifierMinConfidence === null || model.kind === "native") return;
|
|
2146
2343
|
warnedFloorInert = true;
|
|
2147
2344
|
ctx.ui.notify("pi-verdict: classifierMinConfidence has no effect on a chat-model classifier (the floor applies to native classifier models like typesafe/jev-latest, whose confidence is protocol-native)", "warning");
|
|
@@ -2151,7 +2348,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2151
2348
|
* 自省(会话模型,恒为 chat 路径——0.99 分类器不进 /model)。不可用回退会话模型
|
|
2152
2349
|
* 并警告一次;null = 连会话模型都没有 → fail-closed。经 AdjudicateEnv.getModel
|
|
2153
2350
|
* 惰性调用(仅灰区),回退警告不会出现在规则已裁决的调用上。 */
|
|
2154
|
-
function resolveClassifier(ctx: ExtensionContext):
|
|
2351
|
+
function resolveClassifier(ctx: ExtensionContext): ResolvedLayer | null {
|
|
2155
2352
|
const raw =
|
|
2156
2353
|
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
|
|
2157
2354
|
let thinking: ThinkingLevel = "off";
|
|
@@ -2162,17 +2359,15 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2162
2359
|
ctx.ui.notify(msg, "warning");
|
|
2163
2360
|
});
|
|
2164
2361
|
thinking = (level ?? "off") as ThinkingLevel;
|
|
2165
|
-
const
|
|
2166
|
-
if (
|
|
2167
|
-
warnFloorInert(ctx,
|
|
2168
|
-
return {
|
|
2362
|
+
const spec = findSpecModel(ctx, specPart, level, "classifier");
|
|
2363
|
+
if (spec) {
|
|
2364
|
+
warnFloorInert(ctx, spec);
|
|
2365
|
+
return { ...spec, thinking };
|
|
2169
2366
|
}
|
|
2170
2367
|
if (!warnedClassifierModel) {
|
|
2171
2368
|
warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
|
|
2172
2369
|
ctx.ui.notify(
|
|
2173
|
-
|
|
2174
|
-
? `pi-verdict: classifier model "${raw}" unavailable — ${JEV_UNAVAILABLE_HINT}; falling back to session model (self-reflection)`
|
|
2175
|
-
: `pi-verdict: classifier model "${raw}" unavailable (not found or no configured auth), falling back to session model (self-reflection)`,
|
|
2370
|
+
unavailableNotice("classifier model", raw, specPart, "; falling back to session model (self-reflection)"),
|
|
2176
2371
|
"warning",
|
|
2177
2372
|
);
|
|
2178
2373
|
}
|
|
@@ -2180,9 +2375,9 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2180
2375
|
// 自省:继承当前会话模型(chat 路径——0.99 分类器不进 /model,会话模型恒为
|
|
2181
2376
|
// chat/virtual);显式指定的思考级别在回退时仍生效(原语义)
|
|
2182
2377
|
if (ctx.model) {
|
|
2183
|
-
const self:
|
|
2378
|
+
const self: ResolvedSpec = { kind: "chat", model: ctx.model };
|
|
2184
2379
|
warnFloorInert(ctx, self);
|
|
2185
|
-
return {
|
|
2380
|
+
return { ...self, thinking };
|
|
2186
2381
|
}
|
|
2187
2382
|
return null;
|
|
2188
2383
|
}
|
|
@@ -2194,7 +2389,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2194
2389
|
* judgment twice instead of adding a second opinion. Unresolvable → one-time warning
|
|
2195
2390
|
* + null (shadow: inert; enforce: triggered calls fail-closed, see runFallbackCascade).
|
|
2196
2391
|
* Resolved lazily via AdjudicateEnv.getFallbackModel, only after the gate fires. */
|
|
2197
|
-
function resolveFallbackClassifier(ctx: ExtensionContext):
|
|
2392
|
+
function resolveFallbackClassifier(ctx: ExtensionContext): ResolvedLayer | null {
|
|
2198
2393
|
const raw = state.userRules.classifierFallbackModel;
|
|
2199
2394
|
if (!raw) return null;
|
|
2200
2395
|
const { specPart, level } = parseModelSpec(raw, (msg) => {
|
|
@@ -2203,14 +2398,12 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2203
2398
|
ctx.ui.notify(msg, "warning");
|
|
2204
2399
|
});
|
|
2205
2400
|
const thinking = (level ?? "off") as ThinkingLevel;
|
|
2206
|
-
const
|
|
2207
|
-
if (
|
|
2401
|
+
const spec = findSpecModel(ctx, specPart, level, "fallback");
|
|
2402
|
+
if (spec) return { ...spec, thinking };
|
|
2208
2403
|
if (!warnedFallbackModel) {
|
|
2209
2404
|
warnedFallbackModel = true; // one warning per session
|
|
2210
2405
|
ctx.ui.notify(
|
|
2211
|
-
|
|
2212
|
-
? `pi-verdict: fallback model "${raw}" unavailable — ${JEV_UNAVAILABLE_HINT} — classifierFallbackModel inactive this session`
|
|
2213
|
-
: `pi-verdict: fallback model "${raw}" unavailable (not found or no configured auth) — classifierFallbackModel inactive this session`,
|
|
2406
|
+
unavailableNotice("fallback model", raw, specPart, " — classifierFallbackModel inactive this session"),
|
|
2214
2407
|
"warning",
|
|
2215
2408
|
);
|
|
2216
2409
|
}
|