pi-verdict 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@
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
15
+ - Self-protection: the gate guards itself against snooping and tampering
15
16
 
16
17
  ## The problem
17
18
 
@@ -23,10 +24,15 @@ pi-verdict adds the missing gate: a model decides whether each call should run,
23
24
 
24
25
  **verdict is an adjudication, not a switch.** Most classifiers in this space output a binary allow/block. Three states matter: `ask` routes genuinely ambiguous actions to a human (and degrades to `deny` in non-interactive sessions), so "not sure" never silently becomes "go ahead".
25
26
 
27
+ ## Screenshots
28
+
29
+ ![Automode Status](docs/images/status.png)
30
+ ![Ask Permission](docs/images/asked.png)
31
+
26
32
  ## Quick start
27
33
 
28
34
  ```bash
29
- # install from npm (listed in the package catalog: https://pi.dev/packages/pi-verdict)
35
+ # install from npm:
30
36
  pi install npm:pi-verdict
31
37
 
32
38
  # or directly from git — try it once
@@ -62,15 +68,15 @@ pi --extension ./extensions/auto-mode.ts
62
68
  ```
63
69
 
64
70
  - `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
65
- - matched against the **full command string** for bash, the **absolute path** for file tools (read/write/edit/grep/find/ls); other tools (MCP etc.) always go to the classifier
66
- - `denyPaths` are plain paths (not regexes) you declare **protected** ([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)): any tool call touching them — file tools via their path, bash via path tokens extracted from the command string — triggers a **terminal ask** you adjudicate (non-interactive sessions degrade to deny). The tool owns normalization: `~`, `$HOME/`, relative, `..` and symlink spellings all resolve, compared per path segment. Priority: after your `deny` rules, **before your `allow` rules** (not even your own allowlist may touch these), and not affected by `builtinDenyFloor: false` — it is your declaration, not a built-in claim. The classifier only ever learns that protected paths *exist* (a fixed system-prompt hint to judge copy-then-read/archiving/indirection strictly); the paths themselves never leave your machine, and a hit never reaches the classifier at all — the matched path shows **only** in the local confirm dialog; block reasons and notifications carry none (they return into the agent context). Entries are normalized once at session start, anchored to the session cwd — mid-session symlink creation or cwd drift does not change what the declaration covers. When S0 secrets paths deny outright while your `denyPaths` merely ask, that asymmetry is deliberate: the exception to a *user-declared* path belongs to the user; S0 is an author-vetted set (see the ADR)
71
+ - `denyPaths` are plain paths (not regexes) you declare **protected**: any tool call touching them — file tools via their path, bash via path tokens extracted from the command string — triggers a **terminal ask** you adjudicate (non-interactive sessions degrade to deny). Not affected by `builtinDenyFloor: false`.
72
+ The classifier only ever learns that protected paths *exist*; the paths themselves never leave your machine, and a matched path shows **only** in the local confirm dialog.
67
73
  - `builtinDenyFloor: false` turns the built-in danger/path floor off entirely (risk accepted by you; the classifier and your rules remain — the self-protection layer below always stays on)
68
74
  - `classifierModel: "provider/model-id"` sets the classifier model (e.g. a fast flash-class model); precedence is flag > env > config > session model (self-reflection); an invalid value falls back to the session model with a one-time warning
69
- - the spec accepts pi's native `--model` thinking suffix: `"zai/glm-5.3-flash:low"` sets classifier thinking to effort low (default without suffix: thinking explicitly off — the [measured](research/thinking-param-blackhole.md) default)
70
- - `toggleShortcut` rebinds the master-switch toggle key (any pi key combo, e.g. `ctrl+shift+x`; `null` or empty disables the shortcut; an invalid combo warns once at session start and skips registration). The toggle is semantically identical to `/automode on|off` — works mid-run, no confirmation, never persisted (persistence stays with the `--no-auto-mode` flag; the extension never writes its own protected config)
71
- - first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`); changes apply to new sessions
75
+ - the spec accepts pi's native `--model` thinking suffix: `"zai/glm-5.3-flash:low"` sets classifier thinking to effort low (default without suffix: thinking explicitly off)
76
+ - `toggleShortcut` rebinds the master-switch toggle key (`null` or empty disables it, not persisted)
77
+ - first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`)
72
78
 
73
- **Why no built-in allowlist?** Bypass testing of the rule layer ([writeup](research/rule-layer-security-audit.md)) showed that allowlist soundness requires shell AST analysis — every built-in "always allow" would be a security claim maintained by the author. The built-in layer only makes **deny** claims (the sound direction); allow claims are yours.
79
+ **Why no built-in allowlist?** Bypass testing of the rule layer ([writeup](research/rule-layer-security-audit.md)) showed that allowlist robustness is very limited. The built-in layer only makes **deny** claims (the sound direction); allow claims are yours.
74
80
 
75
81
  ### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
76
82
 
@@ -115,11 +121,13 @@ tool_call
115
121
  │ config changed + interactive → one keep/restore confirm
116
122
 
117
123
  ├─ 1. Rule layer (deterministic, zero latency)
118
- │ ├─ built-in deny floor: bash danger regexes (full-string) +
119
- │ │ path sensitivity S0–S5 (secrets/system/.git meta → deny)
124
+ │ ├─ built-in deny floor: bash danger regexes (full-string, capped at 8192 chars) +
125
+ │ │ path sensitivity S0–S5 (secrets/system/.git meta → deny;
126
+ │ │ dual-form matching — lexical + realpath, symlink aliases resolve)
120
127
  │ ├─ your rules: user deny beats user allow (regex, see below)
121
128
  │ ├─ denyPaths (ADR-0002): user-declared protected paths, tool-owned
122
- │ │ normalization (~, $HOME, relative, .., symlink) terminal ask,
129
+ │ │ normalization (~, $HOME, relative, .., symlink, case on
130
+ │ │ macOS/Windows) → terminal ask,
123
131
  │ │ before user allow; classifier sees an existence hint only
124
132
  │ └─ no built-in allowlist — every "always allow" claim is yours to make
125
133
 
@@ -142,7 +150,7 @@ tool_call
142
150
  replays a double-key LRU(128) to measure would-be hit rate
143
151
  ```
