pi-verdict 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,24 @@ 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:` shape, other classifier APIs render `classifier:`
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
+ - **Batch latency is configurable** ([ADR-0006](docs/adr/0006-codemode-nested-calls-policy.md)): every nested call adjudicates individually and gray-zone adjudication is serial (~350ms/call measured), so large script batches pay real latency. `"codemodeNestedCalls": "rules-only"` opts nested calls into the deterministic layers only (rules, floor, self-protection, denyPaths + ask) — the gray zone passes; the trade-off is that rule-passing actions the classifier would have caught (e.g. a nested `bash head ~/.ssh/config` without a denyPaths declaration) pass too. Audit records carry `toolCallId`/`parentToolCallId` either way
161
+
151
162
  ### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
152
163
 
153
164
  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.
@@ -174,7 +185,7 @@ Honest framing: pi-automode and pi-verdict have **converged on the same architec
174
185
 
175
186
  ![pi-verdict security gate — tool-call adjudication pipeline](https://cdn.jsdelivr.net/gh/jesset/pi-verdict@main/docs/diagrams/security-pipeline.en.svg)
176
187
 
177
- *Diagram source & regeneration: [docs/diagrams/](docs/diagrams/README.md). Pipeline as of v0.12 — the ASCII version below is the text-faithful equivalent.*
188
+ *Diagram source & regeneration: [docs/diagrams/](docs/diagrams/README.md). Pipeline as of v0.14 — the ASCII version below is the text-faithful equivalent.*
178
189
 
179
190
  ```
180
191
  tool_call
@@ -192,10 +203,13 @@ tool_call
192
203
  │ ├─ ignoreTools: your declared uncovered tools → allow, zero model calls
193
204
  │ └─ no built-in allowlist — every "always allow" claim is yours to make
194
205
  │
195
- ├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
196
- │ ├─ input: CC-style <transcript> — recent user intent + tool calls,
197
- │ │ action under review always last
198
- │ └─ output contract: <verdict>allow|ask|deny</verdict> prefix-anchored
206
+ ├─ 2. Gray zone → nested-call policy, then model classifier
207
+ │ ├─ nested call (codemode) + codemodeNestedCalls=rules-only → pass;
208
+ │ │ deterministic layers above already ran (ADR-0006)
209
+ │ ├─ gate (default, or a direct call) → classifier: native classify()
210
+ │ │ (choice + probabilities + confidence; tag-free reason, full contract
211
+ │ │ line in the audit rawResponse) or, on the chat path (session-model
212
+ │ │ self-reflection), <verdict>…</verdict> prefix-anchored free text
199
213
  │
200
214
  └─ 3. Three-state adjudication
201
215
  ├─ allow → pass
@@ -223,6 +237,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
223
237
  - 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
238
  - 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
239
  - 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)
240
+ - 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
241
  - AGENTS.md is not passed to the classifier as downweighted intent evidence (Claude Code does this)
227
242
  - parallel gray-zone calls are adjudicated serially
228
243
  - 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,24 @@ 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:` 形态,其余分类器 API 渲染 `classifier:`
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
+ - **批量延迟可配置**([ADR-0006](docs/adr/0006-codemode-nested-calls-policy.md)):每个嵌套调用独立裁决且灰区裁决为串行(实测 ~350ms/次),大脚本批次的判定开销可观。`"codemodeNestedCalls": "rules-only"` 让嵌套调用只走确定性层(规则、floor、自保护、denyPaths + ask)——灰区直接放行;代价是规则层放行、但分类器会拦的动作(如未声明 denyPaths 时的嵌套 `bash head ~/.ssh/config`)同样放行。两种模式下审计记录均携带 `toolCallId`/`parentToolCallId` 归因
161
+
151
162
  ### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
152
163
 
153
164
  门禁自身的文件——配置与扩展安装副本——**仅用户可改**:门禁之内的写入一律硬 deny(读放行);你的编辑器修改不经门禁,同类先例是 sudoers 必须经 visudo。
@@ -174,7 +185,7 @@ jev 的校准 confidence 正是置信地板的判定依据——搭配第二层
174
185
 
175
186
  ![pi-verdict 安全门禁——工具调用判定管线](https://cdn.jsdelivr.net/gh/jesset/pi-verdict@main/docs/diagrams/security-pipeline.zh.svg)
176
187
 
177
- *图源与再生成:[docs/diagrams/](docs/diagrams/README.md)。管线基准:v0.12——下方 ASCII 为文本等价版。*
188
+ *图源与再生成:[docs/diagrams/](docs/diagrams/README.md)。管线基准:v0.14——下方 ASCII 为文本等价版。*
178
189
 
179
190
  ```
180
191
  tool_call
@@ -192,10 +203,13 @@ tool_call
192
203
  │ ├─ ignoreTools:用户声明的未覆盖工具 → 直接放行,零模型调用
193
204
  │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
194
205
  │
195
- ├─ 2. 灰区 → 模型分类器(默认继承会话模型 —— "自省")
196
- │ ├─ 输入:CC 风格 <transcript> —— 近期用户意图 + 工具调用,
197
- │ │ 待审动作固定在末尾
198
- │ └─ 输出契约:<verdict>allow|ask|deny</verdict> 前缀锚定
206
+ ├─ 2. 灰区 → 嵌套策略,再进模型分类器
207
+ │ ├─ 嵌套调用(codemode)+ codemodeNestedCalls=rules-only → 放行;
208
+ │ │ 上方确定性层已全部跑完(ADR-0006)
209
+ │ ├─ gate(默认,或直发调用)→ 分类器:原生 classify()
210
+ │ │ (choice + probabilities + confidence;reason 无标签,完整契约行
211
+ │ │ 存审计 rawResponse)或 chat 路径(会话模型自省)的
212
+ │ │ <verdict>…</verdict> 前缀锚定自由文本
199
213
  │
200
214
  └─ 3. 三态裁决
201
215
  ├─ allow → 放行
