pi-verdict 0.13.1 → 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 +9 -7
- package/README.zh-CN.md +9 -5
- package/extensions/pi-verdict.ts +45 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -157,6 +157,7 @@ pi 0.99 can run model-written JavaScript in a QuickJS sandbox (`codemode`) that
|
|
|
157
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
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
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
|
|
160
161
|
|
|
161
162
|
### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
162
163
|
|
|
@@ -184,7 +185,7 @@ Honest framing: pi-automode and pi-verdict have **converged on the same architec
|
|
|
184
185
|
|
|
185
186
|

|
|
186
187
|
|
|
187
|
-
*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.*
|
|
188
189
|
|
|
189
190
|
```
|
|
190
191
|
tool_call
|
|
@@ -202,12 +203,13 @@ tool_call
|
|
|
202
203
|
│ ├─ ignoreTools: your declared uncovered tools → allow, zero model calls
|
|
203
204
|
│ └─ no built-in allowlist — every "always allow" claim is yours to make
|
|
204
205
|
│
|
|
205
|
-
├─ 2. Gray zone →
|
|
206
|
-
│ ├─
|
|
207
|
-
│ │
|
|
208
|
-
│
|
|
209
|
-
│
|
|
210
|
-
│
|
|
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
|
|
211
213
|
│
|
|
212
214
|
└─ 3. Three-state adjudication
|
|
213
215
|
├─ allow → pass
|
package/README.zh-CN.md
CHANGED
|
@@ -157,6 +157,7 @@ pi 0.99 可以在 QuickJS 沙箱(`codemode`)里运行模型写的 JavaScript 去
|
|
|
157
157
|
- **`ignoreTools` 与 MCP 工具名**:工具名会归一化——`[A-Za-z0-9_]` 之外的字符统一变 `_`(`mcp__dev-radius__x` → `mcp__dev_radius__x`);豁免条目必须写归一化后的形态
|
|
158
158
|
- **成本放大**:单个脚本最多可发 256 次嵌套调用,灰区调用逐个分类,慢的 LLM 分类器会把单次延迟成倍放大
|
|
159
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` 归因
|
|
160
161
|
|
|
161
162
|
### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
162
163
|
|
|
@@ -184,7 +185,7 @@ pi 0.99 可以在 QuickJS 沙箱(`codemode`)里运行模型写的 JavaScript 去
|
|
|
184
185
|
|
|
185
186
|