144
152
 
145
- **fail-closed**: classifier exception / timeout (15s) / contract violation → deny. Never silently allow.
153
+ **fail-closed**: classifier exception / timeout (25s) / contract violation → deny. Never silently allow.
146
154
 
147
155
  ## Evidence-driven, not vibes-driven
148
156
 
@@ -158,9 +166,9 @@ Design decisions here are settled by measurement, and the lab notes ship with th
158
166
 
159
167
  ## Status & limitations
160
168
 
161
- Prototype quality — usable, not hardened:
162
-
163
169
  - 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
170
+ - 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)
171
+ - 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)
164
172
  - AGENTS.md is not passed to the classifier as downweighted intent evidence (Claude Code does this)
165
173
  - parallel gray-zone calls are adjudicated serially
166
174
  - 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)
@@ -179,7 +187,7 @@ The name: the three-state **verdict** is the core concept. The UX keeps `/automo
179
187
  ```bash
180
188
  bun install
181
189
  bun run typecheck
182
- bun test # 91 offline stub tests: self-protection, tamper detection, deny floor, user rules, denyPaths, bypass regression, classifier retry, shadow cache, commands, toggle shortcut
190
+ bun test # offline stub tests: self-protection, tamper detection, deny floor, user rules, denyPaths, bypass regression, classifier retry, shadow cache, commands, toggle shortcut
183
191
  ```
184
192
 
185
193
  Issue tracker and decision records live in the GitHub issues ("map" issue #1 indexes them).
package/README.zh-CN.md CHANGED
@@ -6,12 +6,13 @@
6
6
  [![npm](https://img.shields.io/npm/v/pi-verdict)](https://www.npmjs.com/package/pi-verdict)
7
7
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://pi.dev)
8
8
 
9
- **pi-verdict 是 [pi](https://pi.dev) 的 Claude Code Auto mode 式的极简权限门禁:每次工具调用执行前先过检查——放行、拦截,或先问你。**
9
+ **pi-verdict 是 [pi](https://pi.dev) 的 Claude Code 风格的 Auto mode 式的极简权限门禁:每次工具调用执行前先过检查——放行、拦截,或先问你。**
10
10
 
11
11
  - 只有几百行的极简代码
12
12
  - 内置危险规则与你的 allow/deny 规则以零延迟先行裁决明确情形
13
13
  - 其余交给携带会话上下文的模型分类器
14
14
  - 任何不确定或失败一律 fail-closed, 绝不静默放行
15
+ - 自我保护: 防止被窥探和篡改
15
16
 
16
17
  ## 问题
17
18
 
@@ -23,10 +24,15 @@ pi-verdict 补上这道缺失的门禁, 由模型基于上下文和你的意图
23
24
 
24
25
  **verdict 是裁决,不是开关。** 本品类的分类器大多只输出二值 allow/block。三态有意义的地方在:`ask` 把真正含糊的动作转交人类确认(非交互会话中降级为 `deny`),「不确定」永远不会静默变成「放行」。
25
26
 
27
+ ## 截图
28
+
29
+ ![Automode Status](docs/images/status.png)
30
+ ![Ask Permission](docs/images/asked.png)
31
+
26
32
  ## 快速开始
27
33
 
28
34
  ```bash