@@ -223,6 +237,7 @@ tool_call
223
237
  - 设计上无内置白名单(见[绕过测试](research/rule-layer-security-audit.md)与[用户自定义规则](#用户自定义规则pi-verdictjson));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
224
238
  - 路径敏感度 floor 只作用于文件类工具:bash 命令串仅匹配危险正则——`cat ~/.ssh/id_rsa` 走分类器而非确定性 S0 拦截(文件工具拼写 `read ~/.ssh/id_rsa` 会拦截)
225
239
  - Windows 下内置 floor 仅覆盖 bash 形态模式——PowerShell 原生危险命令(`Remove-Item -Recurse -Force`、`Invoke-Expression`、`Set-ExecutionPolicy` 等)依赖分类器兜底(fail-closed)
240
+ - macOS 下 per-user 临时目录(`$TMPDIR`,`/var/folders/…/T` confstr 目录)豁免于系统目录 floor:读零成本放行,写按普通项目外写交分类器裁决。豁免锚定运行时解析的 confstr 族——手工设置 `TMPDIR` 不会解除任何保护;`/var/tmp`(POSIX 共享临时目录)维持拦截;真实形态逃逸临时树的符号链接拼写仍命中 S1
226
241
  - AGENTS.md 未作为降权意图证据传入分类器(Claude Code 有此设计)
227
242
  - 并行灰区调用串行裁决
228
243
  - 自省意味着会话模型自身裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
@@ -276,9 +276,17 @@ interface UserRules {
276
276
  /** #67: does the second layer adjudicate cascaded calls ("enforce", default since
277
277
  * 0.12.0) or only record its opinion while the human decides ("shadow")? */
278
278
  classifierFallbackMode: "shadow" | "enforce";
279
+ /** #90/ADR-0006: nested-call policy. "gate" (default) = nested calls adjudicate
280
+ * identically to direct calls; "rules-only" = nested calls keep every
281
+ * deterministic layer (self-protection, floor, user rules, denyPaths + its ask)
282
+ * and skip only the classifier + cascade — the gray zone passes, because serial
283
+ * adjudication of codemode batches amplifies per-call latency (live-fire:
284
+ * ~350ms/call). Opt-in: rule-passing actions the classifier would have caught
285
+ * pass under rules-only (coverage is denyPaths-declaration-dependent). */
286
+ codemodeNestedCalls: "gate" | "rules-only";
279
287
  }
280
288
 
281
- const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], ignoreTools: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false, classifierMinConfidence: null, classifierFallbackModel: null, classifierFallbackMode: "enforce" };
289
+ const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], ignoreTools: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false, classifierMinConfidence: null, classifierFallbackModel: null, classifierFallbackMode: "enforce", codemodeNestedCalls: "gate" };
282
290
 
283
291
  /** This module's own file location (import.meta.url resolved; null = unresolvable). */
284
292
  const OWN_FILE_PATH: string | null = (() => {
@@ -371,7 +379,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
371
379
  } catch { /* 只读环境静默跳过 */ }
372
380
  return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
373
381
  }
374
- let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; ignoreTools?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown; classifierFallbackModel?: unknown; classifierFallbackConfidence?: unknown; classifierMinConfidence?: unknown; classifierFallbackMode?: unknown };
382
+ let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; ignoreTools?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown; classifierFallbackModel?: unknown; classifierFallbackConfidence?: unknown; classifierMinConfidence?: unknown; classifierFallbackMode?: unknown; codemodeNestedCalls?: unknown };
375
383
  try {
376
384
  raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
377
385
  } catch (err) {
@@ -416,6 +424,9 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
416
424
  if (minConfRaw !== undefined && minConfRaw !== null && !minConfOk) skipped.push(`classifierMinConfidence: ${JSON.stringify(minConfRaw)}`);
417
425
  const fbModeRaw = raw.classifierFallbackMode;
418
426
  if (fbModeRaw !== undefined && fbModeRaw !== "shadow" && fbModeRaw !== "enforce") skipped.push(`classifierFallbackMode: ${JSON.stringify(fbModeRaw)}`);
427
+ // #90: nested-call policy — invalid values skip into the one-shot warning channel
428
+ const nestedRaw = raw.codemodeNestedCalls;
429
+ if (nestedRaw !== undefined && nestedRaw !== "gate" && nestedRaw !== "rules-only") skipped.push(`codemodeNestedCalls: ${JSON.stringify(nestedRaw)}`);
419
430
  return {
420
431
  rules: {
421
432
  allow: compile(raw.allow),
@@ -430,6 +441,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
430
441
  classifierFallbackModel: typeof raw.classifierFallbackModel === "string" && raw.classifierFallbackModel.trim() ? raw.classifierFallbackModel.trim() : null,
431
442
  classifierMinConfidence: minConfOk ? minConfRaw : null,
432
443
  classifierFallbackMode: fbModeRaw === undefined ? "enforce" : fbModeRaw === "enforce" ? "enforce" : "shadow", // invalid values land on the conservative shadow (standing invalid-config precedent); the key-less default is enforce
444
+ codemodeNestedCalls: nestedRaw === "rules-only" ? "rules-only" : "gate", // invalid values keep the safe default (gate)
433
445
  },
434
446
  skipped,
435
447
  shortcutWarning: shortcut.warning,
@@ -461,7 +473,62 @@ const S0_SECRET = [
461
473
  ];
462
474
  // /private prefixes: macOS firmlinks — /etc, /var are really /private/etc,
463
475
  // /private/var, and realpath'd toolchain output uses the real spelling (#21)
464
- const S1_SYSTEM = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$)/i, /^\/var(\/|$)/i, /^\/System(\/|$)/i, /(^|\/)authorized_keys$/i];
476
+ // Split into two families (#83): the directory-prefix family (what the per-user
477
+ // temp exemption may lift on macOS) and the basename family (authorized_keys —
478
+ // staging it in a temp dir must stay denied; defense in depth for the
479
+ // stage-then-copy chain).
480
+ const S1_SYSTEM_DIRS = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$)/i, /^\/var(\/|$)/i, /^\/System(\/|$)/i];
481
+ const S1_SYSTEM_FILES = [/(^|\/)authorized_keys$/i];
482
+ const S1_SYSTEM = [...S1_SYSTEM_DIRS, ...S1_SYSTEM_FILES];
483
+
484
+ /** #83: the per-user temp exemption bases — macOS confstr family only, at the
485
+ * confstr DEPTH (two segments below the family root: /var/folders/<xx>/<yy>/…),
486
+ * so the family root itself or a stray one-level child can never widen the
487
+ * exemption. The guard is deliberate: os.tmpdir() reads the process env, so
488
+ * trusting it verbatim would let a hand-set TMPDIR (e.g. TMPDIR=/etc, or the
489
+ * family root) lift the floor over S1 trees — narrowing the never-config-exemptible
490
+ * floor must go through code review, not an env accident. Another user's confstr
491
+ * subtree technically passes the depth check but is macOS-permission-guarded
492
+ * (same-user threat only). /var/tmp (POSIX shared temp) intentionally stays S1.
493
+ * Returns [] (inert) on every other platform — their tmpdirs never match S1. */
494
+ export function computeTmpdirBases(
495
+ platform: NodeJS.Platform,
496
+ tmp: string,
497
+ realpath: (p: string) => string | null,
498
+ ): string[] {
499
+ if (platform !== "darwin" || !tmp) return [];
500
+ const lexical = path.resolve(tmp);
501
+ const real = realpath(lexical);
502
+ const bases = real === null ? [lexical] : [lexical, real];
503
+ const confstrFamily = /^(?:\/private)?\/var\/folders\/[^/]+\/[^/]+(?:\/|$)/;
504
+ return bases.every((b) => confstrFamily.test(b)) ? bases : [];
505
+ }
506
+
507
+ /** The session-constant exemption bases (os.tmpdir() is stable for a process
508
+ * lifetime); test-overridable via the exported seam below. */
509
+ function defaultTmpdirBases(): string[] {
510
+ return computeTmpdirBases(process.platform, os.tmpdir(), (p) => {
511
+ try {
512
+ return fs.realpathSync(p);
513
+ } catch {
514
+ return null;
515
+ }
516
+ });
517
+ }
518
+
519
+ let tmpdirExemptBases = defaultTmpdirBases();
520
+
521
+ /** Test seam for tmpdirExemptBases — exported for tests only (the internal-seam
522
+ * surface, the standing #35 pattern); null restores the production bases. */
523
+ export function setTmpdirBasesForTests(bases: string[] | null): void {
524
+ tmpdirExemptBases = bases ?? defaultTmpdirBases();
525
+ }
526
+
527
+ /** #83: is EVERY canonical form of the target under the per-user temp tree?
528
+ * Intersection semantics (#20 discipline): a lexical temp spelling whose real
529
+ * form escapes (symlink to /etc) is NOT exempt — the S1 grading still applies. */
530
+ const tmpdirExempt = (forms: string[]): boolean =>
531
+ tmpdirExemptBases.length > 0 && forms.every((f) => tmpdirExemptBases.some((b) => f === b || f.startsWith(b + path.sep)));
465
532
  const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/i, /crontab/i, /Library\/LaunchAgents(\/|$)/i, /\.config\/systemd(\/|$)/i];
