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 +22 -7
- package/README.zh-CN.md +22 -7
- package/extensions/pi-verdict.ts +295 -64
- package/package.json +2 -2
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:`
|
|
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
|

|
|
176
187
|
|
|
177
|
-
*Diagram source & regeneration: [docs/diagrams/](docs/diagrams/README.md). Pipeline as of v0.
|
|
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 →
|
|
196
|
-
│ ├─
|
|
197
|
-
│ │
|
|
198
|
-
│
|
|
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:`
|
|
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
|

|
|
176
187
|
|
|
177
|
-
*图源与再生成:[docs/diagrams/](docs/diagrams/README.md)。管线基准:v0.
|
|
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
|
-
│ ├─
|
|
197
|
-
│ │
|
|
198
|
-
│
|
|
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)
|
package/extensions/pi-verdict.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
556
|
+
if (hit(s1Rules)) return { verdict: "gray", reason: `read system config path: ${rawPath}` };
|
|
484
557
|
return { verdict: "allow" };
|
|
485
558
|
}
|
|
486
|
-
if (hit(
|
|
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
|
-
|
|
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
|
|
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
|
|
1044
|
-
* (floor-capable by construction), chat = LLM prompt path
|
|
1045
|
-
|
|
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
|
|
1121
|
-
* (
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
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
|
|
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
|
|
1623
|
+
verdict,
|
|
1424
1624
|
reason: line,
|
|
1425
1625
|
source: "model",
|
|
1426
1626
|
confidence: confidencePercent(answer.confidence as number),
|
|
1427
|
-
|
|
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
|
-
|
|
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,
|
|
1638
|
+
env: Pick<AdjudicateEnv, "host" | "signal" | "complete" | "classify">,
|
|
1639
|
+
resolved: ResolvedLayer,
|
|
1640
|
+
call: { actionLine: string; denyPathsActive: boolean; timeoutMs?: number },
|
|
1444
1641
|
): Promise<ClassifierOutcome> {
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
const
|
|
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
|
-
|
|
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: () =>
|
|
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?: () =>
|
|
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
|
|
1781
|
-
if (outcome.source !== "model") return failed(resolved.model.
|
|
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.
|
|
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
|
|
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"):
|
|
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:
|
|
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):
|
|
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
|
|
2166
|
-
if (
|
|
2167
|
-
warnFloorInert(ctx,
|
|
2168
|
-
return {
|
|
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
|
-
|
|
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:
|
|
2416
|
+
const self: ResolvedSpec = { kind: "chat", model: ctx.model };
|
|
2184
2417
|
warnFloorInert(ctx, self);
|
|
2185
|
-
return {
|
|
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):
|
|
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
|
|
2207
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
58
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
59
59
|
"@types/node": "^26.3.0",
|
|
60
60
|
"typescript": "^7.0.2"
|
|
61
61
|
}
|