29
- # 从 npm 安装(已收录 pi 官方包目录: https://pi.dev/packages/pi-verdict)
35
+ # 从 npm 安装:
30
36
  pi install npm:pi-verdict
31
37
 
32
38
  # 或直接从源码 —— 试用一次
@@ -62,15 +68,15 @@ pi --extension ./extensions/auto-mode.ts
62
68
  ```
63
69
 
64
70
  - `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
65
- - 匹配目标:bash = **完整命令串**;文件类工具(read/write/edit/grep/find/ls)= **绝对路径**;其余工具(MCP 等)恒走分类器
66
- - `denyPaths` 是你声明**受保护**的普通路径列表(非正则,[ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):任何触碰它们的工具调用——文件类工具取其路径、bash 从命令串提取路径 token——触发**终局 ask**,由你裁决(非交互会话降级 deny)。归一化由工具负责:`~`、`$HOME/`、相对、`..`、symlink 拼写全部消解,按路径段前缀比对。优先级:在你的 `deny` 规则之后、**`allow` 规则之前**(连你自己的白名单也不得触碰),且不受 `builtinDenyFloor: false` 影响——这是你的声明而非内置声明。分类器只被告知受保护路径**存在**(固定 system prompt 话术,对先拷贝再读取/打包/间接引用从紧裁决);路径明文永不出本机,命中调用根本到不了分类器——命中的路径**只**出现在本地确认弹窗;阻断理由与通知不含明文(它们回流进 agent 上下文)。条目在会话启动时一次性归一化,锚定会话 cwd——会话中途新建 symlink 或 cwd 漂移不改变声明覆盖范围。S0 机密路径直接 deny 而你的 `denyPaths` 仅 ask 的不对称是刻意的:用户声明的例外归用户,S0 是作者审定集(见 ADR)
71
+ - `denyPaths` 是你声明**受保护**的普通路径列表(非正则):任何触碰它们的工具调用——文件类工具取其路径、bash 从命令串提取路径 token——触发**终局 ask**,由你裁决(非交互会话降级 deny)。不受 `builtinDenyFloor: false` 影响
72
+ 分类器只被告知受保护路径**存在**,路径明文永不出本机, 命中的路径**只**出现在本地确认弹窗。
67
73
  - `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在——下方自保护层永远开启)
68
74
  - `classifierModel: "provider/model-id"` 指定分类器模型(如轻量 flash 类);优先级 flag > env > config > 自省;无效值回退会话模型并一次性警告
69
- - spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-5.3-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考,[实测](research/thinking-param-blackhole.md)背书的默认)
70
- - `toggleShortcut` 重绑主开关快捷键(任意 pi 键组合,如 `ctrl+shift+x`;`null` 或空串禁用;非法组合在会话启动时一次性警告并跳过注册)。快捷键与 `/automode on|off` **语义等价**——运行中生效、无确认弹窗、不持久化(持久需求由 `--no-auto-mode` flag 承担;扩展运行时从不写自己的受保护配置)
71
- - 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`);修改后新会话生效
75
+ - spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-5.3-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考)
76
+ - `toggleShortcut` 重绑主开关快捷键(`null` 或空串禁用, 非持久化)
77
+ - 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`)
72
78
 
73
- **为什么没有内置白名单?**对规则层的绕过测试(见 [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健全性需要 shell AST 分析——每条内置「永远放行」都是作者维护的安全声明。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
79
+ **为什么没有内置白名单?**对规则层的绕过测试(见 [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健壮性非常有限。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
74
80
 
75
81
  ### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
76
82
 
@@ -115,10 +121,11 @@ tool_call
115
121
  │ 仅配置被改且有 UI → 一次保留/还原确认
116
122
 
117
123
  ├─ 1. 规则层(确定性,零延迟)
118
- │ ├─ 内置 deny floor:bash 危险正则(完整命令串)+ 路径敏感度 S0–S5
124
+ │ ├─ 内置 deny floor:bash 危险正则(完整命令串,截断上限 8192 字符)+ 路径敏感度 S0–S5
125
+ │ │ (双形匹配 —— 词法 + realpath,符号链接别名会被解析)
119
126
  │ ├─ 用户规则:deny 优先于 allow(正则,见下)
120
127
  │ ├─ denyPaths(ADR-0002):用户声明的受保护路径,工具负责归一化
121
- │ │ (~、$HOME、相对、..、symlink)→ 终局 ask,先于用户 allow;
128
+ │ │ (~、$HOME、相对、..、symlink、macOS/Windows 大小写)→ 终局 ask,先于用户 allow;
122
129
  │ │ 分类器只见存在性话术
123
130
  │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
124
131
 
@@ -140,7 +147,7 @@ tool_call
140
147
  回放双键 LRU(128)测量 would-be 命中率
141
148
  ```