466
533
  const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/i, /(^|\/)\.gitmodules$/i];
467
534
 
@@ -479,11 +546,17 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
479
546
  : (reason: string): RuleResult => ({ verdict: "gray", reason });
480
547
 
481
548
  if (hit(S0_SECRET)) return D(`S0 secrets/credential path: ${rawPath}`);
549
+ // #83: under the per-user temp tree the DIRECTORY-prefix family of S1 lifts (a
550
+ // macOS confstr temp dir is per-user scratch, not a system directory); the
551
+ // basename family (authorized_keys) still applies, and the fall-through keeps the
552
+ // deny-only floor philosophy — reads regain the ordinary read allow, writes grade
553
+ // as ordinary outside-project writes (gray, classifier-adjudicated).
554
+ const s1Rules = tmpdirExempt(forms) ? S1_SYSTEM_FILES : S1_SYSTEM;
482
555
  if (!isWrite) {
483
- if (hit(S1_SYSTEM)) return { verdict: "gray", reason: `read system config path: ${rawPath}` };
556
+ if (hit(s1Rules)) return { verdict: "gray", reason: `read system config path: ${rawPath}` };
484
557
  return { verdict: "allow" };
485
558
  }
486
- if (hit(S1_SYSTEM)) return D(`write to system directory: ${rawPath}`);
559
+ if (hit(s1Rules)) return D(`write to system directory: ${rawPath}`);
487
560
  if (hit(S3_GIT_META)) return D(`write to .git metadata (executable code entry point): ${rawPath}` );
488
561
  if (hit(S2_USER_RC)) return { verdict: "gray", reason: `write to user config/persistence entry point: ${rawPath}` };
489
562
  // In-cwd write allowance (#20): every canonical form must sit inside the cwd
@@ -556,10 +629,126 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
556
629
  // only ever sees a fixed existence hint — zero path plaintext.
557
630
  // ============================================================================
558
631
 
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
- const BASH_PATH_TOKENS =
632
+ /** 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).
633
+ *
634
+ * Exported as the SEMANTIC ORACLE for #32's linear tokenizer (bashPathTokens) — the
635
+ * production path never runs this regex: its four alternatives backtrack
636
+ * quadratically on long failure searches (a 200k separator-free run takes ~28s,
637
+ * issue #32), and unlike the danger regexes (#25's 8192 cap) it cannot be capped —
638
+ * truncation would let a protected-path spelling beyond the cap silently escape
639
+ * the deterministic ask (ADR-0002's never-silently-passed contract). */
640
+ export const BASH_PATH_TOKENS =
561
641
  /(?:~|\$HOME)(?:\/[\w.@*-]+)*|\/(?:[\w.@*-]+\/)*[\w.@*-]*|\.{1,2}(?:\/[\w.@*-]+)+|[\w.-]+(?:\/[\w.-]+)+/g;
562
642
 
