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 +1 -1
- package/README.zh-CN.md +1 -1
- package/extensions/auto-mode.ts +66 -3
- package/package.json +1 -1
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 危险正则(
|
|
124
|
+
│ ├─ 内置 deny floor:bash 危险正则(完整命令串,截断上限 8192 字符)+ 路径敏感度 S0–S5
|
|
125
125
|
│ │ (双形匹配 —— 词法 + realpath,符号链接别名会被解析)
|
|
126
126
|
│ ├─ 用户规则:deny 优先于 allow(正则,见下)
|
|
127
127
|
│ ├─ denyPaths(ADR-0002):用户声明的受保护路径,工具负责归一化
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
}
|