pi-verdict 0.5.1 → 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
@@ -121,7 +121,7 @@ tool_call
121
121
  │ config changed + interactive → one keep/restore confirm
122
122
 
123
123
  ├─ 1. Rule layer (deterministic, zero latency)
124
- │ ├─ built-in deny floor: bash danger regexes (full-string) +
124
+ │ ├─ built-in deny floor: bash danger regexes (full-string, capped at 8192 chars) +
125
125
  │ │ path sensitivity S0–S5 (secrets/system/.git meta → deny;
126
126
  │ │ dual-form matching — lexical + realpath, symlink aliases resolve)
127
127
  │ ├─ your rules: user deny beats user allow (regex, see below)
package/README.zh-CN.md CHANGED
@@ -121,7 +121,7 @@ tool_call
121
121
  │ 仅配置被改且有 UI → 一次保留/还原确认
122
122
 
123
123
  ├─ 1. 规则层(确定性,零延迟)
124
- │ ├─ 内置 deny floor:bash 危险正则(完整命令串)+ 路径敏感度 S0–S5
124
+ │ ├─ 内置 deny floor:bash 危险正则(完整命令串,截断上限 8192 字符)+ 路径敏感度 S0–S5
125
125
  │ │ (双形匹配 —— 词法 + realpath,符号链接别名会被解析)
126
126
  │ ├─ 用户规则:deny 优先于 allow(正则,见下)
127
127
  │ ├─ denyPaths(ADR-0002):用户声明的受保护路径,工具负责归一化
@@ -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" };
@@ -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) => {
@@ -516,9 +530,48 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
516
530
  // 安装副本目标:单文件形态 → 文件本体(exact);npm 目录形态 → 包根目录(prefix)。
517
531
  // extRoot 与 ownFile 各取词法/realpath 双形交叉判定,集合同样双形收录——
518
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
+
519
570
  const extTargets = new Set<string>();
520
571
  if (ownFile) {
521
572
  watchBases.push({ file: ownFile, kind: "extension" });
573
+ const seenWatch = new Set<string>([ownFile]);
574
+ let pkgRoot: string | null = null;
522
575
  const extRoots = new Set([path.join(agentDir, "extensions"), tryRealpath(path.join(agentDir, "extensions"))]);
523
576
  const ownForms = new Set([ownFile, tryRealpath(ownFile)]);
524
577
  for (const extRoot of extRoots) {
@@ -531,6 +584,16 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
531
584
  (singleFile ? exact : prefixes).add(f);
532
585
  extTargets.add(f);
533
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" });
534
597
  }
535
598
  }
536
599
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.5.1",
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",