643
+ /** ASCII class membership for the tokenizer (JS \w is ASCII-only; non-ASCII code
644
+ * points simply fall outside the classes, matching the regex). */
645
+ const TOKEN_W2 = new Uint8Array(128); // [\w.@*-]
646
+ const TOKEN_W4 = new Uint8Array(128); // [\w.-]
647
+ for (let c = 0; c < 128; c++) {
648
+ const ch = String.fromCharCode(c);
649
+ if (/[a-zA-Z0-9_]/.test(ch) || ".@*-".includes(ch)) TOKEN_W2[c] = 1;
650
+ if (/[a-zA-Z0-9_]/.test(ch) || ".-".includes(ch)) TOKEN_W4[c] = 1;
651
+ }
652
+
653
+ const isW2 = (s: string, i: number): boolean => i < s.length && s.charCodeAt(i) < 128 && TOKEN_W2[s.charCodeAt(i)] === 1;
654
+ const isW4 = (s: string, i: number): boolean => i < s.length && s.charCodeAt(i) < 128 && TOKEN_W4[s.charCodeAt(i)] === 1;
655
+
656
+ /** #32: linear tokenizer for BASH_PATH_TOKENS — one deterministic pass, provably
657
+ * O(n): each alternative parses greedily with at most a bounded (≤ 2) retry, and
658
+ * the scan position only advances. The regex oracle's matchAll semantics are
659
+ * reproduced exactly (alternation priority included; equivalence pinned by a
660
+ * fuzz test against the oracle). Derivation per alternative:
661
+ * - alt1 `(~|$HOME)(\/W2+)*`: the star never fails — prefix + maximal (/ + W2-run)
662
+ * repetitions; a bare ~ / $HOME is a legal zero-iteration match.
663
+ * - alt2 `\/(W2+\/)*W2*`: pairs stop at the first word-run not followed by a slash;
664
+ * the trailing star always succeeds, so the greedy parse is THE match (a lone
665
+ * "/" is a legal zero-pair, empty-tail match).
666
+ * - alt3 `\.{1,2}(\/W2+)+`: dots are tried greedily (2 then 1 — the regex's DFS
667
+ * order); the plus needs one '/'-then-W2 continuation, else the alternative fails.
668
+ * - alt4 `W4+(\/W4+)+`: the leading run is maximal [p, e); a continuation is viable
669
+ * ONLY at a '/' (a literal) immediately followed by a W4 char, and once viable
670
+ * the greedy inner always completes — so the DFS-first match takes the LARGEST
671
+ * viable '/' at or before e and extends greedily. This is exactly where the
672
+ * regex paid O(n) per start position on failure; the scan computes it in O(1)
673
+ * amortized. */
674
+ export function bashPathTokens(command: string): string[] {
675
+ const s = command;
676
+ const n = s.length;
677
+ // Right-to-left precompute of maximal-run ends — the single pass that makes every
678
+ // position O(1): runEndX[i] = first index >= i not in class X (i when s[i] itself
679
+ // is out of class; n at the end of string).
680
+ const runEnd2 = new Int32Array(n + 1);
681
+ const runEnd4 = new Int32Array(n + 1);
682
+ runEnd2[n] = n;
683
+ runEnd4[n] = n;
684
+ for (let i = n - 1; i >= 0; i--) {
685
+ runEnd2[i] = isW2(s, i) ? runEnd2[i + 1] : i;
686
+ runEnd4[i] = isW4(s, i) ? runEnd4[i + 1] : i;
687
+ }
688
+ const out: string[] = [];
689
+ let p = 0;
690
+ while (p < n) {
691
+ const c = s[p];
692
+ let m = 0; // match end (exclusive); 0 = no match at p
693
+ if (c === "~" || s.startsWith("$HOME", p)) {
694
+ // alt1: deterministic greedy (/ + W2-run) repetitions
695
+ let q = c === "~" ? p + 1 : p + 5;
696
+ for (;;) {
697
+ if (s[q] === "/" && isW2(s, q + 1)) q = runEnd2[q + 1];
698
+ else break;
699
+ }
700
+ m = q;
701
+ } else if (c === "/") {
702
+ // alt2: (W2-run + /) pairs while possible, then the trailing W2-run
703
+ let q = p + 1;
704
+ for (;;) {
705
+ if (!isW2(s, q)) break; // empty tail — the match is the consumed prefix
706
+ const r = runEnd2[q];
707
+ if (s[r] !== "/") {
708
+ q = r; // tail run consumes through r
709
+ break;
710
+ }
711
+ q = r + 1; // pair complete — another may follow
712
+ }
713
+ m = q;
714
+ } else if (c === ".") {
715
+ // alt3: dots greedy 2 then 1; inner = maximal (/ + W2-run) repetitions, >= 1 required
716
+ const innerEnd = (q: number): number | null => {
717
+ if (s[q] !== "/" || !isW2(s, q + 1)) return null;
718
+ let r = q;
719
+ for (;;) {
720
+ if (s[r] === "/" && isW2(s, r + 1)) r = runEnd2[r + 1];
721
+ else break;
722
+ }
723
+ return r;
724
+ };
725
+ if (s[p + 1] === ".") m = innerEnd(p + 2) ?? 0;
726
+ if (m === 0) m = innerEnd(p + 1) ?? 0;
727
+ }
728
+ if (m === 0 && isW4(s, p)) {
729
+ // alt4: the maximal leading run is [p, e). '/' is not in W4, so the run
730
+ // itself contains no slash and the ONLY viable continuation split is at e
731
+ // — the O(1) step that replaces the regex's O(n)-per-position backtrack.
732
+ const e = runEnd4[p];
733
+ if (s[e] === "/" && isW4(s, e + 1)) {
734
+ let q = e;
735
+ for (;;) {
736
+ if (s[q] === "/" && isW4(s, q + 1)) q = runEnd4[q + 1];
737
+ else break;
738
+ }
739
+ m = q;
740
+ }
741
+ }
742
+ if (m > p) {
743
+ out.push(s.slice(p, m));
744
+ p = m; // matchAll semantics: continue after the match
745
+ } else {
746
+ p++;
747
+ }
748
+ }
749
+ return out;
750
+ }
751
+
563
752
  /** Normalized forms of one path for denyPaths comparison: base tier only (ADR-0002) —
564
753
  * no ancestor rebuild; a nonexistent target under a symlinked dir falls to the
565
754
  * classifier + existence hint instead (pinned by a regression test). */
