pi-verdict 0.7.0 → 0.7.1
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 +10 -3
- package/README.zh-CN.md +10 -3
- package/extensions/pi-verdict.ts +42 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,7 +89,14 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
89
89
|
{
|
|
90
90
|
"allow": ["^ls\\b", "^git (status|log|diff)\\b"],
|
|
91
91
|
"deny": ["rm ", "docker ", "^/etc/"],
|
|
92
|
-
"denyPaths": [
|
|
92
|
+
"denyPaths": [
|
|
93
|
+
"~/.ssh/",
|
|
94
|
+
"~/.profile",
|
|
95
|
+
"~/.gnupg",
|
|
96
|
+
"~/.mc",
|
|
97
|
+
"~/.zshrc",
|
|
98
|
+
"~/.bashrc"
|
|
99
|
+
],
|
|
93
100
|
"builtinDenyFloor": true,
|
|
94
101
|
"classifierModel": null,
|
|
95
102
|
"toggleShortcut": "ctrl+shift+a"
|
|
@@ -97,7 +104,7 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
97
104
|
```
|
|
98
105
|
|
|
99
106
|
- `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
|
|
100
|
-
- `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
|
|
107
|
+
- `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), active from the first session after the initial run (any config change applies to new sessions) — a pre-filled *user declaration*, not a built-in floor: edit or empty it freely, add your own (`~/Documents/private`, …) alongside; existing configs are never rewritten
|
|
101
108
|
- `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
|
|
102
109
|
- `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
|
|
103
110
|
|
|
@@ -179,7 +186,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
|
|
|
179
186
|
- 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)
|
|
180
187
|
- shadow cache is observe-only by decision; the serving switch is a one-line change once measured hit rates justify it
|
|
181
188
|
- `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
|
|
182
|
-
- `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.
|
|
189
|
+
- `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
|
|
183
190
|
- 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)
|
|
184
191
|
- dev checkouts (running the extension from a repo, not `<agentDir>/extensions/`) are not self-protected — the installed copy the *next* normal session loads is only covered by its own sessions' gate
|
|
185
192
|
|
package/README.zh-CN.md
CHANGED
|
@@ -90,7 +90,14 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
90
90
|
{
|
|
91
91
|
"allow": ["^ls\\b", "^git (status|log|diff)\\b"],
|
|
92
92
|
"deny": ["rm ", "docker ", "^/etc/"],
|
|
93
|
-
"denyPaths": [
|
|
93
|
+
"denyPaths": [
|
|
94
|
+
"~/.ssh/",
|
|
95
|
+
"~/.profile",
|
|
96
|
+
"~/.gnupg",
|
|
97
|
+
"~/.mc",
|
|
98
|
+
"~/.zshrc",
|
|
99
|
+
"~/.bashrc"
|
|
100
|
+
],
|
|
94
101
|
"builtinDenyFloor": true,
|
|
95
102
|
"classifierModel": null,
|
|
96
103
|
"toggleShortcut": "ctrl+shift+a"
|
|
@@ -98,7 +105,7 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
98
105
|
```
|
|
99
106
|
|
|
100
107
|
- `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
|
|
101
|
-
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny)
|
|
108
|
+
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件),自初次运行后的第一个会话起生效(一切配置变更均自新会话生效)——它是预填的*用户声明*而非内置 floor:可随意增删清空,也可与自己的路径(`~/Documents/private`、……)并列;既有配置永不被改写
|
|
102
109
|
- `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
|
|
103
110
|
- `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
|
|
104
111
|
|
|
@@ -180,7 +187,7 @@ tool_call
|
|
|
180
187
|
- 自省意味着会话模型亲自裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
|
|
181
188
|
- 影子缓存按决议仅观察不生效;实测命中率达标后,生效开关是一行改动
|
|
182
189
|
- `denyPaths` 的 bash 提取是 token 级([ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md)):命令替换、base64 内嵌路径、外部脚本内容不产生命中信号——这些调用回落到分类器的存在性话术警戒。MCP 与自定义工具完全绕过提取器(其灰区裁决仍带话术)。路径归一化亦为基础档(ADR-0002):经符号链接目录写入尚不存在的目标不重建真实形、不产生命中——该间接路径同样由话术警戒覆盖(祖先重建档只适用于自保护层与路径敏感度 floor,不适用 denyPaths)。诚实表述,与自保护子串正则同例:确定性层可被混淆——这正是命中交由**你**裁决而非静默决定的原因
|
|
183
|
-
- `denyPaths` 的 bash token 不含空格:**声明路径本身含空格时**,bash 拼写无法被提取器识别——`cat "/path with space/x"` 被拆成两个 token 永不命中(文件类工具仍命中,其路径不经 token 化)。glob 覆盖基名末段(`denyPaths: ["/proj/personal"]` 时 `cat /proj/pers*`)
|
|
190
|
+
- `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 一样回落到分类器的存在性话术
|
|
184
191
|
- 自保护 bash 匹配是子串正则——可被混淆绕过;变更检测兜底覆盖会话内绕过,跨会话基线(启动时哈希比对与变更确认,含升级 UX)按 ADR-0001 为二期
|
|
185
192
|
- dev checkout(从仓库而非 `<agentDir>/extensions/` 运行扩展)不受自保护——下一个正常会话加载的安装副本只在其自身会话的门禁内受保护
|
|
186
193
|
|
package/extensions/pi-verdict.ts
CHANGED
|
@@ -310,10 +310,17 @@ function userConfigPath(): string {
|
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
313
|
-
_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.",
|
|
313
|
+
_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). The template pre-fills a starter denyPaths list (~/.ssh, ~/.gnupg, shell rc files) — edit or empty it freely, it is your declaration, not a built-in floor. 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.",
|
|
314
314
|
allow: ["^ls\\b"],
|
|
315
315
|
deny: [],
|
|
316
|
-
denyPaths: [
|
|
316
|
+
denyPaths: [
|
|
317
|
+
"~/.ssh/",
|
|
318
|
+
"~/.profile",
|
|
319
|
+
"~/.gnupg",
|
|
320
|
+
"~/.mc",
|
|
321
|
+
"~/.zshrc",
|
|
322
|
+
"~/.bashrc",
|
|
323
|
+
],
|
|
317
324
|
builtinDenyFloor: true,
|
|
318
325
|
classifierModel: null,
|
|
319
326
|
toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
|
|
@@ -459,13 +466,23 @@ function toolKind(toolName: string): "command" | "file" | null {
|
|
|
459
466
|
}
|
|
460
467
|
}
|
|
461
468
|
|
|
462
|
-
/**
|
|
469
|
+
/** Scope tools (grep/find/ls): pi's schema makes `path` optional (default:
|
|
470
|
+
* current directory) and the search covers a directory SUBTREE — an omitted or
|
|
471
|
+
* empty path means the cwd is the effective target (#48). */
|
|
472
|
+
function isScopeTool(toolName: string): boolean {
|
|
473
|
+
return toolName === "grep" || toolName === "find" || toolName === "ls";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 用户规则匹配目标:bash/powershell=完整命令串;路径类工具=解析后绝对路径;其余工具不参与。
|
|
477
|
+
* Scope tools with an omitted path resolve to the cwd (#48) — user rules match
|
|
478
|
+
* the effective target, never a null that skips the whole rule block. */
|
|
463
479
|
function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: string): string | null {
|
|
464
480
|
const kind = toolKind(toolName);
|
|
465
481
|
if (kind === "command") return String(input.command ?? "");
|
|
466
482
|
if (kind === "file") {
|
|
467
483
|
const p = typeof input.path === "string" && input.path ? input.path : null;
|
|
468
|
-
|
|
484
|
+
if (!p) return isScopeTool(toolName) ? path.resolve(cwd) : null;
|
|
485
|
+
return path.resolve(cwd, expandHome(p));
|
|
469
486
|
}
|
|
470
487
|
return null;
|
|
471
488
|
}
|
|
@@ -478,7 +495,9 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
|
|
|
478
495
|
// ~ / $HOME expansion, lexical resolve against cwd, realpath resolution of
|
|
479
496
|
// symlink indirection (failure — nonexistent target, glob token — degrades to
|
|
480
497
|
// the lexical form). Comparison is per path segment, both sides in dual form
|
|
481
|
-
// (lexical + realpath).
|
|
498
|
+
// (lexical + realpath). Scope tools (grep/find/ls) are subtree-scoped and
|
|
499
|
+
// bidirectional (#48): an omitted path means the cwd, and a declaration that
|
|
500
|
+
// sits INSIDE the searched subtree hits as well. The extractor is an evidence producer, never an
|
|
482
501
|
// adjudicator: a hit routes to a terminal ask (the declaring user owns the
|
|
483
502
|
// exception); non-interactive sessions degrade to deny. External script
|
|
484
503
|
// contents are never read (unsound by construction, ADR-0002); the classifier
|
|
@@ -502,13 +521,16 @@ function denyPathForms(raw: string, cwd: string): string[] {
|
|
|
502
521
|
/** Normalize the configured denyPaths against one cwd (ADR-0002: anchored once per session, never re-derived) */
|
|
503
522
|
const anchorDenyPaths = (paths: string[], cwd: string): string[] => paths.flatMap((b) => denyPathForms(b, cwd));
|
|
504
523
|
|
|
505
|
-
/** Every path candidate a tool call exposes to denyPaths comparison (MCP/custom tools: none — classifier + hint covers)
|
|
506
|
-
|
|
524
|
+
/** Every path candidate a tool call exposes to denyPaths comparison (MCP/custom tools: none — classifier + hint covers).
|
|
525
|
+
* Scope tools with an omitted/empty path contribute the cwd: their search scope
|
|
526
|
+
* IS the cwd subtree (#48). */
|
|
527
|
+
function denyPathCandidates(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
|
|
507
528
|
const kind = toolKind(toolName);
|
|
508
529
|
if (kind === "command") return [...String(input.command ?? "").matchAll(BASH_PATH_TOKENS)].map((m) => m[0]);
|
|
509
530
|
if (kind === "file") {
|
|
510
|
-
const p = typeof input.path === "string" ? input.path :
|
|
511
|
-
|
|
531
|
+
const p = typeof input.path === "string" && input.path ? input.path : null;
|
|
532
|
+
if (!p) return isScopeTool(toolName) ? [cwd] : [];
|
|
533
|
+
return [p];
|
|
512
534
|
}
|
|
513
535
|
return [];
|
|
514
536
|
}
|
|
@@ -516,13 +538,19 @@ function denyPathCandidates(toolName: string, input: Record<string, unknown>): s
|
|
|
516
538
|
/** Does the call touch a user-declared protected path? `bases` are the denyPaths
|
|
517
539
|
* pre-normalized ONCE at session start (anchored to the session cwd) — mid-session
|
|
518
540
|
* symlink creation or cwd drift must not change what the declaration covers.
|
|
519
|
-
* Returns the matched base for the ask dialog (UI-only plaintext, see RuleResult.detail).
|
|
541
|
+
* Returns the matched base for the ask dialog (UI-only plaintext, see RuleResult.detail).
|
|
542
|
+
* Scope tools compare BIDIRECTIONALLY (#48): their search covers a subtree, so a
|
|
543
|
+
* hit fires when the target sits under a base (single-target direction) OR a base
|
|
544
|
+
* sits inside the searched subtree (cwd-inside-declaration, declaration-under-cwd).
|
|
545
|
+
* False positives ask — the safe direction. read/write/edit and bash tokens stay
|
|
546
|
+
* one-directional: single-target semantics. */
|
|
520
547
|
function hitDenyPaths(toolName: string, input: Record<string, unknown>, cwd: string, bases: string[]): string | null {
|
|
521
548
|
if (bases.length === 0) return null;
|
|
522
|
-
|
|
549
|
+
const subtree = isScopeTool(toolName);
|
|
550
|
+
for (const candidate of denyPathCandidates(toolName, input, cwd)) {
|
|
523
551
|
for (const c of denyPathForms(candidate, cwd)) {
|
|
524
552
|
for (const b of bases) {
|
|
525
|
-
if (pathEquals(c, b) || pathStartsWith(c, b)) return b;
|
|
553
|
+
if (pathEquals(c, b) || pathStartsWith(c, b) || (subtree && pathStartsWith(b, c))) return b;
|
|
526
554
|
}
|
|
527
555
|
}
|
|
528
556
|
}
|
|
@@ -836,7 +864,8 @@ function classifyByRules(toolName: string, input: Record<string, unknown>, cwd:
|
|
|
836
864
|
// read keeps classifyPath even with an empty path: resolved to cwd, it still
|
|
837
865
|
// carries the system-directory gray grading (bit-for-bit with the old switch)
|
|
838
866
|
base = classifyPath(toolName, String(input.path ?? ""), cwd, false, user.builtinDenyFloor);
|
|
839
|
-
} else if (kind === "file") { // grep/find/ls: optional path
|
|
867
|
+
} else if (kind === "file") { // grep/find/ls: optional path; absent → cwd is the
|
|
868
|
+
// effective target, so user rules and denyPaths compare against it (#48)
|
|
840
869
|
const p = typeof input.path === "string" ? input.path : undefined;
|
|
841
870
|
base = p ? classifyPath(toolName, p, cwd, false, user.builtinDenyFloor) : { verdict: "allow" };
|
|
842
871
|
} else {
|