|
|
186
187
|
|
|
187
|
-
*图源与再生成:[docs/diagrams/](docs/diagrams/README.md)。管线基准:v0.
|
|
188
|
+
*图源与再生成:[docs/diagrams/](docs/diagrams/README.md)。管线基准:v0.14——下方 ASCII 为文本等价版。*
|
|
188
189
|
|
|
189
190
|
```
|
|
190
191
|
tool_call
|
|
@@ -202,10 +203,13 @@ tool_call
|
|
|
202
203
|
│ ├─ ignoreTools:用户声明的未覆盖工具 → 直接放行,零模型调用
|
|
203
204
|
│ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
|
|
204
205
|
│
|
|
205
|
-
├─ 2. 灰区 →
|
|
206
|
-
│ ├─
|
|
207
|
-
│ │
|
|
208
|
-
│
|
|
206
|
+
├─ 2. 灰区 → 嵌套策略,再进模型分类器
|
|
207
|
+
│ ├─ 嵌套调用(codemode)+ codemodeNestedCalls=rules-only → 放行;
|
|
208
|
+
│ │ 上方确定性层已全部跑完(ADR-0006)
|
|
209
|
+
│ ├─ gate(默认,或直发调用)→ 分类器:原生 classify()
|
|
210
|
+
│ │ (choice + probabilities + confidence;reason 无标签,完整契约行
|
|
211
|
+
│ │ 存审计 rawResponse)或 chat 路径(会话模型自省)的
|
|
212
|
+
│ │ <verdict>…</verdict> 前缀锚定自由文本
|
|
209
213
|
│
|
|
210
214
|
└─ 3. 三态裁决
|
|
211
215
|
├─ allow → 放行
|
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,
|
|
@@ -1741,14 +1753,23 @@ export interface AuditRecord {
|
|
|
1741
1753
|
verdict: "allow" | "ask" | "deny";
|
|
1742
1754
|
reason: string;
|
|
1743
1755
|
/** #62: protected-path asks are recorded too — their user answers grade the
|
|
1744
|
-
* denyPaths rules; rule allow/deny verdicts remain unaudited.
|
|
1745
|
-
|
|
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";
|
|
1746
1759
|
degraded: boolean;
|
|
1747
1760
|
/** #62 ground truth: the user's answer to an interactive ask confirm. Present only
|
|
1748
1761
|
* on records whose confirm actually ran; headless/degraded asks omit it. */
|
|
1749
1762
|
userAnswer?: "allowed" | "declined";
|
|
1750
1763
|
/** #62: ISO timestamp of the confirm resolution; `ts` stays adjudication time. */
|
|
1751
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";
|
|
1752
1773
|
/** #62: protected-path records only — the matched path. */
|
|
1753
1774
|
detail?: string;
|
|
1754
1775
|
/** #67: the confidence floor fired — the first-layer verdict was demoted. */
|
|
@@ -1991,7 +2012,7 @@ async function runConfidenceCascade(
|
|
|
1991
2012
|
*/
|
|
1992
2013
|
export async function adjudicate(
|
|
1993
2014
|
state: SessionState,
|
|
1994
|
-
call: { toolName: string; input: Record<string, unknown
|
|
2015
|
+
call: { toolName: string; input: Record<string, unknown>; toolCallId?: string; parentToolCallId?: string },
|
|
1995
2016
|
env: AdjudicateEnv,
|
|
1996
2017
|
): Promise<Verdict> {
|
|
1997
2018
|
const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
|
|
@@ -2016,6 +2037,10 @@ export async function adjudicate(
|
|
|
2016
2037
|
thinking: raw?.thinking ?? null,
|
|
2017
2038
|
transcript: raw?.transcript ?? null,
|
|
2018
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 } : {}),
|
|
2019
2044
|
...v,
|
|
2020
2045
|
});
|
|
2021
2046
|
|
|
@@ -2030,6 +2055,17 @@ export async function adjudicate(
|
|
|
2030
2055
|
return { verdict: "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: true };
|
|
2031
2056
|
}
|
|
2032
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
|
+
|
|
2033
2069
|
// 灰区 → 分类器;无可用模型 → fail-closed
|
|
2034
2070
|
|
|
2035
2071
|
const resolved = env.getModel();
|
|
@@ -2219,6 +2255,8 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2219
2255
|
const toggleHint = () => (registeredToggleKey ? ` · toggle: ${registeredToggleKey}` : "");
|
|
2220
2256
|
/** Status line denyPaths count (ADR-0002): shown only when configured */
|
|
2221
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)" : "");
|
|
2222
2260
|
/** Status line audit hint (#54): shown only while the sink is active */
|
|
2223
2261
|
const auditHint = () => (state.audit ? `\naudit: on → ${state.audit.dir}` : "");
|
|
2224
2262
|
/** Status line cascade hint (#63/#67): shown while the floor or the fallback is configured */
|
|
@@ -2236,7 +2274,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2236
2274
|
const arg = args.trim().toLowerCase();
|
|
2237
2275
|
// 裸调用:只读状态展示,无副作用
|
|
2238
2276
|
if (arg === "") {
|
|
2239
|
-
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");
|
|
2240
2278
|
return;
|
|
2241
2279
|
}
|
|
2242
2280
|
// 幂等设定:与现值相同不翻转,仅确认
|
|
@@ -2451,7 +2489,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2451
2489
|
}
|
|
2452
2490
|
|
|
2453
2491
|
// 判定管线(零 UI)→ 呈现(source 模板)
|
|
2454
|
-
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 }, {
|
|
2455
2493
|
cwd: ctx.cwd,
|
|
2456
2494
|
hasUI: !!ctx.hasUI,
|
|
2457
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
|
}
|