pi-verdict 0.13.1 → 0.15.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 +19 -10
- package/README.zh-CN.md +19 -8
- package/extensions/pi-verdict.ts +179 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -110,6 +110,7 @@ pi-verdict 0.13+ requires **pi ≥ 0.99** and runs on pi only (native classifier
|
|
|
110
110
|
"classifierModel": null,
|
|
111
111
|
"toggleShortcut": "ctrl+shift+a",
|
|
112
112
|
"audit": false,
|
|
113
|
+
"auditRedactSecrets": true,
|
|
113
114
|
"notifyAllows": false,
|
|
114
115
|
"classifierMinConfidence": null,
|
|
115
116
|
"classifierFallbackModel": null,
|
|
@@ -123,7 +124,7 @@ pi-verdict 0.13+ requires **pi ≥ 0.99** and runs on pi only (native classifier
|
|
|
123
124
|
- `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
|
|
124
125
|
- `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
|
|
125
126
|
- `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)
|
|
126
|
-
- `audit: true` records every **gray-zone adjudication** (the full transcript sent to the classifier, its raw response, the parsed verdict) as JSONL under `~/.pi/agent/verdicts/<sessionId>.jsonl` — one file per session, the 20 most recent kept. Interactive asks also record your answer (`userAnswer` ground truth, written after the confirm resolves), and protected-path asks are recorded too (#62); rule allow/deny stays unaudited. Local-only and full-fidelity (protected-path plaintext may appear — it never leaves your machine; [ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) boundary note); the agent can neither read nor write the directory. `/automode` shows the audit state and path while on
|
|
127
|
+
- `audit: true` records every **gray-zone adjudication** (the full transcript sent to the classifier, its raw response, the parsed verdict) as JSONL under `~/.pi/agent/verdicts/<sessionId>.jsonl` — one file per session, the 20 most recent kept. Interactive asks also record your answer (`userAnswer` ground truth, written after the confirm resolves), and protected-path asks are recorded too (#62); rule allow/deny stays unaudited. Local-only and full-fidelity (protected-path plaintext may appear — it never leaves your machine; [ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) boundary note); secret-shaped text is redacted on append (`<redacted:type#fingerprint>`, deterministic so cross-record tracing survives — `auditRedactSecrets: false` opts out; [ADR-0007](docs/adr/0007-audit-secret-redaction.md)); the agent can neither read nor write the directory. `/automode` shows the audit state and path while on
|
|
127
128
|
- `notifyAllows: true` notifies on every **classifier allow** (reason + action line — e.g. jev's probability breakdown); default `false` keeps passes silent. Mechanical passes (your own allow rules, protected-path confirms) never notify; with both switches on the notification appears once
|
|
128
129
|
- `classifierMinConfidence` (optional, [ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)) sets the **confidence floor**: a native-classifier verdict below it is demoted — cascaded to `classifierFallbackModel` if set (`enforce`, the default = the second layer adjudicates; a demoted **deny or ask** can never be auto-relaxed to an allow; a fail-closed layer emitted no verdict, so its rescue stands; `shadow` = records its opinion only and you are asked — `/automode` hints the activation switch), otherwise asked of you directly. At/above the floor the first layer is autonomous. The floor applies to native classifier models only (protocol-native confidence) — with a chat/LLM classifier it is inert, and a one-time warning says so. A natural pairing: jev first + a haiku/flash-class fallback
|
|
129
130
|
|
|
@@ -157,6 +158,7 @@ pi 0.99 can run model-written JavaScript in a QuickJS sandbox (`codemode`) that
|
|
|
157
158
|
- **`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
159
|
- **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
160
|
- **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
|
|
161
|
+
- **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
162
|
|
|
161
163
|
### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
162
164
|
|
|
@@ -184,7 +186,7 @@ Honest framing: pi-automode and pi-verdict have **converged on the same architec
|
|
|
184
186
|
|
|
185
187
|

|
|
186
188
|
|
|
187
|
-
*Diagram source & regeneration: [docs/diagrams/](docs/diagrams/README.md). Pipeline as of v0.
|
|
189
|
+
*Diagram source & regeneration: [docs/diagrams/](docs/diagrams/README.md). Pipeline as of v0.14 — the ASCII version below is the text-faithful equivalent.*
|
|
188
190
|
|
|
189
191
|
```
|
|
190
192
|
tool_call
|
|
@@ -200,14 +202,21 @@ tool_call
|
|
|
200
202
|
│ ├─ denyPaths (ADR-0002): protected paths → terminal ask,
|
|
201
203
|
│ │ before user allow; classifier sees an existence hint only
|
|
202
204
|
│ ├─ ignoreTools: your declared uncovered tools → allow, zero model calls
|
|
203
|
-
│
|
|
205
|
+
│ ├─ headless subagent-artifacts writes (ADR-0008): a no-UI session's
|
|
206
|
+
│ │ write/edit under <agentDir>/sessions/**/subagent-artifacts/ →
|
|
207
|
+
│ │ deterministic allow (interactive sessions keep the ask); your
|
|
208
|
+
│ │ deny rules and denyPaths still outrank it
|
|
209
|
+
│ └─ no built-in allowlist on your surfaces — every "always allow"
|
|
210
|
+
│ claim is yours to make (the one built-in allow is pi's own
|
|
211
|
+
│ subagent-artifacts subtree, headless-only, ADR-0008)
|
|
204
212
|
│
|
|
205
|
-
├─ 2. Gray zone →
|
|
206
|
-
│ ├─
|
|
207
|
-
│ │
|
|
208
|
-
│
|
|
209
|
-
│
|
|
210
|
-
│
|
|
213
|
+
├─ 2. Gray zone → nested-call policy, then model classifier
|
|
214
|
+
│ ├─ nested call (codemode) + codemodeNestedCalls=rules-only → pass;
|
|
215
|
+
│ │ deterministic layers above already ran (ADR-0006)
|
|
216
|
+
│ ├─ gate (default, or a direct call) → classifier: native classify()
|
|
217
|
+
│ │ (choice + probabilities + confidence; tag-free reason, full contract
|
|
218
|
+
│ │ line in the audit rawResponse) or, on the chat path (session-model
|
|
219
|
+
│ │ self-reflection), <verdict>…</verdict> prefix-anchored free text
|
|
211
220
|
│
|
|
212
221
|
└─ 3. Three-state adjudication
|
|
213
222
|
├─ allow → pass
|
|
@@ -232,7 +241,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
|
|
|
232
241
|
|
|
233
242
|
## Status & limitations
|
|
234
243
|
|
|
235
|
-
- no built-in allowlist by design (see the [bypass writeup](research/rule-layer-security-audit.md)); with an empty `allow` config most commands go to the classifier — point `--auto-mode-model` at a fast model if per-call latency matters
|
|
244
|
+
- no built-in allowlist on user surfaces by design (see the [bypass writeup](research/rule-layer-security-audit.md); the one built-in allow is pi's own subagent-artifacts subtree, headless-only, [ADR-0008](docs/adr/0008-agentdir-sessions-write-exemption.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
|
|
236
245
|
- 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)
|
|
237
246
|
- on Windows the built-in floor covers bash-shaped patterns only — PowerShell-native dangerous commands (`Remove-Item -Recurse -Force`, `Invoke-Expression`, `Set-ExecutionPolicy`, …) rely on the classifier (fail-closed)
|
|
238
247
|
- 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
|
package/README.zh-CN.md
CHANGED
|
@@ -110,6 +110,7 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
|
|
|
110
110
|
"classifierModel": null,
|
|
111
111
|
"toggleShortcut": "ctrl+shift+a",
|
|
112
112
|
"audit": false,
|
|
113
|
+
"auditRedactSecrets": true,
|
|
113
114
|
"notifyAllows": false,
|
|
114
115
|
"classifierMinConfidence": null,
|
|
115
116
|
"classifierFallbackModel": null,
|
|
@@ -123,7 +124,7 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
|
|
|
123
124
|
- `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
|
|
124
125
|
- `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
|
|
125
126
|
- `classifierModel: "typesafe/jev-latest"` 启用**原生 jev 分类器**——每次灰区裁决经 pi 内置分类器目录发一次结构化 `classify()` 调用(TypeSafe 直连,或 OpenRouter/OpenCode/Cloudflare/Vercel 上的 Jev);详见 [ADR-0005](docs/adr/0005-native-classifier-migration.md)
|
|
126
|
-
- `audit: true` 把每次**灰区裁决**(发给分类器的完整转录、其原始响应、解析出的裁决)以 JSONL 记录到 `~/.pi/agent/verdicts/<sessionId>.jsonl`——按会话一分文件,保留最近 20 个。交互式 ask 还会记录你的应答(`userAnswer` ground truth,确认结束后落盘),protected-path ask 也入审计(#62);规则 allow/deny 仍不入。仅存本机且全保真(受保护路径明文可能出现——永不出本机;[ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) 边界注);agent 对该目录读写双拒。开启时 `/automode` 会显示审计状态与路径
|
|
127
|
+
- `audit: true` 把每次**灰区裁决**(发给分类器的完整转录、其原始响应、解析出的裁决)以 JSONL 记录到 `~/.pi/agent/verdicts/<sessionId>.jsonl`——按会话一分文件,保留最近 20 个。交互式 ask 还会记录你的应答(`userAnswer` ground truth,确认结束后落盘),protected-path ask 也入审计(#62);规则 allow/deny 仍不入。仅存本机且全保真(受保护路径明文可能出现——永不出本机;[ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) 边界注);凭证明文在落盘前脱敏(`<redacted:类型#指纹>`,指纹具确定性、跨记录可追踪——`auditRedactSecrets: false` 可显式关闭;[ADR-0007](docs/adr/0007-audit-secret-redaction.md));agent 对该目录读写双拒。开启时 `/automode` 会显示审计状态与路径
|
|
127
128
|
- `notifyAllows: true` 对每次 **classifier 放行**发通知(reason + action 行——如 jev 的概率分解);默认 `false` 保持放行静默。机械放行(你自己的 allow 规则、protected-path 确认)永不通知;两开关同开时通知只出现一次
|
|
128
129
|
- `classifierMinConfidence`(可选,[ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))设定**置信地板**:低于它的原生分类器裁决被降级——配置了 `classifierFallbackModel` 则级联(`enforce`,默认 = 第二层全权裁决;例外:降级的 **deny 与 ask** 永不被自动放宽为 allow;fail-closed 未产生裁决,其获救裁决照常生效;`shadow` = 只记录意见、由你裁决——`/automode` 会提示激活开关),否则直接问你。不低于地板时第一层自主。地板仅作用于原生分类器模型(协议原生置信度)——chat/LLM 分类器下不生效,会有一次中性警告提示。天然搭配:jev 在前 + haiku/flash 级回退
|
|
129
130
|
|
|
@@ -157,6 +158,7 @@ pi 0.99 可以在 QuickJS 沙箱(`codemode`)里运行模型写的 JavaScript 去
|
|
|
157
158
|
- **`ignoreTools` 与 MCP 工具名**:工具名会归一化——`[A-Za-z0-9_]` 之外的字符统一变 `_`(`mcp__dev-radius__x` → `mcp__dev_radius__x`);豁免条目必须写归一化后的形态
|
|
158
159
|
- **成本放大**:单个脚本最多可发 256 次嵌套调用,灰区调用逐个分类,慢的 LLM 分类器会把单次延迟成倍放大
|
|
159
160
|
- **暴露边界**:添加 MCP 服务器会自动开启 codemode,而 `pi --no-extensions -e builtin:mcp` 可在不加载任何扩展(即无本门禁)的情况下启用 MCP 工具。门禁自身即扩展,在完全不加载扩展的会话中无法生效;此边界为 pi 扩展模型的固有属性,此处显式陈述而非掩饰
|
|
161
|
+
- **批量延迟可配置**([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
162
|
|
|
161
163
|
### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
162
164
|
|
|
@@ -184,7 +186,7 @@ pi 0.99 可以在 QuickJS 沙箱(`codemode`)里运行模型写的 JavaScript 去
|
|
|
184
186
|
|
|
185
187
|

|
|
186
188
|
|
|
187
|
-
*图源与再生成:[docs/diagrams/](docs/diagrams/README.md)。管线基准:v0.
|
|
189
|
+
*图源与再生成:[docs/diagrams/](docs/diagrams/README.md)。管线基准:v0.14——下方 ASCII 为文本等价版。*
|
|
188
190
|
|
|
189
191
|
```
|
|
190
192
|
tool_call
|
|
@@ -200,12 +202,21 @@ tool_call
|
|
|
200
202
|
│ ├─ denyPaths(ADR-0002):受保护路径 → 终局 ask,先于用户 allow;
|
|
201
203
|
│ │ 分类器只见存在性话术
|
|
202
204
|
│ ├─ ignoreTools:用户声明的未覆盖工具 → 直接放行,零模型调用
|
|
203
|
-
│
|
|
205
|
+
│ ├─ headless subagent-artifacts 写入(ADR-0008):无 UI 会话对
|
|
206
|
+
│ │ <agentDir>/sessions/**/subagent-artifacts/ 的 write/edit →
|
|
207
|
+
│ │ 确定性放行(交互会话仍可 ask);用户 deny 规则与 denyPaths
|
|
208
|
+
│ │ 声明仍然优先
|
|
209
|
+
│ └─ 你的操作面上无内置白名单 —— 「永远放行」的声明由你自己做
|
|
210
|
+
│ (唯一的内置放行是 pi 自身的 subagent-artifacts 子树,仅限
|
|
211
|
+
│ headless,ADR-0008)
|
|
204
212
|
│
|
|
205
|
-
├─ 2. 灰区 →
|
|
206
|
-
│ ├─
|
|
207
|
-
│ │
|
|
208
|
-
│
|
|
213
|
+
├─ 2. 灰区 → 嵌套策略,再进模型分类器
|
|
214
|
+
│ ├─ 嵌套调用(codemode)+ codemodeNestedCalls=rules-only → 放行;
|
|
215
|
+
│ │ 上方确定性层已全部跑完(ADR-0006)
|
|
216
|
+
│ ├─ gate(默认,或直发调用)→ 分类器:原生 classify()
|
|
217
|
+
│ │ (choice + probabilities + confidence;reason 无标签,完整契约行
|
|
218
|
+
│ │ 存审计 rawResponse)或 chat 路径(会话模型自省)的
|
|
219
|
+
│ │ <verdict>…</verdict> 前缀锚定自由文本
|
|
209
220
|
│
|
|
210
221
|
└─ 3. 三态裁决
|
|
211
222
|
├─ allow → 放行
|
|
@@ -230,7 +241,7 @@ tool_call
|
|
|
230
241
|
|
|
231
242
|
## 状态与限制
|
|
232
243
|
|
|
233
|
-
-
|
|
244
|
+
- 设计上在你的操作面无内置白名单(见[绕过测试](research/rule-layer-security-audit.md)与[用户自定义规则](#用户自定义规则pi-verdictjson);唯一内置放行是 pi 自身的 subagent-artifacts 子树、仅限 headless,[ADR-0008](docs/adr/0008-agentdir-sessions-write-exemption.md));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
|
|
234
245
|
- 路径敏感度 floor 只作用于文件类工具:bash 命令串仅匹配危险正则——`cat ~/.ssh/id_rsa` 走分类器而非确定性 S0 拦截(文件工具拼写 `read ~/.ssh/id_rsa` 会拦截)
|
|
235
246
|
- Windows 下内置 floor 仅覆盖 bash 形态模式——PowerShell 原生危险命令(`Remove-Item -Recurse -Force`、`Invoke-Expression`、`Set-ExecutionPolicy` 等)依赖分类器兜底(fail-closed)
|
|
236
247
|
- macOS 下 per-user 临时目录(`$TMPDIR`,`/var/folders/…/T` confstr 目录)豁免于系统目录 floor:读零成本放行,写按普通项目外写交分类器裁决。豁免锚定运行时解析的 confstr 族——手工设置 `TMPDIR` 不会解除任何保护;`/var/tmp`(POSIX 共享临时目录)维持拦截;真实形态逃逸临时树的符号链接拼写仍命中 S1
|
package/extensions/pi-verdict.ts
CHANGED
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
* research/pi-model-call-and-ref-implementations.md
|
|
82
82
|
*/
|
|
83
83
|
|
|
84
|
+
import * as crypto from "node:crypto";
|
|
84
85
|
import * as fs from "node:fs";
|
|
85
86
|
import * as os from "node:os";
|
|
86
87
|
import * as path from "node:path";
|
|
@@ -276,9 +277,20 @@ interface UserRules {
|
|
|
276
277
|
/** #67: does the second layer adjudicate cascaded calls ("enforce", default since
|
|
277
278
|
* 0.12.0) or only record its opinion while the human decides ("shadow")? */
|
|
278
279
|
classifierFallbackMode: "shadow" | "enforce";
|
|
280
|
+
/** #90/ADR-0006: nested-call policy. "gate" (default) = nested calls adjudicate
|
|
281
|
+
* identically to direct calls; "rules-only" = nested calls keep every
|
|
282
|
+
* deterministic layer (self-protection, floor, user rules, denyPaths + its ask)
|
|
283
|
+
* and skip only the classifier + cascade — the gray zone passes, because serial
|
|
284
|
+
* adjudication of codemode batches amplifies per-call latency (live-fire:
|
|
285
|
+
* ~350ms/call). Opt-in: rule-passing actions the classifier would have caught
|
|
286
|
+
* pass under rules-only (coverage is denyPaths-declaration-dependent). */
|
|
287
|
+
codemodeNestedCalls: "gate" | "rules-only";
|
|
288
|
+
/** ADR-0007: redact secret-shaped text from audit records before append.
|
|
289
|
+
* Default true; false is the explicit keep-raw escape hatch. */
|
|
290
|
+
auditRedactSecrets: boolean;
|
|
279
291
|
}
|
|
280
292
|
|
|
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" };
|
|
293
|
+
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", auditRedactSecrets: true };
|
|
282
294
|
|
|
283
295
|
/** This module's own file location (import.meta.url resolved; null = unresolvable). */
|
|
284
296
|
const OWN_FILE_PATH: string | null = (() => {
|
|
@@ -347,6 +359,7 @@ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
|
347
359
|
classifierModel: null,
|
|
348
360
|
toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
|
|
349
361
|
audit: false,
|
|
362
|
+
auditRedactSecrets: true,
|
|
350
363
|
notifyAllows: false,
|
|
351
364
|
classifierMinConfidence: null,
|
|
352
365
|
classifierFallbackModel: null,
|
|
@@ -371,7 +384,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
371
384
|
} catch { /* 只读环境静默跳过 */ }
|
|
372
385
|
return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
|
|
373
386
|
}
|
|
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 };
|
|
387
|
+
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; auditRedactSecrets?: unknown };
|
|
375
388
|
try {
|
|
376
389
|
raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
|
|
377
390
|
} catch (err) {
|
|
@@ -416,6 +429,11 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
416
429
|
if (minConfRaw !== undefined && minConfRaw !== null && !minConfOk) skipped.push(`classifierMinConfidence: ${JSON.stringify(minConfRaw)}`);
|
|
417
430
|
const fbModeRaw = raw.classifierFallbackMode;
|
|
418
431
|
if (fbModeRaw !== undefined && fbModeRaw !== "shadow" && fbModeRaw !== "enforce") skipped.push(`classifierFallbackMode: ${JSON.stringify(fbModeRaw)}`);
|
|
432
|
+
// #90: nested-call policy — invalid values skip into the one-shot warning channel
|
|
433
|
+
const nestedRaw = raw.codemodeNestedCalls;
|
|
434
|
+
if (nestedRaw !== undefined && nestedRaw !== "gate" && nestedRaw !== "rules-only") skipped.push(`codemodeNestedCalls: ${JSON.stringify(nestedRaw)}`);
|
|
435
|
+
// ADR-0007: audit redaction — non-boolean values skip into the one-shot warning channel, redaction stays on
|
|
436
|
+
if (raw.auditRedactSecrets !== undefined && typeof raw.auditRedactSecrets !== "boolean") skipped.push(`auditRedactSecrets: ${JSON.stringify(raw.auditRedactSecrets)}`);
|
|
419
437
|
return {
|
|
420
438
|
rules: {
|
|
421
439
|
allow: compile(raw.allow),
|
|
@@ -430,6 +448,8 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
430
448
|
classifierFallbackModel: typeof raw.classifierFallbackModel === "string" && raw.classifierFallbackModel.trim() ? raw.classifierFallbackModel.trim() : null,
|
|
431
449
|
classifierMinConfidence: minConfOk ? minConfRaw : null,
|
|
432
450
|
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
|
|
451
|
+
codemodeNestedCalls: nestedRaw === "rules-only" ? "rules-only" : "gate", // invalid values keep the safe default (gate)
|
|
452
|
+
auditRedactSecrets: raw.auditRedactSecrets !== false, // default on; only an explicit false disables (ADR-0007)
|
|
433
453
|
},
|
|
434
454
|
skipped,
|
|
435
455
|
shortcutWarning: shortcut.warning,
|
|
@@ -808,6 +828,8 @@ interface ProtectedSet {
|
|
|
808
828
|
prefixes: string[];
|
|
809
829
|
/** 读拒绝前缀(#54):verdicts 审计目录——记录含不可信原始输出,禁回流 agent context */
|
|
810
830
|
readPrefixes: string[];
|
|
831
|
+
/** Subagent-artifacts write-exemption tree (ADR-0008): <agentDir>/sessions — headless write/edit to its subagent-artifacts subtree passes deterministically */
|
|
832
|
+
runtimeWritePrefixes: string[];
|
|
811
833
|
/** bash/powershell 命令串危险特征(子串匹配,可绕——变更检测兜底) */
|
|
812
834
|
bashPatterns: RegExp[];
|
|
813
835
|
/** 变更检测基线(词法路径 + 类别;session_start 时快照全文) */
|
|
@@ -965,7 +987,15 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
|
|
|
965
987
|
}
|
|
966
988
|
bashPatterns.push(new RegExp(`(?:${[...vAlts].join("|")})`));
|
|
967
989
|
|
|
968
|
-
|
|
990
|
+
// ADR-0008: <agentDir>/sessions is pi's own runtime output tree (subagent
|
|
991
|
+
// artifacts live there); write/edit grade as deterministic allow. The prefix
|
|
992
|
+
// uses the ancestor-rebuild tier (rebuiltForms), matching the target-side
|
|
993
|
+
// forms: sessions/ may not exist yet on a fresh install, and an allow-grade
|
|
994
|
+
// exemption must not hinge on directory existence (baseForms alone would
|
|
995
|
+
// miss the firmlink real form and silently void the exemption).
|
|
996
|
+
const runtimeWritePrefixes = rebuiltForms(path.join(agentDir, "sessions"));
|
|
997
|
+
|
|
998
|
+
return { exact: [...exact], prefixes: [...prefixes], readPrefixes: verdictsForms, runtimeWritePrefixes, bashPatterns, watchBases };
|
|
969
999
|
}
|
|
970
1000
|
|
|
971
1001
|
/** Does the resolved write path hit the protected set (realpath guards against
|
|
@@ -996,6 +1026,20 @@ export function isProtectedReadPath(rawPath: string | undefined, cwd: string, pr
|
|
|
996
1026
|
return false;
|
|
997
1027
|
}
|
|
998
1028
|
|
|
1029
|
+
/** ADR-0008: subagent-artifacts write exemption — EVERY canonical form must sit
|
|
1030
|
+
* under <agentDir>/sessions AND carry a subagent-artifacts directory segment
|
|
1031
|
+
* (intersection semantics, same discipline as the in-cwd allowance): a lexical
|
|
1032
|
+
* hit whose real form escapes (symlink alias) earns no exemption and falls back
|
|
1033
|
+
* to the classifier. Gated to headless sessions at the call site — an
|
|
1034
|
+
* interactive session keeps its ask capability. */
|
|
1035
|
+
const SUBAGENT_ARTIFACTS_SEG = /(^|\/)subagent-artifacts(\/|$)/;
|
|
1036
|
+
export function isSubagentArtifactWrite(rawPath: string, cwd: string, prot: ProtectedSet): boolean {
|
|
1037
|
+
if (prot.runtimeWritePrefixes.length === 0 || !rawPath) return false;
|
|
1038
|
+
const forms = rebuiltForms(path.resolve(cwd, expandHome(rawPath)));
|
|
1039
|
+
const inTree = (f: string) => prot.runtimeWritePrefixes.some((p) => f === p || f.startsWith(p + path.sep));
|
|
1040
|
+
return forms.every((f) => inTree(f) && SUBAGENT_ARTIFACTS_SEG.test(f));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
999
1043
|
/** 自保护层裁决(第 0 层,先于一切):触碰门禁自身文件 → 不可豁免的 deny;其余 null 交后续层 */
|
|
1000
1044
|
function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
|
|
1001
1045
|
switch (toolName) {
|
|
@@ -1741,14 +1785,23 @@ export interface AuditRecord {
|
|
|
1741
1785
|
verdict: "allow" | "ask" | "deny";
|
|
1742
1786
|
reason: string;
|
|
1743
1787
|
/** #62: protected-path asks are recorded too — their user answers grade the
|
|
1744
|
-
* denyPaths rules; rule allow/deny verdicts remain unaudited.
|
|
1745
|
-
|
|
1788
|
+
* denyPaths rules; rule allow/deny verdicts remain unaudited. "rule" exists for
|
|
1789
|
+
* one audited rule-layer outcome: the rules-only nested passthrough (#90). */
|
|
1790
|
+
source: "model" | "fail-closed" | "protected-path" | "rule";
|
|
1746
1791
|
degraded: boolean;
|
|
1747
1792
|
/** #62 ground truth: the user's answer to an interactive ask confirm. Present only
|
|
1748
1793
|
* on records whose confirm actually ran; headless/degraded asks omit it. */
|
|
1749
1794
|
userAnswer?: "allowed" | "declined";
|
|
1750
1795
|
/** #62: ISO timestamp of the confirm resolution; `ts` stays adjudication time. */
|
|
1751
1796
|
answeredAt?: string;
|
|
1797
|
+
/** #90: the call's id — `<parent id>/<n>` for nested calls (codemode scripts).
|
|
1798
|
+
* Direct-call records carry it too; pre-0.14 corpora simply lack the field. */
|
|
1799
|
+
toolCallId?: string;
|
|
1800
|
+
/** #90: set iff another tool (a codemode script) issued this call — the audit
|
|
1801
|
+
* attribution that makes nested calls distinguishable from direct ones. */
|
|
1802
|
+
parentToolCallId?: string;
|
|
1803
|
+
/** #90: the nested-call policy in effect for a rules-only passthrough record. */
|
|
1804
|
+
policy?: "rules-only";
|
|
1752
1805
|
/** #62: protected-path records only — the matched path. */
|
|
1753
1806
|
detail?: string;
|
|
1754
1807
|
/** #67: the confidence floor fired — the first-layer verdict was demoted. */
|
|
@@ -1757,20 +1810,110 @@ export interface AuditRecord {
|
|
|
1757
1810
|
fallback?: FallbackAudit;
|
|
1758
1811
|
}
|
|
1759
1812
|
|
|
1813
|
+
// ============================================================================
|
|
1814
|
+
// Audit secret redaction (ADR-0007)
|
|
1815
|
+
// ============================================================================
|
|
1816
|
+
|
|
1817
|
+
/** sha256 fingerprint (first 8 hex), unsalted: deterministic — the same secret
|
|
1818
|
+
* keeps one fingerprint across records, preserving clustering and diffability.
|
|
1819
|
+
* The threat model anchors on high-entropy API keys, for which the rainbow
|
|
1820
|
+
* tables that salting guards against are infeasible (ADR-0007). */
|
|
1821
|
+
function redactionFingerprint(secret: string): string {
|
|
1822
|
+
return crypto.createHash("sha256").update(secret).digest("hex").slice(0, 8);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/** Context-guided value redaction — shapeless secrets identified by neighboring
|
|
1826
|
+
* structure (assignment/header/URL position). Every value character class
|
|
1827
|
+
* excludes `<` (every replacement starts with `<`, so layering is idempotent);
|
|
1828
|
+
* `#` is excluded only where a bare fragment could look like a value (bearer,
|
|
1829
|
+
* query). The URL value class also excludes `/` (RFC 3986 userinfo forbids it —
|
|
1830
|
+
* and it keeps `host:8080/path@…` port shapes out of the password slot). */
|
|
1831
|
+
const REDACT_CONTEXT_RULES: ReadonlyArray<{ re: RegExp; build: (...m: string[]) => string }> = [
|
|
1832
|
+
{ re: /(https?:\/\/[^/\s:@]+:)([^@/\s<]{8,})(?=@)/g, build: (...m) => `${m[1]}<redacted:url#${redactionFingerprint(m[2])}>` },
|
|
1833
|
+
{ re: /(-u\s+"?)([^:\s"<]+):([^"\s<]{8,})(")/g, build: (...m) => `${m[1]}${m[2]}:<redacted:basic#${redactionFingerprint(m[3])}>${m[4]}` },
|
|
1834
|
+
{ re: /(-u\s+)([^:\s"<]+):([^"\s<]{8,})/g, build: (...m) => `${m[1]}${m[2]}:<redacted:basic#${redactionFingerprint(m[3])}>` },
|
|
1835
|
+
// query runs before env: a `?token=…` param belongs to the query tag; the env
|
|
1836
|
+
// bare-name branch (TOKEN/APIKEY/SECRET) then only sees non-query positions
|
|
1837
|
+
{ re: /([?&](?:token|apikey|api_key|access_token|client_secret|signature|sig)=)([^&\s"'#<>]{8,})/gi, build: (...m) => `${m[1]}<redacted:query#${redactionFingerprint(m[2])}>` },
|
|
1838
|
+
{ re: /\b([A-Za-z_][A-Za-z0-9_]*(?:_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIALS?)|APIKEY|TOKEN|SECRET)(=)("[^"<]{8,}"|[^\s"<&;]{8,})/gi, build: (...m) => `${m[1]}${m[2]}<redacted:env#${redactionFingerprint(m[3].replace(/^"|"$/g, ""))}>` },
|
|
1839
|
+
{ re: /(\bbearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi, build: (...m) => `${m[1]}<redacted:bearer#${redactionFingerprint(m[2])}>` },
|
|
1840
|
+
];
|
|
1841
|
+
|
|
1842
|
+
/** Exact provider token shapes (near-zero false positives). Order matters both
|
|
1843
|
+
* here (specific prefix before general: sk-ant before sk) and against the
|
|
1844
|
+
* context layer: shapes run SECOND, so a provider-shaped *username* (e.g.
|
|
1845
|
+
* `-u "sk-…:shapelesspass"`) still has its raw form visible when the context
|
|
1846
|
+
* rules match — running shapes first would rewrite the username into a marker
|
|
1847
|
+
* and the `<` exclusion would defeat the `-u` rule, dropping the shapeless
|
|
1848
|
+
* password raw. All patterns are character classes + bounded/greedy
|
|
1849
|
+
* quantifiers — no nested quantifiers, no backtrack blowup. */
|
|
1850
|
+
const REDACT_TOKEN_SHAPES: ReadonlyArray<{ type: string; re: RegExp }> = [
|
|
1851
|
+
{ type: "jwt", re: /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g },
|
|
1852
|
+
{ type: "github_pat", re: /github_pat_[A-Za-z0-9_]{22,}/g },
|
|
1853
|
+
{ type: "gh", re: /gh[posur]_[A-Za-z0-9]{30,}/g },
|
|
1854
|
+
{ type: "sk-ant", re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
1855
|
+
{ type: "sk", re: /sk-[A-Za-z0-9][A-Za-z0-9_-]{15,}/g },
|
|
1856
|
+
{ type: "pk", re: /pk-[A-Za-z0-9][A-Za-z0-9_-]{15,}/g },
|
|
1857
|
+
{ type: "aws", re: /AKIA[0-9A-Z]{16}/g },
|
|
1858
|
+
{ type: "slack", re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
1859
|
+
{ type: "google", re: /AIza[0-9A-Za-z_-]{30,}/g },
|
|
1860
|
+
];
|
|
1861
|
+
|
|
1862
|
+
function redactString(s: string): string {
|
|
1863
|
+
let out = s;
|
|
1864
|
+
for (const { re, build } of REDACT_CONTEXT_RULES) {
|
|
1865
|
+
out = out.replace(re, build);
|
|
1866
|
+
}
|
|
1867
|
+
for (const { type, re } of REDACT_TOKEN_SHAPES) {
|
|
1868
|
+
out = out.replace(re, (m) => `<redacted:${type}#${redactionFingerprint(m)}>`);
|
|
1869
|
+
}
|
|
1870
|
+
return out;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/** Audit redaction: recursively processes every string value of the record
|
|
1874
|
+
* (input/actionLine/transcript/reason/fallback…), returning a fresh tree and
|
|
1875
|
+
* never mutating the caller's object (pendingAudit is spread later for the
|
|
1876
|
+
* userAnswer finalize). Exotic objects (Date, …) pass through untouched —
|
|
1877
|
+
* JSON.stringify keeps authority over their serialization. Applies to the
|
|
1878
|
+
* persisted copy only — the adjudication pipeline and the classifier input
|
|
1879
|
+
* keep the raw text: the fact that a command carries a secret is itself an
|
|
1880
|
+
* adjudication signal (ADR-0007). */
|
|
1881
|
+
export function redactSecrets<T>(value: T): T {
|
|
1882
|
+
if (typeof value === "string") return redactString(value) as T;
|
|
1883
|
+
if (Array.isArray(value)) return value.map((v) => redactSecrets(v)) as T;
|
|
1884
|
+
if (value !== null && typeof value === "object") {
|
|
1885
|
+
const proto = Object.getPrototypeOf(value);
|
|
1886
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
1887
|
+
const out: Record<string, unknown> = {};
|
|
1888
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = redactSecrets(v);
|
|
1889
|
+
return out as T;
|
|
1890
|
+
}
|
|
1891
|
+
return value;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1760
1894
|
/** Audit sink (#54): append-only and fail-soft (the first write failure surfaces
|
|
1761
1895
|
* once via drainWarning; verdicts are never affected). The dir is created
|
|
1762
1896
|
* lazily — audit on with no gray-zone call all session leaves zero filesystem trace. */
|
|
1763
1897
|
export class AuditLog {
|
|
1764
1898
|
private warning: string | null = null;
|
|
1765
1899
|
private warned = false;
|
|
1766
|
-
constructor(readonly dir: string) {}
|
|
1900
|
+
constructor(readonly dir: string, readonly redact = true) {}
|
|
1767
1901
|
|
|
1768
1902
|
append(record: AuditRecord): void {
|
|
1769
1903
|
// sessionId comes from the host with no shape guarantee: narrow to a safe filename charset
|
|
1770
1904
|
const file = path.join(this.dir, `${record.sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`);
|
|
1771
1905
|
try {
|
|
1772
1906
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
1773
|
-
|
|
1907
|
+
// ADR-0007: redact the persisted copy only (new tree — the caller's
|
|
1908
|
+
// pendingAudit object is spread later for the userAnswer finalize);
|
|
1909
|
+
// a redaction failure fails open: record integrity over hygiene
|
|
1910
|
+
let persisted: AuditRecord = record;
|
|
1911
|
+
if (this.redact) {
|
|
1912
|
+
try {
|
|
1913
|
+
persisted = redactSecrets(record);
|
|
1914
|
+
} catch { /* fail-open */ }
|
|
1915
|
+
}
|
|
1916
|
+
fs.appendFileSync(file, JSON.stringify(persisted) + "\n");
|
|
1774
1917
|
} catch (err) {
|
|
1775
1918
|
if (!this.warned) {
|
|
1776
1919
|
this.warned = true;
|
|
@@ -1838,7 +1981,7 @@ export class SessionState {
|
|
|
1838
1981
|
|
|
1839
1982
|
/** #54: the audit flag follows the rules (applies to new sessions); the dir is anchored to the install path */
|
|
1840
1983
|
private makeAudit(rules: UserRules): AuditLog | null {
|
|
1841
|
-
return rules.audit && this.agentDir ? new AuditLog(path.join(this.agentDir, "verdicts")) : null;
|
|
1984
|
+
return rules.audit && this.agentDir ? new AuditLog(path.join(this.agentDir, "verdicts"), rules.auditRedactSecrets) : null;
|
|
1842
1985
|
}
|
|
1843
1986
|
|
|
1844
1987
|
/** 会话重置:重载用户规则(配置改动新会话生效)+ 按会话 cwd 重锚 denyPaths
|
|
@@ -1991,12 +2134,20 @@ async function runConfidenceCascade(
|
|
|
1991
2134
|
*/
|
|
1992
2135
|
export async function adjudicate(
|
|
1993
2136
|
state: SessionState,
|
|
1994
|
-
call: { toolName: string; input: Record<string, unknown
|
|
2137
|
+
call: { toolName: string; input: Record<string, unknown>; toolCallId?: string; parentToolCallId?: string },
|
|
1995
2138
|
env: AdjudicateEnv,
|
|
1996
2139
|
): Promise<Verdict> {
|
|
1997
2140
|
const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
|
|
1998
2141
|
if (rule.verdict === "allow") return { verdict: "allow", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1999
2142
|
if (rule.verdict === "deny") return { verdict: "deny", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
2143
|
+
// ADR-0008: subagent artifacts — a headless (no-UI) session writing pi's own
|
|
2144
|
+
// runtime artifact tree passes deterministically; user deny and denyPaths have
|
|
2145
|
+
// already ruled above (declarations beat the built-in exemption), and only the
|
|
2146
|
+
// gray grade is lifted. An interactive session keeps its ask capability, so the
|
|
2147
|
+
// exemption stays headless-only. Read and bash are deliberately NOT exempt.
|
|
2148
|
+
if (rule.verdict === "gray" && !env.hasUI && (call.toolName === "write" || call.toolName === "edit") && isSubagentArtifactWrite(String(call.input.path ?? ""), env.cwd, state.prot)) {
|
|
2149
|
+
return { verdict: "allow", reason: "subagent artifacts (headless) — deterministic write exemption (ADR-0008)", source: "rule", degraded: false };
|
|
2150
|
+
}
|
|
2000
2151
|
|
|
2001
2152
|
// #62: the audit surface widens to protected-path asks (their user answers grade the
|
|
2002
2153
|
// denyPaths rules); rule allow/deny stay unaudited (no corpus value, #54). Record
|
|
@@ -2016,6 +2167,10 @@ export async function adjudicate(
|
|
|
2016
2167
|
thinking: raw?.thinking ?? null,
|
|
2017
2168
|
transcript: raw?.transcript ?? null,
|
|
2018
2169
|
rawResponse: raw?.rawResponse ?? null,
|
|
2170
|
+
// #90 audit attribution: ids ride on every record; nested records additionally
|
|
2171
|
+
// carry the parent linkage, making them distinguishable from direct calls
|
|
2172
|
+
...(call.toolCallId !== undefined ? { toolCallId: call.toolCallId } : {}),
|
|
2173
|
+
...(call.parentToolCallId !== undefined ? { parentToolCallId: call.parentToolCallId } : {}),
|
|
2019
2174
|
...v,
|
|
2020
2175
|
});
|
|
2021
2176
|
|
|
@@ -2030,6 +2185,17 @@ export async function adjudicate(
|
|
|
2030
2185
|
return { verdict: "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: true };
|
|
2031
2186
|
}
|
|
2032
2187
|
|
|
2188
|
+
// ADR-0006 (#90): the layered-exemption policy. Nested calls under rules-only have
|
|
2189
|
+
// already passed every deterministic layer above (self-protection, floor, user
|
|
2190
|
+
// rules, denyPaths + its ask) — only the intelligence layer is skipped: the gray
|
|
2191
|
+
// zone passes. Audited when audit is on (source rule + policy marker): the user
|
|
2192
|
+
// needs corpus data on what the opt-in actually let through.
|
|
2193
|
+
if (call.parentToolCallId !== undefined && state.userRules.codemodeNestedCalls === "rules-only") {
|
|
2194
|
+
const reason = "codemodeNestedCalls rules-only: gray-zone passthrough (nested call — deterministic layers only)";
|
|
2195
|
+
state.audit?.append({ ...buildRecord({ verdict: "allow", reason, source: "rule", degraded: false }, null), policy: "rules-only" });
|
|
2196
|
+
return { verdict: "allow", reason, source: "rule", degraded: false };
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2033
2199
|
// 灰区 → 分类器;无可用模型 → fail-closed
|
|
2034
2200
|
|
|
2035
2201
|
const resolved = env.getModel();
|
|
@@ -2219,6 +2385,8 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2219
2385
|
const toggleHint = () => (registeredToggleKey ? ` · toggle: ${registeredToggleKey}` : "");
|
|
2220
2386
|
/** Status line denyPaths count (ADR-0002): shown only when configured */
|
|
2221
2387
|
const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
|
|
2388
|
+
/** #90: nested-call policy hint — shown only when the exemption is active */
|
|
2389
|
+
const nestedPolicyHint = () => (state.userRules.codemodeNestedCalls === "rules-only" ? "\nnested calls: rules-only (deterministic layers only — the gray zone passes)" : "");
|
|
2222
2390
|
/** Status line audit hint (#54): shown only while the sink is active */
|
|
2223
2391
|
const auditHint = () => (state.audit ? `\naudit: on → ${state.audit.dir}` : "");
|
|
2224
2392
|
/** Status line cascade hint (#63/#67): shown while the floor or the fallback is configured */
|
|
@@ -2236,7 +2404,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2236
2404
|
const arg = args.trim().toLowerCase();
|
|
2237
2405
|
// 裸调用:只读状态展示,无副作用
|
|
2238
2406
|
if (arg === "") {
|
|
2239
|
-
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${denyPathsHint()}${auditHint()}${fallbackHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
2407
|
+
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${denyPathsHint()}${nestedPolicyHint()}${auditHint()}${fallbackHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
2240
2408
|
return;
|
|
2241
2409
|
}
|
|
2242
2410
|
// 幂等设定:与现值相同不翻转,仅确认
|
|
@@ -2451,7 +2619,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2451
2619
|
}
|
|
2452
2620
|
|
|
2453
2621
|
// 判定管线(零 UI)→ 呈现(source 模板)
|
|
2454
|
-
const verdict = await adjudicate(state, { toolName: event.toolName, input }, {
|
|
2622
|
+
const verdict = await adjudicate(state, { toolName: event.toolName, input, toolCallId: event.toolCallId, parentToolCallId: event.parentToolCallId }, {
|
|
2455
2623
|
cwd: ctx.cwd,
|
|
2456
2624
|
hasUI: !!ctx.hasUI,
|
|
2457
2625
|
getModel: () => resolveClassifier(ctx),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-verdict",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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
|
}
|