pi-verdict 0.11.0 → 0.12.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 -11
- package/README.zh-CN.md +18 -10
- package/extensions/pi-verdict.ts +117 -205
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**pi-verdict is a minimal permission gate for [pi](https://pi.dev) in the style of Claude Code's auto mode: every tool call gets checked before it runs — allow, deny, or ask you first.**
|
|
10
10
|
|
|
11
|
-
- Minimal — just
|
|
11
|
+
- Minimal — just 2k lines of code
|
|
12
12
|
- Built-in danger rules and your own allow/deny rules settle the clear cases first, at zero latency
|
|
13
13
|
- Everything else goes to a model classifier that sees the conversation context
|
|
14
14
|
- Any uncertainty or failure fails closed; nothing ever runs silently
|
|
@@ -69,7 +69,7 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
69
69
|
| user rules | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
|
|
70
70
|
| credential file (S0 hard deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
|
|
71
71
|
|
|
72
|
-
- `/automode` — show current status: on/off
|
|
72
|
+
- `/automode` — show current status: on/off
|
|
73
73
|
- `/automode on`
|
|
74
74
|
- `/automode off`
|
|
75
75
|
- `ctrl+shift+a` — toggle the master switch silently (the always-on footer is the only feedback; rebind or disable via `toggleShortcut`)
|
|
@@ -97,6 +97,12 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
97
97
|
"~/.zshrc",
|
|
98
98
|
"~/.bashrc"
|
|
99
99
|
],
|
|
100
|
+
"ignoreTools": [
|
|
101
|
+
"todo",
|
|
102
|
+
"ask_user_question",
|
|
103
|
+
"memory_write",
|
|
104
|
+
"memory_search"
|
|
105
|
+
],
|
|
100
106
|
"builtinDenyFloor": true,
|
|
101
107
|
"classifierModel": null,
|
|
102
108
|
"toggleShortcut": "ctrl+shift+a",
|
|
@@ -104,18 +110,19 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
104
110
|
"notifyAllows": false,
|
|
105
111
|
"classifierMinConfidence": null,
|
|
106
112
|
"classifierFallbackModel": null,
|
|
107
|
-
"classifierFallbackMode": "
|
|
113
|
+
"classifierFallbackMode": "enforce"
|
|
108
114
|
}
|
|
109
115
|
```
|
|
110
116
|
|
|
111
117
|
- `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
|
|
112
|
-
- `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)
|
|
118
|
+
- `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)
|
|
119
|
+
- `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)
|
|
113
120
|
- `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
|
|
114
121
|
- `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
|
|
115
122
|
- `classifierModel: "typesafe/jev-latest"` opts into the bundled **jev decisions adapter** — gray-zone verdicts via TypeSafe's jev (OpenRouter by default, or TypeSafe's official API directly with `PI_VERDICT_JEV_TRANSPORT=typesafe`); experimental, see [ADR-0003](docs/adr/0003-jev-decisions-adapter.md)
|
|
116
123
|
- `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
|
|
117
|
-
- `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;
|
|
118
|
-
- `classifierMinConfidence` (optional, [ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)) sets the **confidence floor**: a jev verdict below it is demoted — cascaded to `classifierFallbackModel` if set (`
|
|
124
|
+
- `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
|
|
125
|
+
- `classifierMinConfidence` (optional, [ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)) sets the **confidence floor**: a jev verdict below it is demoted — cascaded to `classifierFallbackModel` if set (`enforce`, the default = the second layer adjudicates, except 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. A natural pairing: jev first + a haiku/flash-class fallback
|
|
119
126
|
|
|
120
127
|
No built-in allowlist — every "always allow" claim is yours ([why](docs/configuration.md#why-no-built-in-allowlist)). Full reference: [docs/configuration.md](docs/configuration.md).
|
|
121
128
|
|
|
@@ -134,7 +141,7 @@ No built-in allowlist — every "always allow" claim is yours ([why](docs/config
|
|
|
134
141
|
- **Hosts**: pi only. On omp the setting warns and falls back to the session model; and it must never be selected as the session model (no text generation — selecting it warns)
|
|
135
142
|
- **Escape hatch**: `PI_VERDICT_JEV_URL` overrides the active transport's endpoint (OpenRouter's is an alpha API)
|
|
136
143
|
|
|
137
|
-
jev's calibrated confidence is exactly what the confidence floor keys on — pair it with a second layer (`"classifierMinConfidence"
|
|
144
|
+
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)).
|
|
138
145
|
|
|
139
146
|
### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
140
147
|
|
|
@@ -145,6 +152,8 @@ The gate's own files — the config and the installed extension copy — are **u
|
|
|
145
152
|
|
|
146
153
|
Requires pi ≥ 0.84. Works in interactive and non-interactive (`-p`/json/rpc) sessions; in non-interactive modes `ask` degrades to `deny`.
|
|
147
154
|
|
|
155
|
+
---
|
|
156
|
+
|
|
148
157
|
## How it compares
|
|
149
158
|
|
|
150
159
|
| | three-state verdict | classifier sees context | fail direction | runtime deps |
|
|
@@ -173,6 +182,7 @@ tool_call
|
|
|
173
182
|
│ ├─ your rules: user deny beats user allow
|
|
174
183
|
│ ├─ denyPaths (ADR-0002): protected paths → terminal ask,
|
|
175
184
|
│ │ before user allow; classifier sees an existence hint only
|
|
185
|
+
│ ├─ ignoreTools: your declared uncovered tools → allow, zero model calls
|
|
176
186
|
│ └─ no built-in allowlist — every "always allow" claim is yours to make
|
|
177
187
|
│
|
|
178
188
|
├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
|
|
@@ -185,7 +195,6 @@ tool_call
|
|
|
185
195
|
├─ deny → block, reason returned to the agent
|
|
186
196
|
└─ ask → human confirm; non-interactive modes degrade to deny
|
|
187
197
|
|
|
188
|
-
[shadow cache] observe-only telemetry alongside 2/3, never changes a verdict
|
|
189
198
|
```
|
|
190
199
|
|
|
191
200
|
**fail-closed**: classifier exception / timeout (25s) / contract violation → deny. Never silently allow.
|
|
@@ -194,7 +203,7 @@ tool_call
|
|
|
194
203
|
|
|
195
204
|
Design decisions here are settled by measurement, and the lab notes ship with the repo:
|
|
196
205
|
|
|
197
|
-
- [`research/cache-sim`](research/cache-sim/README.md) — replayed 1.2k+ real classifier verdicts to measure verdict-cache hit rate (**3.2%** → cache
|
|
206
|
+
- [`research/cache-sim`](research/cache-sim/README.md) — replayed 1.2k+ real classifier verdicts to measure verdict-cache hit rate (**3.2%** → serving cache declined; the runtime shadow telemetry built afterwards measured 3.3% and was later removed too, #73)
|
|
198
207
|
- [`research/thinking-param-blackhole.md`](research/thinking-param-blackhole.md) — three-layer forensic root-cause of thinking models burning the classifier budget; why the fix is `thinkingEnabled: false`
|
|
199
208
|
- [`research/rule-engine-sim`](research/rule-engine-sim/README.md) — measured a tree-sitter rule-engine port against 746 real bash calls (**absorbs 0 gray calls**) and rejected it
|
|
200
209
|
- [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) — the competitive landscape this README's positioning is checked against
|
|
@@ -210,7 +219,6 @@ Design decisions here are settled by measurement, and the lab notes ship with th
|
|
|
210
219
|
- AGENTS.md is not passed to the classifier as downweighted intent evidence (Claude Code does this)
|
|
211
220
|
- parallel gray-zone calls are adjudicated serially
|
|
212
221
|
- 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)
|
|
213
|
-
- shadow cache is observe-only by decision; the serving switch is a one-line change once measured hit rates justify it
|
|
214
222
|
- `denyPaths` bash extraction is token-level ([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)): command substitution, base64-embedded paths and external script contents produce no hit signal — those calls fall back to the classifier's existence-hint vigilance. MCP and custom tools bypass the extractor entirely (their gray-zone adjudication still carries the hint). Path normalization is base-tier only (ADR-0002): a nonexistent target written through a symlinked directory rebuilds no real form and produces no hit — that indirection falls to the hint vigilance too (the ancestor-rebuilding tier applies to the self-protection layer and the sensitivity floor, not denyPaths). Honest framing, same as the self-protection substring precedent: the deterministic layer is obfuscatable, which is exactly why a hit routes to *you* rather than silently deciding
|
|
215
223
|
- `denyPaths` bash tokens contain no spaces: a *declared* path containing spaces cannot be spelled in a bash command in a way the extractor sees — `cat "/path with space/x"` splits into two tokens and never hits (file tools still hit, their path is not tokenized). A glob covering the final segment of a base (`cat /proj/pers*` against `denyPaths: ["/proj/personal"]`) also misses — the base's own name never appears literally. A recursive search issued from a shell misses in both spellings — no path argument (defaults to the cwd, e.g. a bare `rg foo`) or a parent-directory argument (`rg foo <parent-of-a-declared-path>`): an argument-less command contributes no token at all and bash tokens otherwise compare one-directionally, while the file tools' bidirectional subtree compare covers the same shapes issued through `grep`/`find`/`ls`. All three holes fall back to the classifier's existence hint, alongside substitution/base64 above
|
|
216
224
|
- self-protection bash matching is substring regex — obfuscatable; the tamper-detection backstop catches within-session bypasses, but a cross-session baseline (hash + change confirmation at startup, incl. upgrade UX) is phase 2 per [ADR-0001](docs/adr/0001-self-protection-layer.md)
|
|
@@ -225,7 +233,7 @@ The name: the three-state **verdict** is the core concept. The UX keeps `/automo
|
|
|
225
233
|
```bash
|
|
226
234
|
bun install
|
|
227
235
|
bun run typecheck
|
|
228
|
-
bun test # offline stub tests: self-protection, tamper detection, deny floor, user rules, denyPaths, bypass regression, classifier retry,
|
|
236
|
+
bun test # offline stub tests: self-protection, tamper detection, deny floor, user rules, denyPaths, bypass regression, classifier retry, commands, toggle shortcut
|
|
229
237
|
```
|
|
230
238
|
|
|
231
239
|
Issue tracker and decision records live in the GitHub issues ("map" issue #1 indexes them).
|
package/README.zh-CN.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**pi-verdict 是 [pi](https://pi.dev) 的 Claude Code 风格的 Auto mode 式的极简权限门禁:每次工具调用执行前先过检查——放行、拦截,或先问你。**
|
|
10
10
|
|
|
11
|
-
- 只有
|
|
11
|
+
- 只有2k行左右的极简代码
|
|
12
12
|
- 内置危险规则与你的 allow/deny 规则以零延迟先行裁决明确情形
|
|
13
13
|
- 其余交给携带会话上下文的模型分类器
|
|
14
14
|
- 任何不确定或失败一律 fail-closed, 绝不静默放行
|
|
@@ -70,7 +70,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
70
70
|
| 用户规则 | `~/.pi/agent/config/pi-verdict.json` | `~/.omp/agent/config/pi-verdict.json` |
|
|
71
71
|
| 凭据文件(S0 硬 deny) | `~/.pi/agent/auth.json` | `~/.omp/agent/auth.json` |
|
|
72
72
|
|
|
73
|
-
- `/automode` —— 显示当前状态:开/关
|
|
73
|
+
- `/automode` —— 显示当前状态:开/关
|
|
74
74
|
- `/automode on`
|
|
75
75
|
- `/automode off`
|
|
76
76
|
- `ctrl+shift+a` —— 静默切换主开关(footer 始终显示为唯一反馈;键位可经 `toggleShortcut` 重绑或禁用)
|
|
@@ -99,6 +99,12 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
99
99
|
"~/.zshrc",
|
|
100
100
|
"~/.bashrc"
|
|
101
101
|
],
|
|
102
|
+
"ignoreTools": [
|
|
103
|
+
"todo",
|
|
104
|
+
"ask_user_question",
|
|
105
|
+
"memory_write",
|
|
106
|
+
"memory_search"
|
|
107
|
+
],
|
|
102
108
|
"builtinDenyFloor": true,
|
|
103
109
|
"classifierModel": null,
|
|
104
110
|
"toggleShortcut": "ctrl+shift+a",
|
|
@@ -106,18 +112,19 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
106
112
|
"notifyAllows": false,
|
|
107
113
|
"classifierMinConfidence": null,
|
|
108
114
|
"classifierFallbackModel": null,
|
|
109
|
-
"classifierFallbackMode": "
|
|
115
|
+
"classifierFallbackMode": "enforce"
|
|
110
116
|
}
|
|
111
117
|
```
|
|
112
118
|
|
|
113
119
|
- `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
|
|
114
|
-
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件)
|
|
120
|
+
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件)
|
|
121
|
+
- `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` 的存在性话术警戒(未覆盖工具本就不进路径提取器)
|
|
115
122
|
- `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
|
|
116
123
|
- `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
|
|
117
124
|
- `classifierModel: "typesafe/jev-latest"` 启用随包的 **jev 决策适配器**——灰区裁决经 TypeSafe jev 完成(默认 OpenRouter,或 `PI_VERDICT_JEV_TRANSPORT=typesafe` 直连官方 API);实验性质,详见 [ADR-0003](docs/adr/0003-jev-decisions-adapter.md)
|
|
118
125
|
- `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` 会显示审计状态与路径
|
|
119
|
-
- `notifyAllows: true` 对每次 **classifier 放行**发通知(reason + action 行——如 jev 的概率分解);默认 `false` 保持放行静默。机械放行(你自己的 allow 规则、protected-path 确认)
|
|
120
|
-
- `classifierMinConfidence`(可选,[ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))设定**置信地板**:低于它的 jev 裁决被降级——配置了 `classifierFallbackModel` 则级联(`
|
|
126
|
+
- `notifyAllows: true` 对每次 **classifier 放行**发通知(reason + action 行——如 jev 的概率分解);默认 `false` 保持放行静默。机械放行(你自己的 allow 规则、protected-path 确认)永不通知;两开关同开时通知只出现一次
|
|
127
|
+
- `classifierMinConfidence`(可选,[ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))设定**置信地板**:低于它的 jev 裁决被降级——配置了 `classifierFallbackModel` 则级联(`enforce`,默认 = 第二层全权裁决,但降级的 **deny 与 ask** 永不被自动放宽为 allow——fail-closed 未产生任何裁决,其获救裁决照常生效;`shadow` = 只记录意见、由你裁决——`/automode` 会提示激活开关),否则直接问你。不低于地板时第一层自主。天然搭配:jev 打头 + haiku/flash 级兜底
|
|
121
128
|
|
|
122
129
|
没有内置白名单——每一条「永远放行」声明都归你([为什么](docs/configuration.md#why-no-built-in-allowlist))。完整参考:[docs/configuration.md](docs/configuration.md)。
|
|
123
130
|
|
|
@@ -136,7 +143,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
136
143
|
- **宿主**:仅支持pi。omp 上该设置会警告并回退会话模型。也绝不能选作会话主模型(不生成文本,选中即警告)
|
|
137
144
|
- **逃生口**:`PI_VERDICT_JEV_URL` 可覆盖当前 transport 的端点(OpenRouter 侧为 alpha 接口)
|
|
138
145
|
|
|
139
|
-
jev 的校准 confidence 正是置信地板的判定依据——搭配第二层使用(`"classifierMinConfidence"
|
|
146
|
+
jev 的校准 confidence 正是置信地板的判定依据——搭配第二层使用(`"classifierMinConfidence", "classifierFallbackModel"`),让低置信调用交给更深的模型而非直接生效([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))。
|
|
140
147
|
|
|
141
148
|
### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
142
149
|
|
|
@@ -147,6 +154,8 @@ jev 的校准 confidence 正是置信地板的判定依据——搭配第二层
|
|
|
147
154
|
|
|
148
155
|
需要 pi ≥ 0.84。交互与非交互(`-p`/json/rpc)会话均支持;非交互模式下 `ask` 降级为 `deny`。
|
|
149
156
|
|
|
157
|
+
---
|
|
158
|
+
|
|
150
159
|
## 与品类对比
|
|
151
160
|
|
|
152
161
|
| | 三态裁决 | 分类器携带上下文 | fail 方向 | 运行时依赖 |
|
|
@@ -175,6 +184,7 @@ tool_call
|
|
|
175
184
|
│ ├─ 用户规则:deny 优先于 allow
|
|
176
185
|
│ ├─ denyPaths(ADR-0002):受保护路径 → 终局 ask,先于用户 allow;
|
|
177
186
|
│ │ 分类器只见存在性话术
|
|
187
|
+
│ ├─ ignoreTools:用户声明的未覆盖工具 → 直接放行,零模型调用
|
|
178
188
|
│ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
|
|
179
189
|
│
|
|
180
190
|
├─ 2. 灰区 → 模型分类器(默认继承会话模型 —— "自省")
|
|
@@ -187,7 +197,6 @@ tool_call
|
|
|
187
197
|
├─ deny → 拦截,理由回传 agent
|
|
188
198
|
└─ ask → 人工确认;非交互模式降级为 deny
|
|
189
199
|
|
|
190
|
-
[影子缓存] observe-only 遥测,与 2/3 并行,永不改变裁决
|
|
191
200
|
```
|
|
192
201
|
|
|
193
202
|
**fail-closed**:分类器异常 / 超时(25s)/ 输出违反契约 → 拦截,绝不静默放行。
|
|
@@ -212,7 +221,6 @@ tool_call
|
|
|
212
221
|
- AGENTS.md 未作为降权意图证据传入分类器(Claude Code 有此设计)
|
|
213
222
|
- 并行灰区调用串行裁决
|
|
214
223
|
- 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
|
|
215
|
-
- 影子缓存按决议仅观察不生效;实测命中率达标后,生效开关是一行改动
|
|
216
224
|
- `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。路径归一化亦为基础档(ADR-0002):经符号链接目录写入尚不存在的目标不重建真实形、不产生命中——该间接路径同样由话术警戒覆盖(祖先重建档只适用于自保护层与路径敏感度 floor,不适用 denyPaths)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
|
|
217
225
|
- `denyPaths` 的 bash token 不含空格:**声明路径本身含空格时**,bash 拼写无法被提取器识别——`cat "/path with space/x"` 被拆成两个 token 永不命中(文件类工具仍命中,其路径不经 token 化)。glob 覆盖基名末段(`denyPaths: ["/proj/personal"]` 时 `cat /proj/pers*`)同样漏过——基名自身从未字面出现。经 shell 发起的递归搜索在两种拼写下都漏过——不带路径参数(默认搜 cwd,如裸 `rg foo`)或带父目录参数(`rg foo <声明路径的父目录>`):无参命令根本不产生 token,带参时 bash token 只做单向比较;同一形状经 `grep`/`find`/`ls` 工具发起则由双向子树比较覆盖。三个洞与上述替换/base64 一样回落到分类器的存在性话术
|
|
218
226
|
- 自保护 bash 匹配是子串正则——可被混淆绕过;变更检测兜底覆盖会话内绕过,跨会话基线(启动时哈希比对与变更确认,含升级 UX)按 ADR-0001 为二期
|
|
@@ -227,7 +235,7 @@ tool_call
|
|
|
227
235
|
```bash
|
|
228
236
|
bun install
|
|
229
237
|
bun run typecheck
|
|
230
|
-
bun test # 离线桩测试:自保护 / 变更检测 / deny floor / 用户规则 / denyPaths / 绕过回归 / 分类器重试 /
|
|
238
|
+
bun test # 离线桩测试:自保护 / 变更检测 / deny floor / 用户规则 / denyPaths / 绕过回归 / 分类器重试 / 命令 / toggle 快捷键
|
|
231
239
|
```
|
|
232
240
|
|
|
233
241
|
Issue tracker 与决策记录在 GitHub issues(「地图」issue #1 为索引)。
|
package/extensions/pi-verdict.ts
CHANGED
|
@@ -39,14 +39,10 @@
|
|
|
39
39
|
* Structure: the pipeline is adjudicate() — a zero-UI module returning a Verdict
|
|
40
40
|
* value object (source: rule|protected-path|classifier|fail-closed, plus a
|
|
41
41
|
* `degraded` flag for ask→deny in non-interactive sessions); the tool_call
|
|
42
|
-
* handler maps verdicts to UI (notify/confirm/select) by source
|
|
42
|
+
* handler maps verdicts to UI (notify/confirm/select) by source (the degraded
|
|
43
|
+
* context is implicit in the protected-path source's deny wording) and
|
|
43
44
|
* runs IntegrityWatch (ADR-0001) as a pre-pipeline gate-integrity check.
|
|
44
45
|
*
|
|
45
|
-
* Shadow cache (observe-only, #7): gray-zone verdicts are replayed against a
|
|
46
|
-
* double-key LRU(128) to measure would-be hit rate; recorded, never applied
|
|
47
|
-
* (verdicts always come from the model), accumulating pi field data for the
|
|
48
|
-
* "should a serving cache ship" question (#5 decision).
|
|
49
|
-
*
|
|
50
46
|
* fail-closed: classifier exception/timeout/contract violation → deny; in
|
|
51
47
|
* non-interactive modes (no UI) ask → deny.
|
|
52
48
|
*
|
|
@@ -60,8 +56,7 @@
|
|
|
60
56
|
* suffix (pi-native --model syntax; default off
|
|
61
57
|
* = thinking explicitly disabled)
|
|
62
58
|
* PI_AUTO_MODE_MODEL env-var form of the above
|
|
63
|
-
* --auto-mode-debug notify on every verdict (incl. allows)
|
|
64
|
-
* cache annotation on
|
|
59
|
+
* --auto-mode-debug notify on every verdict (incl. allows)
|
|
65
60
|
* PI_AUTO_MODE_DEBUG=1 env-var form of the above (kept for compat)
|
|
66
61
|
* <agentDir>/config/pi-verdict.json user rules: { allow: [regex], deny: [regex],
|
|
67
62
|
* denyPaths: [path], builtinDenyFloor,
|
|
@@ -74,9 +69,9 @@
|
|
|
74
69
|
* Known prototype simplifications (see README "Status & limitations"):
|
|
75
70
|
* - no built-in bash allowlist; danger detection is regex floor (no AST parsing)
|
|
76
71
|
* — unknown shapes go to the classifier
|
|
77
|
-
* - serving verdict cache
|
|
78
|
-
*
|
|
79
|
-
* deny-storm cost blowup / long non-interactive runs)
|
|
72
|
+
* - no serving verdict cache (#5 decision; runtime shadow telemetry removed #73 —
|
|
73
|
+
* re-evaluation belongs to offline replay tooling); no circuit breaker (revisit
|
|
74
|
+
* signals = deny-storm cost blowup / long non-interactive runs)
|
|
80
75
|
* - AGENTS.md not passed to the classifier as downweighted intent evidence
|
|
81
76
|
* - denyPaths bash extraction is token-level: command substitution, base64-
|
|
82
77
|
* embedded paths and external script contents produce no hit signal — those
|
|
@@ -254,6 +249,14 @@ interface UserRules {
|
|
|
254
249
|
deny: RegExp[];
|
|
255
250
|
/** User-declared protected paths (ADR-0002): plain paths, tool-owned normalization; hit → ask */
|
|
256
251
|
denyPaths: string[];
|
|
252
|
+
/** User-declared tool passthrough: names of tools OUTSIDE the command/file
|
|
253
|
+
* families (todo, web_search, MCP/custom tools, …) that skip adjudication —
|
|
254
|
+
* session-metadata/read-only tools the owner exempts, same stance as user allow
|
|
255
|
+
* rules: no built-in passthrough, every exemption is the user's own claim.
|
|
256
|
+
* Entries naming covered tools (bash/read/write/edit/grep/find/ls/powershell)
|
|
257
|
+
* are inert: those are governed by the deny floor and user allow/deny rules,
|
|
258
|
+
* which this list can never weaken. */
|
|
259
|
+
ignoreTools: string[];
|
|
257
260
|
/** 内置 deny floor 开关(危险正则 + 路径敏感度 deny),默认 true;关闭后依赖用户规则与分类器 */
|
|
258
261
|
builtinDenyFloor: boolean;
|
|
259
262
|
/** 分类器模型 spec(provider/id);null = 未配置(自省继承会话模型) */
|
|
@@ -271,12 +274,12 @@ interface UserRules {
|
|
|
271
274
|
/** #63/#67: second-layer model spec (provider/id[:thinking]); consulted on demotion
|
|
272
275
|
* and fail-closed only. null = no second layer. */
|
|
273
276
|
classifierFallbackModel: string | null;
|
|
274
|
-
/** #67: does the second layer adjudicate cascaded calls ("enforce"
|
|
275
|
-
* opinion while the human decides ("shadow"
|
|
277
|
+
/** #67: does the second layer adjudicate cascaded calls ("enforce", default since
|
|
278
|
+
* 0.12.0) or only record its opinion while the human decides ("shadow")? */
|
|
276
279
|
classifierFallbackMode: "shadow" | "enforce";
|
|
277
280
|
}
|
|
278
281
|
|
|
279
|
-
const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false, classifierMinConfidence: null, classifierFallbackModel: null, classifierFallbackMode: "
|
|
282
|
+
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" };
|
|
280
283
|
|
|
281
284
|
/** This module's own file location (import.meta.url resolved; null = unresolvable). */
|
|
282
285
|
const OWN_FILE_PATH: string | null = (() => {
|
|
@@ -325,7 +328,6 @@ function userConfigPath(): string {
|
|
|
325
328
|
}
|
|
326
329
|
|
|
327
330
|
const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
328
|
-
_hint: "pi-verdict user rules — full reference: https://github.com/jesset/pi-verdict/blob/main/docs/configuration.md. deny beats allow. denyPaths: protected paths, any touch asks for your confirmation (non-interactive degrades to deny); the pre-filled starter list is your declaration, edit or empty freely. builtinDenyFloor=false disables the built-in danger floor at your own risk (the self-protection layer always stays on). classifierModel pins the classifier (provider/id, e.g. zai/glm-5.3-flash; empty = session model). classifierFallbackModel (optional) adds a second-layer classifier consulted only when the first layer is uncertain (ask / fail-closed / jev confidence below classifierFallbackConfidence, default 50); mode shadow (default) observes without changing verdicts, enforce escalates strictness only. toggleShortcut sets the master-switch toggle key (null or empty disables). This file is part of the permission gate: agent-side modification is denied — edit it manually outside pi. Changes apply to new sessions.",
|
|
329
331
|
allow: ["^ls\\b"],
|
|
330
332
|
deny: [],
|
|
331
333
|
denyPaths: [
|
|
@@ -336,6 +338,12 @@ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
|
336
338
|
"~/.zshrc",
|
|
337
339
|
"~/.bashrc",
|
|
338
340
|
],
|
|
341
|
+
ignoreTools: [
|
|
342
|
+
"todo",
|
|
343
|
+
"ask_user_question",
|
|
344
|
+
"memory_write",
|
|
345
|
+
"memory_search",
|
|
346
|
+
],
|
|
339
347
|
builtinDenyFloor: true,
|
|
340
348
|
classifierModel: null,
|
|
341
349
|
toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
|
|
@@ -343,13 +351,16 @@ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
|
343
351
|
notifyAllows: false,
|
|
344
352
|
classifierMinConfidence: null,
|
|
345
353
|
classifierFallbackModel: null,
|
|
346
|
-
classifierFallbackMode: "
|
|
354
|
+
classifierFallbackMode: "enforce",
|
|
347
355
|
}, null, 2)}\n`;
|
|
348
356
|
|
|
349
357
|
/**
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
358
|
+
* Load user rules. First run generates a template (bare keys + starter lists, no
|
|
359
|
+
* embedded prose — the full reference is docs/configuration.md; only ^ls\b in the
|
|
360
|
+
* allow example is live, the rest are placeholders). A missing/malformed/invalid
|
|
361
|
+
* config falls back to empty rules (safe default — the gate never disables);
|
|
362
|
+
* invalid regexes are collected and reported, an invalid toggleShortcut collects
|
|
363
|
+
* a warning text (both surface via session_start alongside `skipped`).
|
|
353
364
|
*/
|
|
354
365
|
function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning: string | null } {
|
|
355
366
|
try {
|
|
@@ -361,7 +372,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
361
372
|
} catch { /* 只读环境静默跳过 */ }
|
|
362
373
|
return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
|
|
363
374
|
}
|
|
364
|
-
let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown; classifierFallbackModel?: unknown; classifierFallbackConfidence?: unknown; classifierMinConfidence?: unknown; classifierFallbackMode?: unknown };
|
|
375
|
+
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 };
|
|
365
376
|
try {
|
|
366
377
|
raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
|
|
367
378
|
} catch (err) {
|
|
@@ -389,6 +400,15 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
389
400
|
}
|
|
390
401
|
return [x.trim()];
|
|
391
402
|
});
|
|
403
|
+
// ignoreTools entries are plain tool names: only type-valid non-empty strings
|
|
404
|
+
// survive; anything else joins the same one-shot warning channel
|
|
405
|
+
const ignoreTools = (Array.isArray(raw.ignoreTools) ? raw.ignoreTools : []).flatMap((x) => {
|
|
406
|
+
if (typeof x !== "string" || !x.trim()) {
|
|
407
|
+
if (x !== undefined && x !== null) skipped.push(`ignoreTools: ${JSON.stringify(x)}`);
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
return [x.trim()];
|
|
411
|
+
});
|
|
392
412
|
const shortcut = resolveToggleShortcut(raw.toggleShortcut);
|
|
393
413
|
// #63/#67: confidence-floor keys — invalid values skip into the one-shot warning channel
|
|
394
414
|
if (raw.classifierFallbackConfidence !== undefined) skipped.push("classifierFallbackConfidence: renamed to classifierMinConfidence (0.11.0) — key ignored");
|
|
@@ -402,6 +422,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
402
422
|
allow: compile(raw.allow),
|
|
403
423
|
deny: compile(raw.deny),
|
|
404
424
|
denyPaths,
|
|
425
|
+
ignoreTools,
|
|
405
426
|
builtinDenyFloor: raw.builtinDenyFloor !== false,
|
|
406
427
|
classifierModel: typeof raw.classifierModel === "string" && raw.classifierModel.trim() ? raw.classifierModel.trim() : null,
|
|
407
428
|
toggleShortcut: shortcut.key,
|
|
@@ -409,7 +430,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
409
430
|
notifyAllows: raw.notifyAllows === true,
|
|
410
431
|
classifierFallbackModel: typeof raw.classifierFallbackModel === "string" && raw.classifierFallbackModel.trim() ? raw.classifierFallbackModel.trim() : null,
|
|
411
432
|
classifierMinConfidence: minConfOk ? minConfRaw : null,
|
|
412
|
-
classifierFallbackMode: fbModeRaw === "enforce" ? "enforce" : "shadow",
|
|
433
|
+
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
|
|
413
434
|
},
|
|
414
435
|
skipped,
|
|
415
436
|
shortcutWarning: shortcut.warning,
|
|
@@ -923,7 +944,8 @@ class IntegrityWatch {
|
|
|
923
944
|
* 2. user deny → deny (beats allow)
|
|
924
945
|
* 3. denyPaths hit → terminal ask (ADR-0002: the declaring user adjudicates; before user allow)
|
|
925
946
|
* 4. user allow → allow
|
|
926
|
-
* 5. base (path tools' default allow/gray;
|
|
947
|
+
* 5. base (path tools' default allow/gray; uncovered tools: ignoreTools hit → allow
|
|
948
|
+
* passthrough, else gray) → classifier
|
|
927
949
|
*/
|
|
928
950
|
function classifyByRules(toolName: string, input: Record<string, unknown>, cwd: string, user: UserRules, prot: ProtectedSet, denyPathBases: string[]): RuleResult {
|
|
929
951
|
// 第 0 层:自保护层(ADR-0001)——先于一切,不可经任何配置豁免
|
|
@@ -946,7 +968,12 @@ function classifyByRules(toolName: string, input: Record<string, unknown>, cwd:
|
|
|
946
968
|
const p = typeof input.path === "string" ? input.path : undefined;
|
|
947
969
|
base = p ? classifyPath(toolName, p, cwd, false, user.builtinDenyFloor) : { verdict: "allow" };
|
|
948
970
|
} else {
|
|
949
|
-
|
|
971
|
+
// ignoreTools passthrough: user-declared exempt tools skip adjudication;
|
|
972
|
+
// reached only after self-protection and the deny floor, so neither can be
|
|
973
|
+
// weakened by it. Everything else uncovered stays gray for the classifier.
|
|
974
|
+
base = user.ignoreTools.includes(toolName)
|
|
975
|
+
? { verdict: "allow", reason: `user ignoreTools passthrough: ${toolName}` }
|
|
976
|
+
: { verdict: "gray", reason: `tool not covered by built-in rules: ${toolName}` };
|
|
950
977
|
}
|
|
951
978
|
if (base.verdict === "deny") return base; // 内置 floor:deny 优先于一切用户规则
|
|
952
979
|
|
|
@@ -1032,7 +1059,6 @@ export type PipelineHost = Pick<ExtensionContext["sessionManager"], "getBranch"
|
|
|
1032
1059
|
/**
|
|
1033
1060
|
* 从会话分支收集精简转录原料:user 消息行与 assistant 工具调用行。
|
|
1034
1061
|
* 丢弃 assistant 叙述/thinking 与 toolResult(注入面与 token 大头)。
|
|
1035
|
-
* 影子缓存的 contextKey 与 buildTranscript 同源(同一批 user 行),保证键与模型输入一致。
|
|
1036
1062
|
*/
|
|
1037
1063
|
function collectTranscriptParts(host: PipelineHost): { userLines: string[]; toolLines: string[] } {
|
|
1038
1064
|
const userLines: string[] = [];
|
|
@@ -1271,126 +1297,7 @@ async function classifyWithModel(
|
|
|
1271
1297
|
}
|
|
1272
1298
|
|
|
1273
1299
|
// ============================================================================
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
// 键设计(#5 定案):
|
|
1277
|
-
// commandKey = hash(toolName + JSON.stringify(input) + cwd) —— 不做命令规范化
|
|
1278
|
-
// contextKey = hash(最近 5 条 sanitized user 行,与 transcript 同源同窗口)
|
|
1279
|
-
// 行为:
|
|
1280
|
-
// 每次灰区裁决前查 would-be 命中;真实模型 allow/deny 回写(LRU 128,上下文变更覆写);
|
|
1281
|
-
// ask 与 fail-closed 不入缓存;命中时对比缓存裁决与本次模型裁决(反事实一致性)。
|
|
1282
|
-
// 永不生效:裁决永远来自模型,此处只记录。
|
|
1283
|
-
// ============================================================================
|
|
1284
|
-
|
|
1285
|
-
const SHADOW_LRU_MAX = 128;
|
|
1286
|
-
|
|
1287
|
-
type ShadowVerdict = "allow" | "deny";
|
|
1288
|
-
interface ShadowEntry {
|
|
1289
|
-
ctxKey: string;
|
|
1290
|
-
verdict: ShadowVerdict;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/** FNV-1a 32 位摘要:仅会话内键用,非密码学 */
|
|
1294
|
-
function fnv1a(s: string): string {
|
|
1295
|
-
let h = 0x811c9dc5;
|
|
1296
|
-
for (let i = 0; i < s.length; i++) {
|
|
1297
|
-
h ^= s.charCodeAt(i);
|
|
1298
|
-
h = Math.imul(h, 0x01000193);
|
|
1299
|
-
}
|
|
1300
|
-
return (h >>> 0).toString(16);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
interface ShadowStats {
|
|
1304
|
-
gray: number; // 灰区裁决总数(含 ask/fail-closed)
|
|
1305
|
-
hits: number; // 双键命中(would-be)
|
|
1306
|
-
missNoEntry: number;
|
|
1307
|
-
missCtx: number;
|
|
1308
|
-
cmdRepeats: number; // 命令键重复(忽略 context 的上界口径)
|
|
1309
|
-
divergeDangerous: number; // 命中且缓存 allow → 模型 deny(若缓存生效会放过本次拦截)
|
|
1310
|
-
divergeConservative: number; // 命中且缓存 deny → 模型 allow
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
type ShadowProbe =
|
|
1314
|
-
| { result: "hit"; entry: ShadowEntry }
|
|
1315
|
-
| { result: "no-entry" }
|
|
1316
|
-
| { result: "ctx-changed"; prevVerdict: ShadowVerdict };
|
|
1317
|
-
|
|
1318
|
-
class ShadowCache {
|
|
1319
|
-
private lru = new Map<string, ShadowEntry>();
|
|
1320
|
-
private seen = new Set<string>();
|
|
1321
|
-
readonly stats: ShadowStats = { gray: 0, hits: 0, missNoEntry: 0, missCtx: 0, cmdRepeats: 0, divergeDangerous: 0, divergeConservative: 0 };
|
|
1322
|
-
|
|
1323
|
-
/** 会话重置:清空 LRU 与统计(#5 定案:会话内存态) */
|
|
1324
|
-
reset(): void {
|
|
1325
|
-
this.lru.clear();
|
|
1326
|
-
this.seen.clear();
|
|
1327
|
-
Object.assign(this.stats, { gray: 0, hits: 0, missNoEntry: 0, missCtx: 0, cmdRepeats: 0, divergeDangerous: 0, divergeConservative: 0 });
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
/** 灰区裁决前置查询(仅遥测,不影响裁决) */
|
|
1331
|
-
probe(commandKey: string, ctxKey: string): ShadowProbe {
|
|
1332
|
-
this.stats.gray++;
|
|
1333
|
-
if (this.seen.has(commandKey)) this.stats.cmdRepeats++;
|
|
1334
|
-
else this.seen.add(commandKey);
|
|
1335
|
-
const entry = this.lru.get(commandKey);
|
|
1336
|
-
if (!entry) {
|
|
1337
|
-
this.stats.missNoEntry++;
|
|
1338
|
-
return { result: "no-entry" };
|
|
1339
|
-
}
|
|
1340
|
-
if (entry.ctxKey !== ctxKey) {
|
|
1341
|
-
this.stats.missCtx++;
|
|
1342
|
-
return { result: "ctx-changed", prevVerdict: entry.verdict };
|
|
1343
|
-
}
|
|
1344
|
-
this.stats.hits++;
|
|
1345
|
-
// LRU 位置刷新,保留原裁决(命中即重放)
|
|
1346
|
-
this.lru.delete(commandKey);
|
|
1347
|
-
this.lru.set(commandKey, entry);
|
|
1348
|
-
return { result: "hit", entry };
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
/** 真实模型 allow/deny 裁决后回写;ask 与 fail-closed 不入 */
|
|
1352
|
-
record(commandKey: string, ctxKey: string, verdict: ShadowVerdict): void {
|
|
1353
|
-
this.lru.delete(commandKey);
|
|
1354
|
-
this.lru.set(commandKey, { ctxKey, verdict });
|
|
1355
|
-
if (this.lru.size > SHADOW_LRU_MAX) {
|
|
1356
|
-
const oldest = this.lru.keys().next().value;
|
|
1357
|
-
if (oldest !== undefined) this.lru.delete(oldest);
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
/** 命中后的反事实一致性计数(仅与可缓存裁决对比;ask/fail-closed 不可比) */
|
|
1362
|
-
countDivergence(cached: ShadowVerdict, actual: ShadowVerdict): void {
|
|
1363
|
-
if (cached === actual) return;
|
|
1364
|
-
if (cached === "allow" && actual === "deny") this.stats.divergeDangerous++;
|
|
1365
|
-
else this.stats.divergeConservative++;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
/** /automode 展示用摘要 */
|
|
1369
|
-
summary(): string {
|
|
1370
|
-
const s = this.stats;
|
|
1371
|
-
if (s.gray === 0) return "shadow cache: no gray-zone verdicts yet this session";
|
|
1372
|
-
const rate = ((100 * s.hits) / s.gray).toFixed(1);
|
|
1373
|
-
return `shadow cache: gray ${s.gray} · two-key hits ${s.hits} (${rate}%) · miss no-entry ${s.missNoEntry}/ctx-changed ${s.missCtx} · cmd repeats ${s.cmdRepeats} · divergence dangerous ${s.divergeDangerous}/conservative ${s.divergeConservative}`;
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
function shadowCommandKey(toolName: string, input: Record<string, unknown>, cwd: string): string {
|
|
1378
|
-
return fnv1a(`${toolName}\u0000${JSON.stringify(input)}\u0000${cwd}`);
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
function shadowContextKey(host: PipelineHost): string {
|
|
1382
|
-
const { userLines } = collectTranscriptParts(host);
|
|
1383
|
-
return fnv1a(userLines.slice(-MAX_USER_MESSAGES).join("\u0000"));
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
function shadowTag(probe: ShadowProbe): string {
|
|
1387
|
-
if (probe.result === "hit") return `(shadow cache: would-hit ${probe.entry.verdict})`;
|
|
1388
|
-
if (probe.result === "ctx-changed") return `(shadow cache: miss:context-changed, previous ${probe.prevVerdict})`;
|
|
1389
|
-
return `(shadow cache: miss:no-entry)`;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
// ============================================================================
|
|
1393
|
-
// Confidence cascade stats (#63/#67: observe-first, session-memory state; the #7 discipline)
|
|
1300
|
+
// Confidence cascade stats (#63/#67: observe-first, session-memory state)
|
|
1394
1301
|
// ============================================================================
|
|
1395
1302
|
|
|
1396
1303
|
interface FallbackStats {
|
|
@@ -1398,14 +1305,15 @@ interface FallbackStats {
|
|
|
1398
1305
|
agreed: number; // fallback verdict equals the first layer's (fail-closed defaults to deny)
|
|
1399
1306
|
overruled: number; // fallback verdict differs (enforce applies it; shadow observes the would-be)
|
|
1400
1307
|
errored: number; // fallback unresolvable or its call failed
|
|
1308
|
+
rescuedAllow: number; // #71: fail-closed origin the fallback ruled allow (enforce: actually allowed; shadow: would) — counted within overruled as well, surfaced distinctly for the rescue reading
|
|
1401
1309
|
}
|
|
1402
1310
|
|
|
1403
1311
|
class FallbackCascade {
|
|
1404
|
-
readonly stats: FallbackStats = { triggered: 0, agreed: 0, overruled: 0, errored: 0 };
|
|
1312
|
+
readonly stats: FallbackStats = { triggered: 0, agreed: 0, overruled: 0, errored: 0, rescuedAllow: 0 };
|
|
1405
1313
|
|
|
1406
|
-
/** Session reset (
|
|
1314
|
+
/** Session reset (session-memory state) */
|
|
1407
1315
|
reset(): void {
|
|
1408
|
-
Object.assign(this.stats, { triggered: 0, agreed: 0, overruled: 0, errored: 0 });
|
|
1316
|
+
Object.assign(this.stats, { triggered: 0, agreed: 0, overruled: 0, errored: 0, rescuedAllow: 0 });
|
|
1409
1317
|
}
|
|
1410
1318
|
|
|
1411
1319
|
note(first: "allow" | "ask" | "deny" | null, fb: "allow" | "ask" | "deny" | null): void {
|
|
@@ -1415,6 +1323,7 @@ class FallbackCascade {
|
|
|
1415
1323
|
return;
|
|
1416
1324
|
}
|
|
1417
1325
|
// A fail-closed origin produced no first-layer verdict; its default outcome is deny
|
|
1326
|
+
if (first === null && fb === "allow") this.stats.rescuedAllow++;
|
|
1418
1327
|
if ((first ?? "deny") !== fb) this.stats.overruled++;
|
|
1419
1328
|
else this.stats.agreed++;
|
|
1420
1329
|
}
|
|
@@ -1423,7 +1332,7 @@ class FallbackCascade {
|
|
|
1423
1332
|
summary(mode: "shadow" | "enforce"): string {
|
|
1424
1333
|
const s = this.stats;
|
|
1425
1334
|
if (s.triggered === 0) return "confidence cascade: not triggered this session";
|
|
1426
|
-
return `confidence cascade (${mode}): triggered ${s.triggered} · agreed ${s.agreed} · ${mode === "enforce" ? "overruled" : "would-overrule"} ${s.overruled} · errored ${s.errored}`;
|
|
1335
|
+
return `confidence cascade (${mode}): triggered ${s.triggered} · agreed ${s.agreed} · ${mode === "enforce" ? "overruled" : "would-overrule"} ${s.overruled} · ${mode === "enforce" ? "rescued-allow" : "would-rescue-allow"} ${s.rescuedAllow} · errored ${s.errored}`;
|
|
1427
1336
|
}
|
|
1428
1337
|
}
|
|
1429
1338
|
|
|
@@ -1473,7 +1382,6 @@ export interface AuditRecord {
|
|
|
1473
1382
|
/** #62: protected-path asks are recorded too — their user answers grade the
|
|
1474
1383
|
* denyPaths rules; rule allow/deny verdicts remain unaudited. */
|
|
1475
1384
|
source: "model" | "fail-closed" | "protected-path";
|
|
1476
|
-
shadow: string;
|
|
1477
1385
|
degraded: boolean;
|
|
1478
1386
|
/** #62 ground truth: the user's answer to an interactive ask confirm. Present only
|
|
1479
1387
|
* on records whose confirm actually ran; headless/degraded asks omit it. */
|
|
@@ -1554,7 +1462,6 @@ export class AuditLog {
|
|
|
1554
1462
|
*/
|
|
1555
1463
|
export class SessionState {
|
|
1556
1464
|
readonly prot: ProtectedSet;
|
|
1557
|
-
readonly shadow = new ShadowCache();
|
|
1558
1465
|
readonly fallback = new FallbackCascade();
|
|
1559
1466
|
userRules: UserRules;
|
|
1560
1467
|
audit: AuditLog | null;
|
|
@@ -1574,12 +1481,11 @@ export class SessionState {
|
|
|
1574
1481
|
}
|
|
1575
1482
|
|
|
1576
1483
|
/** 会话重置:重载用户规则(配置改动新会话生效)+ 按会话 cwd 重锚 denyPaths
|
|
1577
|
-
* (ADR-0002: 每会话锚定一次)
|
|
1484
|
+
* (ADR-0002: 每会话锚定一次);返回加载报告供表现层通知 */
|
|
1578
1485
|
reset(cwd: string): { skipped: string[]; shortcutWarning: string | null } {
|
|
1579
1486
|
const loaded = loadUserRules();
|
|
1580
1487
|
this.userRules = loaded.rules;
|
|
1581
1488
|
this.denyPathBases = anchorDenyPaths(loaded.rules.denyPaths, cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1582
|
-
this.shadow.reset();
|
|
1583
1489
|
this.fallback.reset();
|
|
1584
1490
|
this.audit = this.makeAudit(loaded.rules);
|
|
1585
1491
|
return { skipped: loaded.skipped, shortcutWarning: loaded.shortcutWarning };
|
|
@@ -1604,14 +1510,13 @@ export type VerdictSource = "rule" | "protected-path" | "classifier" | "fail-clo
|
|
|
1604
1510
|
|
|
1605
1511
|
/** 判定管线的输出值对象:一次 tool_call 的完整裁决。detail 为 UI-only 明文(受保护
|
|
1606
1512
|
* 路径仅入本地确认框,ADR-0002 零泄漏承诺——reason 与通知永不携带);degraded 标记
|
|
1607
|
-
* ask 在无 UI
|
|
1513
|
+
* ask 在无 UI 会话的降级产物。 */
|
|
1608
1514
|
export interface Verdict {
|
|
1609
1515
|
verdict: "allow" | "ask" | "deny";
|
|
1610
1516
|
reason: string;
|
|
1611
1517
|
detail?: string;
|
|
1612
1518
|
source: VerdictSource;
|
|
1613
1519
|
degraded: boolean;
|
|
1614
|
-
shadow?: string;
|
|
1615
1520
|
/** #62: pending audit record for an interactive ask — adjudicate defers the append so
|
|
1616
1521
|
* the handler can attach the user's answer after the confirm resolves. The handler
|
|
1617
1522
|
* owns the single finalize: append with userAnswer/answeredAt, or without them when
|
|
@@ -1657,8 +1562,9 @@ interface CascadeResult {
|
|
|
1657
1562
|
* - demotion with no fallback → ask the human
|
|
1658
1563
|
* - shadow → the fallback records its opinion; a demotion still asks the human, a
|
|
1659
1564
|
* fail-closed deny stands
|
|
1660
|
-
* - enforce → the fallback adjudicates de novo, with one carve-out: a demoted
|
|
1661
|
-
* deny may not be
|
|
1565
|
+
* - enforce → the fallback adjudicates de novo, with one carve-out family (#71): a demoted
|
|
1566
|
+
* first-layer deny or ask may not be auto-relaxed to an allow — the human decides; a
|
|
1567
|
+
* fail-closed origin has no first-layer verdict, so any fallback ruling applies
|
|
1662
1568
|
* - fallback failure/unresolvable on a cascaded call → ask the human (the tier that was
|
|
1663
1569
|
* to adjudicate is down); headless degrades downstream */
|
|
1664
1570
|
async function runConfidenceCascade(
|
|
@@ -1698,20 +1604,25 @@ async function runConfidenceCascade(
|
|
|
1698
1604
|
state.fallback.note(first?.verdict ?? null, outcome.verdict);
|
|
1699
1605
|
const fb: FallbackAudit = { ...base, model: resolved.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs: Date.now() - start, error: null };
|
|
1700
1606
|
if (mode === "shadow") return { ...demotedMark, fb, ...shadowApplied };
|
|
1701
|
-
// The
|
|
1702
|
-
//
|
|
1703
|
-
|
|
1704
|
-
|
|
1607
|
+
// The carve-outs on second-layer authority (#71): it may not auto-relax a negative
|
|
1608
|
+
// first-layer verdict — a demoted deny OR ask that the fallback would allow goes to
|
|
1609
|
+
// the human (headless degrades to deny downstream). A fail-closed origin has no
|
|
1610
|
+
// first-layer verdict to relax; its fallback allow is a de novo ruling and stands.
|
|
1611
|
+
if (trigger.kind === "demotion" && (first?.verdict === "deny" || first?.verdict === "ask") && outcome.verdict === "allow") {
|
|
1612
|
+
return { demoted: true, fb: { ...fb, effective: "ask" }, effective: { verdict: "ask", reason: `${outcome.reason} (first layer said ${first!.verdict} at confidence ${trigger.confidence}%; second opinion allows — your call)`, source: "classifier" } };
|
|
1705
1613
|
}
|
|
1706
1614
|
return { ...demotedMark, fb: { ...fb, effective: outcome.verdict }, effective: { verdict: outcome.verdict, reason: outcome.reason, source: "classifier" } };
|
|
1707
1615
|
}
|
|
1708
1616
|
|
|
1709
1617
|
/**
|
|
1710
1618
|
* 判定管线(CONTEXT.md「判定管线」词条的实现):自保护 → 内置 floor → 用户 deny →
|
|
1711
|
-
* denyPaths ask →
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1619
|
+
* denyPaths ask → user allow → gray-zone classifier; ask degradation (no UI → deny)
|
|
1620
|
+
* and fail-closed are built in here — the two formerly duplicated degradation
|
|
1621
|
+
* implementations now live in one place. Zero UI: presentation (notify/confirm/
|
|
1622
|
+
* select) is the handler's job, keyed on source alone (the degraded context is
|
|
1623
|
+
* implicit in the protected-path deny wording); IntegrityWatch (tamper detection)
|
|
1624
|
+
* is a pre-pipeline concern, not part of adjudicate. Exported for tests only
|
|
1625
|
+
* (the internal-seam test surface, the standing #35 pattern).
|
|
1715
1626
|
*/
|
|
1716
1627
|
export async function adjudicate(
|
|
1717
1628
|
state: SessionState,
|
|
@@ -1729,7 +1640,7 @@ export async function adjudicate(
|
|
|
1729
1640
|
// appends immediately. Recording stays observe-only — it never changes a verdict; write
|
|
1730
1641
|
// failures stay fail-soft in the sink and surface once via drainWarning.
|
|
1731
1642
|
const actionLine = toolCallLine(call.toolName, call.input);
|
|
1732
|
-
const buildRecord = (v: Pick<AuditRecord, "verdict" | "reason" | "source" | "degraded">, raw: ClassifierOutcome["auditRaw"] | null
|
|
1643
|
+
const buildRecord = (v: Pick<AuditRecord, "verdict" | "reason" | "source" | "degraded">, raw: ClassifierOutcome["auditRaw"] | null): AuditRecord => ({
|
|
1733
1644
|
ts: new Date().toISOString(),
|
|
1734
1645
|
sessionId: env.host.getSessionId(),
|
|
1735
1646
|
cwd: env.cwd,
|
|
@@ -1740,18 +1651,17 @@ export async function adjudicate(
|
|
|
1740
1651
|
thinking: raw?.thinking ?? null,
|
|
1741
1652
|
transcript: raw?.transcript ?? null,
|
|
1742
1653
|
rawResponse: raw?.rawResponse ?? null,
|
|
1743
|
-
shadow,
|
|
1744
1654
|
...v,
|
|
1745
1655
|
});
|
|
1746
1656
|
|
|
1747
1657
|
if (rule.verdict === "ask") {
|
|
1748
1658
|
// denyPaths 命中 → ask 终局(ADR-0002):声明者本人裁决例外;无 UI 降级为 deny
|
|
1749
1659
|
if (env.hasUI) {
|
|
1750
|
-
const ppRecord: AuditRecord = { ...buildRecord({ verdict: "ask", reason: rule.reason ?? "", source: "protected-path", degraded: false }, null
|
|
1660
|
+
const ppRecord: AuditRecord = { ...buildRecord({ verdict: "ask", reason: rule.reason ?? "", source: "protected-path", degraded: false }, null), detail: rule.detail };
|
|
1751
1661
|
return { verdict: "ask", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: false, ...(state.audit ? { pendingAudit: ppRecord } : {}) };
|
|
1752
1662
|
}
|
|
1753
1663
|
// headless: the ask degrades to deny — recorded like the gray-zone rule (the effective post-degradation verdict is what lands in the record)
|
|
1754
|
-
state.audit?.append({ ...buildRecord({ verdict: "deny", reason: rule.reason ?? "", source: "protected-path", degraded: true }, null
|
|
1664
|
+
state.audit?.append({ ...buildRecord({ verdict: "deny", reason: rule.reason ?? "", source: "protected-path", degraded: true }, null), detail: rule.detail });
|
|
1755
1665
|
return { verdict: "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: true };
|
|
1756
1666
|
}
|
|
1757
1667
|
|
|
@@ -1765,33 +1675,23 @@ export async function adjudicate(
|
|
|
1765
1675
|
// shadow records its opinion and the deny stands
|
|
1766
1676
|
const cascade = await runConfidenceCascade(state, env, null, { kind: "fail-closed" }, state.userRules.denyPaths.length > 0, actionLine);
|
|
1767
1677
|
const eff = cascade.effective;
|
|
1768
|
-
|
|
1678
|
+
// #71: a fail-closed layer emits no negative verdict — its deny is a default, not
|
|
1679
|
+
// a ruling. When an enforcing fallback rescues the call, the record's top level
|
|
1680
|
+
// carries the applied verdict; a shadow rescue (no effective) keeps the deny.
|
|
1681
|
+
const effAskHeadless = eff?.verdict === "ask" && !env.hasUI;
|
|
1682
|
+
const fcRecord = buildRecord({ verdict: eff ? (effAskHeadless ? "deny" : eff.verdict) : "deny", reason: eff?.reason ?? reason, source: "fail-closed", degraded: effAskHeadless }, null);
|
|
1769
1683
|
if (cascade.fb) fcRecord.fallback = cascade.fb;
|
|
1770
1684
|
if (eff?.verdict === "ask" && env.hasUI) {
|
|
1771
1685
|
return { verdict: "ask", reason: eff.reason, source: eff.source, degraded: false, ...(state.audit ? { pendingAudit: fcRecord } : {}) };
|
|
1772
1686
|
}
|
|
1773
1687
|
state.audit?.append(fcRecord);
|
|
1774
1688
|
if (eff?.verdict === "allow") return { verdict: "allow", reason: eff.reason, source: "classifier", degraded: false };
|
|
1775
|
-
if (eff) return { verdict: "deny", reason: eff.reason, source: eff.source, degraded:
|
|
1689
|
+
if (eff) return { verdict: "deny", reason: eff.reason, source: eff.source, degraded: effAskHeadless };
|
|
1776
1690
|
return { verdict: "deny", reason, source: "fail-closed", degraded: false };
|
|
1777
1691
|
}
|
|
1778
1692
|
|
|
1779
|
-
// 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
|
|
1780
|
-
const cmdKey = shadowCommandKey(call.toolName, call.input, env.cwd);
|
|
1781
|
-
const ctxKey = shadowContextKey(env.host);
|
|
1782
|
-
const probe = state.shadow.probe(cmdKey, ctxKey);
|
|
1783
|
-
|
|
1784
1693
|
const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, actionLine, resolved.thinking, state.userRules.denyPaths.length > 0);
|
|
1785
1694
|
|
|
1786
|
-
// 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
|
|
1787
|
-
// 命中且本次为可缓存裁决时,对比反事实一致性
|
|
1788
|
-
if (outcome.source === "model" && outcome.verdict !== "ask") {
|
|
1789
|
-
if (probe.result === "hit") state.shadow.countDivergence(probe.entry.verdict, outcome.verdict);
|
|
1790
|
-
state.shadow.record(cmdKey, ctxKey, outcome.verdict);
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
const shadow = shadowTag(probe);
|
|
1794
|
-
|
|
1795
1695
|
// #67 cascade: a confidence-floor demotion, or a classifier fail-closed outcome
|
|
1796
1696
|
// (the first layer produced no verdict)
|
|
1797
1697
|
const demotion = confidenceDemotion(outcome, state.userRules);
|
|
@@ -1805,21 +1705,25 @@ export async function adjudicate(
|
|
|
1805
1705
|
// #62/#67: top-level keeps first-layer semantics (corpus comparability); the applied
|
|
1806
1706
|
// verdict lives in fallback.effective (enforce rows). Non-interactive asks of any
|
|
1807
1707
|
// origin — native, demoted, escalated — record as their effective deny, the
|
|
1808
|
-
// pre-existing ask-degradation convention.
|
|
1708
|
+
// pre-existing ask-degradation convention. #71 exception: a fail-closed first layer
|
|
1709
|
+
// rescued by an enforcing fallback records the applied verdict at the top level —
|
|
1710
|
+
// a fail-closed layer emits no negative verdict, so its default deny would distort
|
|
1711
|
+
// deny-rate statistics (26 observed rows, 25 actually allowed); shadow rescues keep it.
|
|
1809
1712
|
const appliedAskHeadless = !env.hasUI && effVerdict === "ask";
|
|
1810
|
-
const
|
|
1713
|
+
const fcRescued = cascade.effective !== undefined && outcome.source === "fail-closed";
|
|
1714
|
+
const grayRecord = buildRecord({ verdict: appliedAskHeadless ? "deny" : fcRescued ? effVerdict : outcome.verdict, reason: fcRescued ? effReason : outcome.reason, source: outcome.source, degraded: appliedAskHeadless }, outcome.auditRaw ?? null);
|
|
1811
1715
|
if (cascade.demoted) grayRecord.demoted = true;
|
|
1812
1716
|
if (cascade.fb) grayRecord.fallback = cascade.fb;
|
|
1813
1717
|
// #62: an interactive ask defers the append to the handler finalize (ground truth);
|
|
1814
1718
|
// every other outcome appends immediately as before
|
|
1815
1719
|
if (effVerdict === "ask" && env.hasUI) {
|
|
1816
|
-
return { verdict: "ask", reason: effReason, source: effSource, degraded: false,
|
|
1720
|
+
return { verdict: "ask", reason: effReason, source: effSource, degraded: false, ...(state.audit ? { pendingAudit: grayRecord } : {}) };
|
|
1817
1721
|
}
|
|
1818
1722
|
state.audit?.append(grayRecord);
|
|
1819
|
-
if (effVerdict === "allow") return { verdict: "allow", reason: effReason, source: effSource, degraded: false
|
|
1820
|
-
if (effVerdict === "deny") return { verdict: "deny", reason: effReason, source: effSource, degraded: false
|
|
1723
|
+
if (effVerdict === "allow") return { verdict: "allow", reason: effReason, source: effSource, degraded: false };
|
|
1724
|
+
if (effVerdict === "deny") return { verdict: "deny", reason: effReason, source: effSource, degraded: false };
|
|
1821
1725
|
// ask:无 UI 降级为 deny(ask 降级,CONTEXT.md 词条)
|
|
1822
|
-
return { verdict: "deny", reason: effReason, source: effSource, degraded: true
|
|
1726
|
+
return { verdict: "deny", reason: effReason, source: effSource, degraded: true };
|
|
1823
1727
|
}
|
|
1824
1728
|
|
|
1825
1729
|
// ============================================================================
|
|
@@ -1842,7 +1746,7 @@ export interface AutoModeDeps {
|
|
|
1842
1746
|
export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
1843
1747
|
pi.registerFlag("auto-mode", { description: "Enable Auto Mode (rules + model classifier gating for tool calls)", type: "boolean", default: true });
|
|
1844
1748
|
pi.registerFlag("auto-mode-model", { description: "Classifier model as provider/id[:thinking] (pi --model syntax; default: inherit session model)", type: "string" });
|
|
1845
|
-
pi.registerFlag("auto-mode-debug", { description: "Notify every verdict incl. allows
|
|
1749
|
+
pi.registerFlag("auto-mode-debug", { description: "Notify every verdict incl. allows", type: "boolean", default: false });
|
|
1846
1750
|
|
|
1847
1751
|
let enabled = pi.getFlag("auto-mode") !== false;
|
|
1848
1752
|
const debug = pi.getFlag("auto-mode-debug") === true || process.env.PI_AUTO_MODE_DEBUG === "1";
|
|
@@ -1857,19 +1761,21 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1857
1761
|
return { block: true, reason: blockedReason("tamper", r.reason) };
|
|
1858
1762
|
}
|
|
1859
1763
|
|
|
1860
|
-
/** Verdict → UI(
|
|
1861
|
-
*
|
|
1764
|
+
/** Verdict → UI (the extension's single presentation point): presentation keys on
|
|
1765
|
+
* source alone — the degraded context is implicit in the protected-path deny
|
|
1766
|
+
* wording. Byte-identical with the pre-refactor wording; protected-path
|
|
1767
|
+
* notifications never carry path plaintext or the action line
|
|
1862
1768
|
* (ADR-0002 story 11:通知与 block reason 回流 agent context)。 */
|
|
1863
1769
|
async function presentVerdict(v: Verdict, action: string, ctx: ExtensionContext): Promise<{ block: true; reason: string } | undefined> {
|
|
1864
1770
|
if (v.verdict === "allow") {
|
|
1865
1771
|
// #60 (CONTEXT.md 通知): classifier allows surface via notifyAllows OR
|
|
1866
|
-
// debug — exactly one notification either way;
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1772
|
+
// debug — exactly one notification either way; mechanical passes
|
|
1773
|
+
// (rule echo, protected-path confirm) stay silent — notifications carry
|
|
1774
|
+
// judgment, the audit log carries completeness
|
|
1869
1775
|
if (debug) {
|
|
1870
1776
|
if (v.source === "rule") ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
|
|
1871
1777
|
else if (v.source === "protected-path") ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
|
|
1872
|
-
else ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}
|
|
1778
|
+
else ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}`, "info");
|
|
1873
1779
|
} else if (state.userRules.notifyAllows && v.source === "classifier") {
|
|
1874
1780
|
ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}`, "info");
|
|
1875
1781
|
}
|
|
@@ -1889,7 +1795,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1889
1795
|
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
|
|
1890
1796
|
return { block: true, reason: blockedReason("rule", v.reason) };
|
|
1891
1797
|
}
|
|
1892
|
-
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}
|
|
1798
|
+
ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
|
|
1893
1799
|
return { block: true, reason: blockedReason("classifier", v.reason) };
|
|
1894
1800
|
}
|
|
1895
1801
|
// ask → 人工确认;非交互已在管线内降级,能走到这里的必有 UI
|
|
@@ -1918,7 +1824,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1918
1824
|
refreshStatus(ctx);
|
|
1919
1825
|
}
|
|
1920
1826
|
|
|
1921
|
-
// session_start
|
|
1827
|
+
// session_start:重置级联计数(会话内存态)+ 重载用户规则(配置改动新会话生效)
|
|
1922
1828
|
// + 重建自保护基线(ADR-0001:受保护文件的会话启动快照)
|
|
1923
1829
|
pi.on("session_start", async (_event, ctx) => {
|
|
1924
1830
|
const report = state.reset(ctx.cwd);
|
|
@@ -1951,15 +1857,21 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1951
1857
|
/** Status line audit hint (#54): shown only while the sink is active */
|
|
1952
1858
|
const auditHint = () => (state.audit ? `\naudit: on → ${state.audit.dir}` : "");
|
|
1953
1859
|
/** Status line cascade hint (#63/#67): shown while the floor or the fallback is configured */
|
|
1954
|
-
const fallbackHint = () =>
|
|
1860
|
+
const fallbackHint = () => {
|
|
1861
|
+
if (state.userRules.classifierMinConfidence === null && !state.userRules.classifierFallbackModel) return "";
|
|
1862
|
+
const shadowNote = state.userRules.classifierFallbackModel && state.userRules.classifierFallbackMode === "shadow"
|
|
1863
|
+
? "\nsecond layer is shadow (records only, never applies) — set classifierFallbackMode to \"enforce\" to activate it"
|
|
1864
|
+
: "";
|
|
1865
|
+
return `\n${state.fallback.summary(state.userRules.classifierFallbackMode)}${shadowNote}`;
|
|
1866
|
+
};
|
|
1955
1867
|
|
|
1956
1868
|
pi.registerCommand("automode", {
|
|
1957
|
-
description: "Show Auto Mode status
|
|
1869
|
+
description: "Show Auto Mode status, or set it: /automode on|off",
|
|
1958
1870
|
handler: async (args, ctx) => {
|
|
1959
1871
|
const arg = args.trim().toLowerCase();
|
|
1960
|
-
// 裸调用:只读状态展示,无副作用
|
|
1872
|
+
// 裸调用:只读状态展示,无副作用
|
|
1961
1873
|
if (arg === "") {
|
|
1962
|
-
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${
|
|
1874
|
+
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${denyPathsHint()}${auditHint()}${fallbackHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1963
1875
|
return;
|
|
1964
1876
|
}
|
|
1965
1877
|
// 幂等设定:与现值相同不翻转,仅确认
|
|
@@ -1970,7 +1882,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1970
1882
|
const head = next
|
|
1971
1883
|
? `🛡️ Auto Mode enabled${changed ? "" : " (unchanged)"}: tool calls adjudicated by rules + classifier`
|
|
1972
1884
|
: `Auto Mode disabled${changed ? "" : " (unchanged)"}: tool calls execute directly`;
|
|
1973
|
-
ctx.ui.notify(`${head}\n${
|
|
1885
|
+
ctx.ui.notify(`${head}\n${fallbackHint()}`, "info");
|
|
1974
1886
|
return;
|
|
1975
1887
|
}
|
|
1976
1888
|
// 未知参数:严格拒绝并列出用法(大小写已归一化)
|
|
@@ -2092,7 +2004,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
2092
2004
|
}
|
|
2093
2005
|
}
|
|
2094
2006
|
|
|
2095
|
-
// 判定管线(零 UI)→ 呈现(source
|
|
2007
|
+
// 判定管线(零 UI)→ 呈现(source 模板)
|
|
2096
2008
|
const verdict = await adjudicate(state, { toolName: event.toolName, input }, {
|
|
2097
2009
|
cwd: ctx.cwd,
|
|
2098
2010
|
hasUI: !!ctx.hasUI,
|