@@ -578,7 +767,7 @@ const anchorDenyPaths = (paths: string[], cwd: string): string[] => paths.flatMa
578
767
  * IS the cwd subtree (#48). */
579
768
  function denyPathCandidates(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
580
769
  const kind = toolKind(toolName);
581
- if (kind === "command") return [...String(input.command ?? "").matchAll(BASH_PATH_TOKENS)].map((m) => m[0]);
770
+ if (kind === "command") return bashPathTokens(String(input.command ?? "")); // #32: linear — the regex stays as the test oracle
582
771
  if (kind === "file") {
583
772
  const p = typeof input.path === "string" && input.path ? input.path : null;
584
773
  if (!p) return isScopeTool(toolName) ? [cwd] : [];
@@ -1040,12 +1229,19 @@ export interface NativeClassifierSpec {
1040
1229
  provider?: string;
1041
1230
  }
1042
1231
 
1043
- /** The resolved classifier layer (ADR-0005): native = classify() protocol path
1044
- * (floor-capable by construction), chat = LLM prompt path (thinking applies). */
1045
- export type ResolvedModel =
1232
+ /** The resolved classifier spec (ADR-0005): native = classify() protocol path
1233
+ * (floor-capable by construction), chat = LLM prompt path — what spec resolution
1234
+ * returns, before the thinking level is attached. */
1235
+ export type ResolvedSpec =
1046
1236
  | { kind: "native"; model: NativeClassifierSpec }
1047
1237
  | { kind: "chat"; model: NonNullable<ExtensionContext["model"]> };
1048
1238
 
1239
+ /** A fully resolved classifier layer: the spec plus its thinking level. Native
1240
+ * layers do not consume the level (classifier models carry no reasoning — a
1241
+ * suffix warns once and the audit records null), but the parsed value stays on
1242
+ * the layer; chat layers pass it through to the completion call. */
1243
+ export type ResolvedLayer = ResolvedSpec & { thinking: ThinkingLevel };
1244
+
1049
1245
  export interface ClassifierAnswerShape {
1050
1246
  type: string;
1051
1247
  choice?: string;
@@ -1117,12 +1313,15 @@ export function confidencePercent(conf: number): number {
1117
1313
  return Math.floor(conf * 100 + 1e-9);
1118
1314
  }
1119
1315
 
1120
- /** Validates the verdict answer and synthesizes the contract line
1121
- * (`<verdict>…</verdict>` + one-line reason) from a native classify() answer. Any
1122
- * malformed shape throws — the caller's fail-closed path owns the fallout. Reason is
1123
- * user-facing (block reasons, ask dialogs): plain percentages, no internal notation.
1124
- * Confidence is hard-required (#63 carried over): the decisions contract guarantees it
1125
- * on choice answers, so absence is contract drift and drift fails closed. */
1316
+ /** Validates the verdict answer and synthesizes the human-readable reason line
1317
+ * (probability breakdown, plain percentages, no internal notation). Any malformed
1318
+ * shape throws — the caller's fail-closed path owns the fallout. Confidence is
1319
+ * hard-required (#63 carried over): the decisions contract guarantees it on choice
1320
+ * answers, so absence is contract drift and drift fails closed. The historical
1321
+ * `<verdict>…</verdict>` prefix is NOT part of the reason anymore (see the ADR-0005
1322
+ * amendment): it existed to satisfy the LLM path's parseVerdict contract, which the
1323
+ * native path never needed — the full contract line lives on in the audit record's
1324
+ * rawResponse only. */
1126
1325
  export function composeVerdictLine(answer: ClassifierAnswerShape, api: string): string {
1127
1326
  const choice = String(answer.choice ?? "").trim().toLowerCase();
1128
1327
  if (!VERDICTS.includes(choice as VerdictChoice)) {
@@ -1138,7 +1337,7 @@ export function composeVerdictLine(answer: ClassifierAnswerShape, api: string):
1138
1337
  .map((v) => `${v} ${pct(probs[v])}`)
1139
1338
  .join(", ");
1140
1339
  const prefix = SYSTEM_ONE_APIS.has(api) ? "jev" : "classifier";
1141
- return `<verdict>${choice}</verdict> ${prefix}: ${choice} ${pct(probs[choice])} (confidence ${confidencePercent(conf)}%; ${rest})`;
1340
+ return `${prefix}: ${choice} ${pct(probs[choice])} (confidence ${confidencePercent(conf)}%; ${rest})`;
1142
1341
  }
1143
1342
 
1144
1343
  const MAX_USER_MESSAGES = 5;
@@ -1419,12 +1618,16 @@ export async function classifyNative(
1419
1618
  if (answer.type !== "choice") return fail(`malformed verdict answer (type=${JSON.stringify(answer.type)})`);
1420
1619
  try {
1421
1620
  const line = composeVerdictLine(answer, model.api);
1621
+ const verdict = String(answer.choice).trim().toLowerCase() as ClassifierOutcome["verdict"];
1422
1622
  return {
1423
- verdict: String(answer.choice).trim().toLowerCase() as ClassifierOutcome["verdict"],
1623
+ verdict,
1424
1624
  reason: line,
1425
1625
  source: "model",
1426
1626
  confidence: confidencePercent(answer.confidence as number),
1427
- auditRaw: { transcript, rawResponse: line, modelId: model.id, thinking: null },
1627
+ // The audit keeps the full contract line (tag included) in rawResponse — "what
1628
+ // the protocol said" — mirroring the LLM path's rawResponse (the model's full
1629
+ // output, tag included). The reason field is human-facing and tag-free.
1630
+ auditRaw: { transcript, rawResponse: `<verdict>${verdict}</verdict> ${line}`, modelId: model.id, thinking: null },
1428
1631
  };
1429
1632
  } catch (error) {
1430
1633
  return fail(`${error instanceof Error ? error.message : String(error)}`);
@@ -1432,27 +1635,23 @@ export async function classifyNative(
1432
1635
  }
1433
1636
 
1434
1637
  async function classifyWithModel(
1435
- host: PipelineHost,
1436
- signal: AbortSignal | undefined,
1437
- complete: CompletionFn,
1438
- classify: ClassifyFn | undefined,
1439
- model: ResolvedModel,
1440
- actionLine: string,
1441
- thinking: ThinkingLevel = "off",
1442
- denyPathsActive = false,
1443
- timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
1638
+ env: Pick<AdjudicateEnv, "host" | "signal" | "complete" | "classify">,
1639
+ resolved: ResolvedLayer,
1640
+ call: { actionLine: string; denyPathsActive: boolean; timeoutMs?: number },
1444
1641
  ): Promise<ClassifierOutcome> {
1445
- if (model.kind === "native") return classifyNative(classify, model.model, host, actionLine, signal, timeoutMs);
1446
- const chat = model.model;
1447
- const transcript = buildTranscript(host, actionLine);
1642
+ const timeoutMs = call.timeoutMs ?? CLASSIFIER_TIMEOUT_MS;
1643
+ if (resolved.kind === "native") return classifyNative(env.classify, resolved.model, env.host, call.actionLine, env.signal, timeoutMs);
1644
+ const chat = resolved.model;
1645
+ const thinking = resolved.thinking;
1646
+ const transcript = buildTranscript(env.host, call.actionLine);
1448
1647
  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;
1648
+ const systemPrompt = call.denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
1450
1649
  const attempts: Array<[number, number]> = [[1, CLASSIFIER_MAX_TOKENS], [2, CLASSIFIER_RETRY_MAX_TOKENS]];
1451
1650
  const failures: string[] = [];
1452
1651
  let rawResponse = ""; // #54: raw output of the last attempt ("" for exception attempts — diagnostics already live in failures)
1453
1652
  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);
1653
+ if (env.signal?.aborted) break; // 用户已取消,不再重试
1654
+ const r = await callClassifierOnce(env.host, env.signal, env.complete, chat, userMessage, maxTokens, thinking, systemPrompt, timeoutMs);
1456
1655
  if (r.ok) {
1457
1656
  rawResponse = r.text;
1458
1657
  const diag = `stopReason=${r.stopReason}, model=${chat.id}, errorMessage=${JSON.stringify(r.errorMessage ?? null)}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
@@ -1554,14 +1753,23 @@ export interface AuditRecord {
1554
1753
  verdict: "allow" | "ask" | "deny";
1555
1754
  reason: string;
1556
1755
  /** #62: protected-path asks are recorded too — their user answers grade the
1557
- * denyPaths rules; rule allow/deny verdicts remain unaudited. */
1558
- source: "model" | "fail-closed" | "protected-path";
1756
+ * denyPaths rules; rule allow/deny verdicts remain unaudited. "rule" exists for
1757
+ * one audited rule-layer outcome: the rules-only nested passthrough (#90). */
1758
+ source: "model" | "fail-closed" | "protected-path" | "rule";
1559
1759
  degraded: boolean;
1560
1760
  /** #62 ground truth: the user's answer to an interactive ask confirm. Present only
1561
1761
  * on records whose confirm actually ran; headless/degraded asks omit it. */
1562
1762
  userAnswer?: "allowed" | "declined";
1563
1763
  /** #62: ISO timestamp of the confirm resolution; `ts` stays adjudication time. */
1564
1764
  answeredAt?: string;
1765
+ /** #90: the call's id — `<parent id>/<n>` for nested calls (codemode scripts).
1766
+ * Direct-call records carry it too; pre-0.14 corpora simply lack the field. */
1767
+ toolCallId?: string;
1768
+ /** #90: set iff another tool (a codemode script) issued this call — the audit
1769
+ * attribution that makes nested calls distinguishable from direct ones. */
1770
+ parentToolCallId?: string;
1771
+ /** #90: the nested-call policy in effect for a rules-only passthrough record. */
1772
+ policy?: "rules-only";
1565
1773
  /** #62: protected-path records only — the matched path. */
1566
1774
  detail?: string;
1567
1775
  /** #67: the confidence floor fired — the first-layer verdict was demoted. */
@@ -1704,14 +1912,14 @@ export interface Verdict {
1704
1912
  export interface AdjudicateEnv {
1705
1913
  cwd: string;
1706
1914
  hasUI: boolean;
1707
- getModel: () => { model: ResolvedModel; thinking: ThinkingLevel } | null;
1915
+ getModel: () => ResolvedLayer | null;
1708
1916
  complete: CompletionFn;
1709
1917
  /** Native classify() seam (ADR-0005); absent on hosts without the capability —
1710
1918
  * the native path fail-closes, never silently falls back to the chat path. */
1711
1919
  classify?: ClassifyFn;
1712
1920
  host: PipelineHost;
1713
1921
  signal?: AbortSignal;
1714
- getFallbackModel?: () => { model: ResolvedModel; thinking: ThinkingLevel } | null;
1922
+ getFallbackModel?: () => ResolvedLayer | null;
1715
1923
  }
1716
1924
 
1717
1925
  /** #67 (0.13, ADR-0005): the floor gates on protocol-native confidence — set only
@@ -1777,10 +1985,10 @@ async function runConfidenceCascade(
1777
1985
  };
1778
1986
  const resolved = getFb();
1779
1987
  if (!resolved) return failed(rules.classifierFallbackModel, "fallback model unresolvable (not found or no configured auth)");
1780
- const outcome = await classifyWithModel(env.host, env.signal, env.complete, env.classify, resolved.model, actionLine, resolved.thinking, denyPathsActive, FALLBACK_TIMEOUT_MS);
1781
- if (outcome.source !== "model") return failed(resolved.model.model.id, outcome.reason);
1988
+ const outcome = await classifyWithModel(env, resolved, { actionLine, denyPathsActive, timeoutMs: FALLBACK_TIMEOUT_MS });
1989
+ if (outcome.source !== "model") return failed(resolved.model.id, outcome.reason);
1782
1990
  state.fallback.note(first?.verdict ?? null, outcome.verdict);
1783
- const fb: FallbackAudit = { ...base, model: resolved.model.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs: Date.now() - start, error: null };
1991
+ const fb: FallbackAudit = { ...base, model: resolved.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs: Date.now() - start, error: null };
1784
1992
  if (mode === "shadow") return { ...demotedMark, fb, ...shadowApplied };
1785
1993
  // The carve-outs on second-layer authority (#71): it may not auto-relax a negative
1786
1994
  // first-layer verdict — a demoted deny OR ask that the fallback would allow goes to
@@ -1804,7 +2012,7 @@ async function runConfidenceCascade(
1804
2012
  */
1805
2013
  export async function adjudicate(
1806
2014
  state: SessionState,
1807
- call: { toolName: string; input: Record<string, unknown> },
2015
+ call: { toolName: string; input: Record<string, unknown>; toolCallId?: string; parentToolCallId?: string },
1808
2016
  env: AdjudicateEnv,
1809
2017
  ): Promise<Verdict> {
1810
2018
  const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
@@ -1829,6 +2037,10 @@ export async function adjudicate(
1829
2037
  thinking: raw?.thinking ?? null,
1830
2038
  transcript: raw?.transcript ?? null,
1831
2039
  rawResponse: raw?.rawResponse ?? null,
2040
+ // #90 audit attribution: ids ride on every record; nested records additionally
2041
+ // carry the parent linkage, making them distinguishable from direct calls
2042
+ ...(call.toolCallId !== undefined ? { toolCallId: call.toolCallId } : {}),
2043
+ ...(call.parentToolCallId !== undefined ? { parentToolCallId: call.parentToolCallId } : {}),
1832
2044
  ...v,
1833
2045
  });
1834
2046
 
@@ -1843,6 +2055,17 @@ export async function adjudicate(
1843
2055
  return { verdict: "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: true };
1844
2056
  }
1845
2057
 
2058
+ // ADR-0006 (#90): the layered-exemption policy. Nested calls under rules-only have
2059
+ // already passed every deterministic layer above (self-protection, floor, user
2060
+ // rules, denyPaths + its ask) — only the intelligence layer is skipped: the gray
2061
+ // zone passes. Audited when audit is on (source rule + policy marker): the user
2062
+ // needs corpus data on what the opt-in actually let through.
2063
+ if (call.parentToolCallId !== undefined && state.userRules.codemodeNestedCalls === "rules-only") {
2064
+ const reason = "codemodeNestedCalls rules-only: gray-zone passthrough (nested call — deterministic layers only)";
2065
+ state.audit?.append({ ...buildRecord({ verdict: "allow", reason, source: "rule", degraded: false }, null), policy: "rules-only" });
2066
+ return { verdict: "allow", reason, source: "rule", degraded: false };
2067
+ }
2068
+
1846
2069
  // 灰区 → 分类器;无可用模型 → fail-closed
1847
2070
 
1848
2071
  const resolved = env.getModel();
@@ -1868,7 +2091,7 @@ export async function adjudicate(
1868
2091
  return { verdict: "deny", reason, source: "fail-closed", degraded: false };
1869
2092
  }
1870
2093
 
1871
- const outcome = await classifyWithModel(env.host, env.signal, env.complete, env.classify, resolved.model, actionLine, resolved.thinking, state.userRules.denyPaths.length > 0);
2094
+ const outcome = await classifyWithModel(env, resolved, { actionLine, denyPathsActive: state.userRules.denyPaths.length > 0 });
1872
2095
 
1873
2096
  // #67 cascade: a confidence-floor demotion, or a classifier fail-closed outcome
1874
2097
  // (the first layer produced no verdict)
@@ -2032,6 +2255,8 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2032
2255
  const toggleHint = () => (registeredToggleKey ? ` · toggle: ${registeredToggleKey}` : "");
2033
2256
  /** Status line denyPaths count (ADR-0002): shown only when configured */
2034
2257
  const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
2258
+ /** #90: nested-call policy hint — shown only when the exemption is active */
2259
+ const nestedPolicyHint = () => (state.userRules.codemodeNestedCalls === "rules-only" ? "\nnested calls: rules-only (deterministic layers only — the gray zone passes)" : "");
2035
2260
  /** Status line audit hint (#54): shown only while the sink is active */
2036
2261
  const auditHint = () => (state.audit ? `\naudit: on → ${state.audit.dir}` : "");
2037
2262
  /** Status line cascade hint (#63/#67): shown while the floor or the fallback is configured */
@@ -2049,7 +2274,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2049
2274
  const arg = args.trim().toLowerCase();
2050
2275
  // 裸调用:只读状态展示,无副作用
2051
2276
  if (arg === "") {
2052
- ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${denyPathsHint()}${auditHint()}${fallbackHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
2277
+ ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${denyPathsHint()}${nestedPolicyHint()}${auditHint()}${fallbackHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
2053
2278
  return;
2054
2279
  }
2055
2280
  // 幂等设定:与现值相同不翻转,仅确认
@@ -2104,6 +2329,16 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2104
2329
  return id.includes("jev");
2105
2330
  }
2106
2331
 
2332
+ /** Shared "spec unavailable" wording: jev specs get the specialized causes,
2333
+ * anything else the generic miss; `tail` carries the layer's fallback
2334
+ * semantics with its leading separator. (Extracted from two previously
2335
+ * lockstep-synchronized ternaries — see CHANGELOG [Unreleased].) */
2336
+ function unavailableNotice(subject: string, raw: string, specPart: string, tail: string): string {
2337
+ return isJevSpec(specPart)
2338
+ ? `pi-verdict: ${subject} "${raw}" unavailable — ${JEV_UNAVAILABLE_HINT}${tail}`
2339
+ : `pi-verdict: ${subject} "${raw}" unavailable (not found or no configured auth)${tail}`;
2340
+ }
2341
+
2107
2342
  /** ADR-0005: shared spec→model resolution for both classifier layers. Native
2108
2343
  * classifier entries are looked up first (findOfType) and WIN on same-id dual
2109
2344
  * listings (llama.cpp chat+classifier share ids) — the native path is the point
@@ -2113,7 +2348,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2113
2348
  * chat path the suffix stays effective and must not be called ignored). Returns
2114
2349
  * null when the spec does not resolve; the layer's fallback semantics stay with
2115
2350
  * the caller. */
2116
- function findSpecModel(ctx: ExtensionContext, specPart: string, level: string | null, layer: "classifier" | "fallback"): ResolvedModel | null {
2351
+ function findSpecModel(ctx: ExtensionContext, specPart: string, level: string | null, layer: "classifier" | "fallback"): ResolvedSpec | null {
2117
2352
  const slash = specPart.indexOf("/");
2118
2353
  if (slash <= 0) return null;
2119
2354
  const provider = specPart.slice(0, slash);
@@ -2141,7 +2376,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2141
2376
  * model and self-reflection fallback alike; a purely rule-adjudicated session never
2142
2377
  * sees it — lazy via getModel). Neutral wording: the fact, never a judgment on the
2143
2378
  * config (users may pre-set the floor for a future classifier switch). */
2144
- function warnFloorInert(ctx: ExtensionContext, model: ResolvedModel): void {
2379
+ function warnFloorInert(ctx: ExtensionContext, model: ResolvedSpec): void {
2145
2380
  if (warnedFloorInert || state.userRules.classifierMinConfidence === null || model.kind === "native") return;
2146
2381
  warnedFloorInert = true;
2147
2382
  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 +2386,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2151
2386
  * 自省(会话模型,恒为 chat 路径——0.99 分类器不进 /model)。不可用回退会话模型
2152
2387
  * 并警告一次;null = 连会话模型都没有 → fail-closed。经 AdjudicateEnv.getModel
2153
2388
  * 惰性调用(仅灰区),回退警告不会出现在规则已裁决的调用上。 */
2154
- function resolveClassifier(ctx: ExtensionContext): { model: ResolvedModel; thinking: ThinkingLevel } | null {
2389
+ function resolveClassifier(ctx: ExtensionContext): ResolvedLayer | null {
2155
2390
  const raw =
2156
2391
  (pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
2157
2392
  let thinking: ThinkingLevel = "off";
@@ -2162,17 +2397,15 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2162
2397
  ctx.ui.notify(msg, "warning");
2163
2398
  });
2164
2399
  thinking = (level ?? "off") as ThinkingLevel;
2165
- const model = findSpecModel(ctx, specPart, level, "classifier");
2166
- if (model) {
2167
- warnFloorInert(ctx, model);
2168
- return { model, thinking };
2400
+ const spec = findSpecModel(ctx, specPart, level, "classifier");
2401
+ if (spec) {
2402
+ warnFloorInert(ctx, spec);
2403
+ return { ...spec, thinking };
2169
2404
  }
2170
2405
  if (!warnedClassifierModel) {
2171
2406
  warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
2172
2407
  ctx.ui.notify(
2173
- isJevSpec(specPart)
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)`,
2408
+ unavailableNotice("classifier model", raw, specPart, "; falling back to session model (self-reflection)"),
2176
2409
  "warning",
2177
2410
  );
2178
2411
  }
@@ -2180,9 +2413,9 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2180
2413
  // 自省:继承当前会话模型(chat 路径——0.99 分类器不进 /model,会话模型恒为
2181
2414
  // chat/virtual);显式指定的思考级别在回退时仍生效(原语义)
2182
2415
  if (ctx.model) {
2183
- const self: ResolvedModel = { kind: "chat", model: ctx.model };
2416
+ const self: ResolvedSpec = { kind: "chat", model: ctx.model };
2184
2417
  warnFloorInert(ctx, self);
2185
- return { model: self, thinking };
2418
+ return { ...self, thinking };
2186
2419
  }
2187
2420
  return null;
2188
2421
  }
@@ -2194,7 +2427,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2194
2427
  * judgment twice instead of adding a second opinion. Unresolvable → one-time warning
2195
2428
  * + null (shadow: inert; enforce: triggered calls fail-closed, see runFallbackCascade).
2196
2429
  * Resolved lazily via AdjudicateEnv.getFallbackModel, only after the gate fires. */
2197
- function resolveFallbackClassifier(ctx: ExtensionContext): { model: ResolvedModel; thinking: ThinkingLevel } | null {
2430
+ function resolveFallbackClassifier(ctx: ExtensionContext): ResolvedLayer | null {
2198
2431
  const raw = state.userRules.classifierFallbackModel;
2199
2432
  if (!raw) return null;
2200
2433
  const { specPart, level } = parseModelSpec(raw, (msg) => {
@@ -2203,14 +2436,12 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2203
2436
  ctx.ui.notify(msg, "warning");
2204
2437
  });
2205
2438
  const thinking = (level ?? "off") as ThinkingLevel;
2206
- const model = findSpecModel(ctx, specPart, level, "fallback");
2207
- if (model) return { model, thinking };
2439
+ const spec = findSpecModel(ctx, specPart, level, "fallback");
2440
+ if (spec) return { ...spec, thinking };
2208
2441
  if (!warnedFallbackModel) {
2209
2442
  warnedFallbackModel = true; // one warning per session
2210
2443
  ctx.ui.notify(
2211
- isJevSpec(specPart)
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`,
2444
+ unavailableNotice("fallback model", raw, specPart, " — classifierFallbackModel inactive this session"),
2214
2445
  "warning",
2215
2446
  );
2216
2447
  }
@@ -2258,7 +2489,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2258
2489
  }
2259
2490
 
2260
2491
  // 判定管线(零 UI)→ 呈现(source 模板)
2261
- const verdict = await adjudicate(state, { toolName: event.toolName, input }, {
2492
+ const verdict = await adjudicate(state, { toolName: event.toolName, input, toolCallId: event.toolCallId, parentToolCallId: event.parentToolCallId }, {
2262
2493
  cwd: ctx.cwd,
2263
2494
  hasUI: !!ctx.hasUI,
2264
2495
  getModel: () => resolveClassifier(ctx),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "A minimal permission gate for Pi, inspired by Claude Code's auto mode",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",
@@ -55,7 +55,7 @@
55
55
  }
56
56
  },
57
57
  "devDependencies": {
58
- "@earendil-works/pi-coding-agent": "0.99.2",
58
+ "@earendil-works/pi-coding-agent": "1.0.0",
59
59
  "@types/node": "^26.3.0",
60
60
  "typescript": "^7.0.2"
61
61
  }