142
149
 
143
- **fail-closed**:分类器异常 / 超时(15s)/ 输出违反契约 → 拦截,绝不静默放行。
150
+ **fail-closed**:分类器异常 / 超时(25s)/ 输出违反契约 → 拦截,绝不静默放行。
144
151
 
145
152
  ## 证据驱动,不靠直觉
146
153
 
@@ -156,9 +163,9 @@ tool_call
156
163
 
157
164
  ## 状态与限制
158
165
 
159
- 原型质量 —— 可用,未硬化:
160
-
161
166
  - 设计上无内置白名单(见[绕过测试](research/rule-layer-security-audit.md)与[用户规则](#用户规则configpi-verdictjson));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
167
+ - 路径敏感度 floor 只作用于文件类工具:bash 命令串仅匹配危险正则——`cat ~/.ssh/id_rsa` 走分类器而非确定性 S0 拦截(文件工具拼写 `read ~/.ssh/id_rsa` 会拦截)
168
+ - Windows 下内置 floor 仅覆盖 bash 形态模式——PowerShell 原生危险命令(`Remove-Item -Recurse -Force`、`Invoke-Expression`、`Set-ExecutionPolicy` 等)依赖分类器兜底(fail-closed)
162
169
  - AGENTS.md 未作为降权意图证据传入分类器(Claude Code 有此设计)
163
170
  - 并行灰区调用串行裁决
164
171
  - 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
@@ -177,7 +184,7 @@ tool_call
177
184
  ```bash
178
185
  bun install
179
186
  bun run typecheck
180
- bun test # 91 个离线桩测试:自保护 / 变更检测 / deny floor / 用户规则 / denyPaths / 绕过回归 / 分类器重试 / 影子缓存 / 命令 / toggle 快捷键
187
+ bun test # 离线桩测试:自保护 / 变更检测 / deny floor / 用户规则 / denyPaths / 绕过回归 / 分类器重试 / 影子缓存 / 命令 / toggle 快捷键
181
188
  ```
182
189
 
183
190
  Issue tracker 与决策记录在 GitHub issues(「地图」issue #1 为索引)。
@@ -118,11 +118,17 @@ interface RuleResult {
118
118
  detail?: string;
119
119
  }
120
120
 
121
+ /** Cap the danger-regex matching input (#25): the prefix-consuming character
122
+ * classes plus nested alternations can backtrack quadratically on very long
123
+ * separator-free strings. Beyond the cap, rule matching is lost and the call
124
+ * falls to the classifier (fail-closed direction). */
125
+ export const BASH_MAX_MATCH_LEN = 8192;
126
+
121
127
  function classifyBash(command: string, floorOn: boolean): RuleResult {
122
- // 内置 deny floor:危险正则对完整命令串匹配;可经 builtinDenyFloor 整体关闭
123
128
  if (floorOn) {
129
+ const capped = command.length > BASH_MAX_MATCH_LEN ? command.slice(0, BASH_MAX_MATCH_LEN) : command;
124
130
  for (const rule of BASH_DANGER_RULES) {
125
- if (rule.pattern.test(command)) return { verdict: "deny", reason: `rule ${rule.id}: ${rule.reason}` };
131
+ if (rule.pattern.test(capped)) return { verdict: "deny", reason: `rule ${rule.id}: ${rule.reason}` };
126
132
  }
127
133
  }
128
134
  if (!command.trim()) return { verdict: "allow", reason: "empty command" };
@@ -207,7 +213,7 @@ function userConfigPath(): string {
207
213
  }
208
214
 
209
215
  const USER_CONFIG_TEMPLATE = `${JSON.stringify({
210
- _hint: "pi-verdict user rules. allow/deny are JS regex arrays; deny wins over allow. Match target: bash = full command string, file tools = absolute path. denyPaths is a list of protected path prefixes (plain paths, not regexes; the tool owns normalization — ~, $HOME, relative, .. and symlink forms all resolve — and any access attempt, including from bash command strings, asks for your confirmation, degrading to deny in non-interactive sessions; priority: after your deny rules, before your allow rules; never sent to the classifier). builtinDenyFloor=false disables the built-in danger/path floor (at your own risk; the self-protection layer always stays on and cannot be turned off by any config). classifierModel persistently sets the classifier model (provider/id, e.g. zai/glm-5.3-flash; accepts a pi-native thinking suffix, e.g. zai/glm-5.3-flash:low; empty = self-reflection, inherit session model). toggleShortcut sets the master-switch toggle key (pi key combo, e.g. ctrl+shift+a; null or empty disables the shortcut). This file is part of the permission gate itself: pi-verdict denies any agent-side modification of it — edit it manually outside pi. Changes apply to new sessions.",
216
+ _hint: "pi-verdict user rules. allow/deny are JS regex arrays; deny wins over allow. Match target: bash = full command string, file tools = absolute path. denyPaths is a list of protected path prefixes (plain paths, not regexes; the tool owns normalization — ~, $HOME, relative, .. and symlink forms all resolve, case folds on macOS/Windows — and any access attempt, including from bash command strings, asks for your confirmation, degrading to deny in non-interactive sessions; priority: after your deny rules, before your allow rules; never sent to the classifier). builtinDenyFloor=false disables the built-in danger/path floor (at your own risk; the self-protection layer always stays on and cannot be turned off by any config). classifierModel persistently sets the classifier model (provider/id, e.g. zai/glm-5.3-flash; accepts a pi-native thinking suffix, e.g. zai/glm-5.3-flash:low; empty = self-reflection, inherit session model). toggleShortcut sets the master-switch toggle key (pi key combo, e.g. ctrl+shift+a; null or empty disables the shortcut). This file is part of the permission gate itself: pi-verdict denies any agent-side modification of it — edit it manually outside pi. Changes apply to new sessions.",
211
217
  allow: ["^ls\\b"],
212
218
  deny: [],
213
219
  denyPaths: [],
@@ -231,7 +237,15 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
231
237
  } catch { /* 只读环境静默跳过 */ }
232
238
  return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
233
239
  }
234
- const raw = JSON.parse(fs.readFileSync(p, "utf8")) as { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown };
240
+ let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown };
241
+ try {
242
+ raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
243
+ } catch (err) {
244
+ // Invalid config never silently disables the gate (#25): a parse failure
245
+ // loads empty user rules (the floor and self-protection layer stay on)
246
+ // and reports through the session_start skip channel, same as invalid regexes
247
+ return { rules: EMPTY_RULES, skipped: [`config parse failed: ${err instanceof Error ? err.message : String(err)} — user rules not loaded (${p})`], shortcutWarning: null };
248
+ }
235
249
  const skipped: string[] = [];
236
250
  const compile = (list: unknown): RegExp[] =>
237
251
  (Array.isArray(list) ? list : []).filter((x): x is string => typeof x === "string").flatMap((src) => {
@@ -277,21 +291,57 @@ function expandHome(p: string): string {
277
291
  return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
278
292
  }
279
293
 
294
+ // All S-rules match case-insensitively (#21): on case-insensitive filesystems
295
+ // (default macOS APFS, Windows) case variants name the same file — realpath
296
+ // normalization covers existing targets, /i covers the lexical forms of
297
+ // nonexistent ones; on linux the uppercase spelling usually does not exist and
298
+ // the occasional false positive fails toward deny (safe direction).
280
299
  const S0_SECRET = [
281
- /\.ssh(\/|$)/, /\.aws(\/|$)/, /\.gnupg(\/|$)/, /(^|\/)\.env(\.|$)/, /credentials?(\.|\/|$)/i,
282
- /(^|\/)id_rsa/, /\.pem$/, /_history$/, /\.config\/gh(\/|$)/, /\.pi\/agent\/auth\.json$/,
300
+ /\.ssh(\/|$)/i, /\.aws(\/|$)/i, /\.gnupg(\/|$)/i, /(^|\/)\.env(\.|$)/i, /credentials?(\.|\/|$)/i,
301
+ /(^|\/)id_rsa/i, /\.pem$/i, /_history$/i, /\.config\/gh(\/|$)/i, /\.pi\/agent\/auth\.json$/i,
283
302
  // V8(安全审计):常见明文凭证文件补全
284
- /(^|\/)\.netrc$/, /(^|\/)\.npmrc$/, /(^|\/)\.pypirc$/, /(^|\/)\.envrc$/, /(^|\/)\.vault-token$/,
285
- /\.kube(\/|$)/, /\.docker\/config\.json$/, /\.gem\/credentials$/,
303
+ /(^|\/)\.netrc$/i, /(^|\/)\.npmrc$/i, /(^|\/)\.pypirc$/i, /(^|\/)\.envrc$/i, /(^|\/)\.vault-token$/i,
304
+ /\.kube(\/|$)/i, /\.docker\/config\.json$/i, /\.gem\/credentials$/i,
286
305
  ];
287
- const S1_SYSTEM = [/^\/etc(\/|$)/, /^\/usr(\/|$)/, /^\/var(\/|$)/, /^\/System(\/|$)/, /(^|\/)authorized_keys$/];
288
- const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/, /crontab/, /Library\/LaunchAgents(\/|$)/, /\.config\/systemd(\/|$)/];
289
- const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/, /(^|\/)\.gitmodules$/];
306
+ // /private prefixes: macOS firmlinks — /etc, /var are really /private/etc,
307
+ // /private/var, and realpath'd toolchain output uses the real spelling (#21)
308
+ const S1_SYSTEM = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$)/i, /^\/var(\/|$)/i, /^\/System(\/|$)/i, /(^|\/)authorized_keys$/i];
309
+ const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/i, /crontab/i, /Library\/LaunchAgents(\/|$)/i, /\.config\/systemd(\/|$)/i];
310
+ const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/i, /(^|\/)\.gitmodules$/i];
311
+
312
+ /**
313
+ * All canonical forms of a path for rule matching: the lexical absolute plus,
314
+ * whenever an existing ancestor can be resolved, the form rebuilt from that
315
+ * ancestor's realpath. Read and write targets may both not exist yet — walking
316
+ * up to the nearest existing ancestor means a symlink alias exposes its real
317
+ * form even when the final segments do not exist (#20).
318
+ */
319
+ function targetForms(abs: string): string[] {
320
+ const out = new Set<string>([abs]);
321
+ let dir = abs;
322
+ const tail: string[] = [];
323
+ for (;;) {
324
+ try {
325
+ const real = fs.realpathSync(dir);
326
+ out.add(path.join(real, ...tail));
327
+ return [...out];
328
+ } catch {
329
+ const parent = path.dirname(dir);
330
+ if (parent === dir) return [...out];
331
+ tail.unshift(path.basename(dir));
332
+ dir = parent;
333
+ }
334
+ }
335
+ }
290
336
 
291
337
  /** read 类工具:S0 读取即高危(deny),其余读取放行。isWrite: write/edit 走完整分级 */
292
338
  function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: boolean, floorOn: boolean): RuleResult {
293
339
  const abs = path.resolve(cwd, expandHome(rawPath));
294
- const hit = (rules: RegExp[]) => rules.some((r) => r.test(abs));
340
+ // Dual-form matching (#20): rules test every canonical form of the target —
341
+ // a project-local symlink aliasing ~/.ssh or a .git/hooks dir must not pass
342
+ // the floor on its lexical spelling alone.
343
+ const forms = targetForms(abs);
344
+ const hit = (rules: RegExp[]) => forms.some((f) => rules.some((r) => r.test(f)));
295
345
  // floor 关闭时:内置 deny 一律降级 gray(永不升格 allow);非 deny 分支(allow/gray)保持
296
346
  const D = floorOn
297
347
  ? (reason: string): RuleResult => ({ verdict: "deny", reason })
@@ -305,7 +355,12 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
305
355
  if (hit(S1_SYSTEM)) return D(`write to system directory: ${rawPath}`);
306
356
  if (hit(S3_GIT_META)) return D(`write to .git metadata (executable code entry point): ${rawPath}` );
307
357
  if (hit(S2_USER_RC)) return { verdict: "gray", reason: `write to user config/persistence entry point: ${rawPath}` };
308
- if (abs === cwd || abs.startsWith(cwd + path.sep)) return { verdict: "allow" };
358
+ // In-cwd write allowance (#20): every canonical form must sit inside the cwd
359
+ // (in either its lexical or real form) — a lexical prefix hit whose real
360
+ // form escapes the project (symlink alias) grades as an outside-cwd write.
361
+ const cwdBases = new Set([cwd, tryRealpath(cwd)]);
362
+ const inCwd = (f: string) => [...cwdBases].some((b) => f === b || f.startsWith(b + path.sep));
363
+ if (forms.every(inCwd)) return { verdict: "allow" };
309
364
  return { verdict: "gray", reason: `write outside project directory (CWD): ${rawPath}` };
310
365
  }
311
366
 
@@ -362,6 +417,14 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
362
417
  const BASH_PATH_TOKENS =
363
418
  /(?:~|\$HOME)(?:\/[\w.@*-]+)*|\/(?:[\w.@*-]+\/)*[\w.@*-]*|\.{1,2}(?:\/[\w.@*-]+)+|[\w.-]+(?:\/[\w.-]+)+/g;
364
419
 
420
+ /** Case-insensitive filesystems (default macOS APFS, Windows) compare path strings
421
+ * case-folded; realpath already normalizes case whenever it resolves, this covers
422
+ * the lexical-only forms of nonexistent targets (#21). Linux stays case-sensitive. */
423
+ const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
424
+ const fold = (s: string): string => (CASE_INSENSITIVE_FS ? s.toLowerCase() : s);
425
+ const pathEquals = (a: string, b: string): boolean => fold(a) === fold(b);
426
+ const pathStartsWith = (child: string, base: string): boolean => fold(child).startsWith(fold(base) + path.sep);
427
+
365
428
  /** Normalized forms of one path (lexical + realpath when it exists) for denyPaths comparison */
366
429
  function denyPathForms(raw: string, cwd: string): string[] {
367
430
  if (!raw) return [];
@@ -393,7 +456,7 @@ function hitDenyPaths(toolName: string, input: Record<string, unknown>, cwd: str
393
456
  for (const candidate of denyPathCandidates(toolName, input)) {
394
457
  for (const c of denyPathForms(candidate, cwd)) {
395
458
  for (const b of bases) {
396
- if (c === b || c.startsWith(b + path.sep)) return b;
459
+ if (pathEquals(c, b) || pathStartsWith(c, b)) return b;
397
460
  }
398
461
  }
399
462
  }
@@ -467,9 +530,48 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
467
530
  // 安装副本目标:单文件形态 → 文件本体(exact);npm 目录形态 → 包根目录(prefix)。
468
531
  // extRoot 与 ownFile 各取词法/realpath 双形交叉判定,集合同样双形收录——
469
532
  // 避免符号链接目录(如 macOS /var → /private/var)导致传入词法路径与集合错位。
533
+ /** List every file under a package root (npm dir install form) for the tamper
534
+ * baseline (#26): write protection covers the whole package dir, so the watch
535
+ * scope must not lag behind it — a planted manifest entry must not survive to
536
+ * the next session undetected. node_modules/.git are skipped; depth and file
537
+ * count are bounded so a planted oversized tree cannot blow up the next
538
+ * session's baseline build (defense in depth, requires a prior bypass). */
539
+ const listPackageFiles = (root: string): string[] => {
540
+ const out: string[] = [];
541
+ const walk = (dir: string, depth: number): void => {
542
+ if (depth > 16 || out.length >= 500) return;
543
+ let names: string[];
544
+ try {
545
+ names = fs.readdirSync(dir);
546
+ } catch {
547
+ return;
548
+ }
549
+ for (const name of names) {
550
+ if (name === "node_modules" || name === ".git") continue;
551
+ const full = path.join(dir, name);
552
+ // stat (not lstat) follows symlinks: a package file replaced by a
553
+ // symlink to outside content must not silently drop out of the
554
+ // baseline — the watched path stays the lexical entry; a symlink
555
+ // cycle (ELOOP) throws and is skipped (#26 review)
556
+ let st: fs.Stats;
557
+ try {
558
+ st = fs.statSync(full);
559
+ } catch {
560
+ continue;
561
+ }
562
+ if (st.isDirectory()) walk(full, depth + 1);
563
+ else if (st.isFile() && out.length < 500) out.push(full);
564
+ }
565
+ };
566
+ walk(root, 0);
567
+ return out;
568
+ };
569
+
470
570
  const extTargets = new Set<string>();
471
571
  if (ownFile) {
472
572
  watchBases.push({ file: ownFile, kind: "extension" });
573
+ const seenWatch = new Set<string>([ownFile]);
574
+ let pkgRoot: string | null = null;
473
575
  const extRoots = new Set([path.join(agentDir, "extensions"), tryRealpath(path.join(agentDir, "extensions"))]);
474
576
  const ownForms = new Set([ownFile, tryRealpath(ownFile)]);
475
577
  for (const extRoot of extRoots) {
@@ -482,6 +584,16 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
482
584
  (singleFile ? exact : prefixes).add(f);
483
585
  extTargets.add(f);
484
586
  }
587
+ if (!singleFile && pkgRoot === null) pkgRoot = target;
588
+ }
589
+ }
590
+ // one walk of the package root (lexical form; takeSnapshots' pathForms
591
+ // expansion picks up real forms per file) — no duplicate entries
592
+ if (pkgRoot !== null) {
593
+ for (const f of listPackageFiles(pkgRoot)) {
594
+ if (seenWatch.has(f)) continue;
595
+ seenWatch.add(f);
596
+ watchBases.push({ file: f, kind: "extension" });
485
597
  }
486
598
  }
487
599
  }
@@ -511,10 +623,12 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
511
623
  return { exact: [...exact], prefixes: [...prefixes], bashPatterns, watchBases };
512
624
  }
513
625
 
514
- /** 解析后的写入路径是否命中受保护集合(经 realpath symlink 旁路) */
626
+ /** Does the resolved write path hit the protected set (realpath guards against
627
+ * symlink bypass; nonexistent targets rebuild their real form from the
628
+ * nearest existing ancestor, #20) */
515
629
  export function isProtectedWritePath(rawPath: string, cwd: string, prot: ProtectedSet): boolean {
516
630
  if (!rawPath) return false;
517
- for (const c of pathForms(path.resolve(cwd, expandHome(rawPath)))) {
631
+ for (const c of targetForms(path.resolve(cwd, expandHome(rawPath)))) {
518
632
  if (prot.exact.includes(c)) return true;
519
633
  for (const p of prot.prefixes) {
520
634
  if (c === p || c.startsWith(p + path.sep)) return true;
@@ -659,13 +773,23 @@ function sanitize(text: string): string {
659
773
  if (cleaned.length <= MAX_ENTRY_CHARS) return cleaned;
660
774
  const head = Math.floor(MAX_ENTRY_CHARS * 0.6);
661
775
  const tail = MAX_ENTRY_CHARS - head;
662
- return `${cleaned.slice(0, head)}\n…[truncated]…\n${cleaned.slice(-tail)}`;
776
+ return `${cleaned.slice(0, head)}…[truncated]…${cleaned.slice(-tail)}`;
777
+ }
778
+
779
+ /** Transcript line body: sanitized (zero-width stripped, length-capped) with
780
+ * line breaks escaped in place — the transcript is line-structured ("User: …" /
781
+ * "tool: …"), and an embedded line break in a path, command, or message could
782
+ * otherwise forge a structural line (#22). Covers \n, \r\n, lone \r and the
783
+ * Unicode separators U+2028/U+2029/U+0085, which models may render as breaks.
784
+ * Content is preserved, only the line structure is defended. */
785
+ function transcriptSafe(text: string): string {
786
+ return sanitize(text).replace(/[\r\n\u2028\u2029\u0085]/g, "\\n");
663
787
  }
664
788
 
665
789
  function toolCallLine(name: string, args: Record<string, unknown>): string {
666
- if (typeof args.command === "string") return `${name}: ${sanitize(args.command)}`;
667
- if (typeof args.path === "string") return `${name}: ${args.path}`;
668
- return `${name}: ${sanitize(JSON.stringify(args))}`;
790
+ if (typeof args.command === "string") return `${name}: ${transcriptSafe(args.command)}`;
791
+ if (typeof args.path === "string") return `${name}: ${transcriptSafe(args.path)}`;
792
+ return `${name}: ${transcriptSafe(JSON.stringify(args))}`;
669
793
  }
670
794
 
671
795
  /**
@@ -681,7 +805,7 @@ function collectTranscriptParts(ctx: ExtensionContext): { userLines: string[]; t
681
805
  const msg = entry.message;
682
806
  if (msg.role === "user") {
683
807
  const text = typeof msg.content === "string" ? msg.content : msg.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
684
- if (text.trim()) userLines.push(`User: ${sanitize(text)}`);
808
+ if (text.trim()) userLines.push(`User: ${transcriptSafe(text)}`);
685
809
  } else if (msg.role === "assistant") {
686
810
  for (const block of msg.content) {
687
811
  if (block.type === "toolCall") toolLines.push(toolCallLine(block.name, block.arguments as Record<string, unknown>));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "A minimal permission gate for Pi in the style of Claude Code's auto mode",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",