@yaosu/pi-path-guard 1.1.1 → 1.3.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 +37 -3
- package/extensions/path-guard.ts +580 -123
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,9 +35,43 @@ After installing, run `/reload` or restart pi. 安装后 `/reload` 或重启 pi
|
|
|
35
35
|
- `/guard` — interactive mode picker (title shows the full decision matrix; choices are bilingual) 交互式选择防护模式(标题展示完整判定矩阵,选项中英双语)
|
|
36
36
|
- `/guard <strict|normal|loose|trusted|naked>` — quick switch (trusted requires a warning; naked requires a double warning) 快捷切换(trusted 需警告确认;naked 需两级确认)
|
|
37
37
|
- Invalid argument → falls back to the interactive picker 非法参数 → 兜底弹出交互选择
|
|
38
|
-
-
|
|
38
|
+
- The active mode persists across sessions via settings.json (`pathGuard.mode`): project `.pi/settings.json` overrides global `~/.pi/agent/settings.json`, falling back to `normal`. `/guard <mode>` writes it back (project settings in a trusted project, else global). 模式跨会话持久化到 settings.json(`pathGuard.mode`):项目 `.pi/settings.json` 优先于全局 `~/.pi/agent/settings.json`,缺省回 `normal`;`/guard <mode>` 切换时回写(受信任项目写项目配置,否则写全局)
|
|
39
|
+
- `/guard paths add|rm|list|clear <path>` — manage custom protected paths, enforced in EVERY mode (incl. naked) 管理自定义受保护路径,任何模式(含 naked)都生效
|
|
39
40
|
- The active mode is shown in the footer status bar (`🛡 <mode>`, `🛡 NAKED` in warning color) 当前模式显示在底部状态栏(`🛡 <mode>`,naked 用警示色 `🛡 NAKED`)
|
|
40
41
|
|
|
42
|
+
### Custom protected paths / 自定义受保护路径
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
/guard paths list # show configured custom protected paths 列出
|
|
46
|
+
/guard paths add <path> # add one (absolute or ~/…; symlink-resolved) 新增
|
|
47
|
+
/guard paths rm <path> # remove 移除
|
|
48
|
+
/guard paths clear # clear all 清空
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Custom paths are checked against the resolved real path and protected in every mode — even `naked` (built-in protected paths are NOT enforced in `naked`; only yours are). They are also settable statically:
|
|
52
|
+
自定义路径按解析后的真实路径匹配,且在任何模式下都被守护——包括 `naked`(内置受保护路径在 `naked` 下不生效,但你自定义的始终生效)。也可在 settings.json 静态配置:
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
"pathGuard": { "extraProtected": ["~/secrets", "/path/to/important.txt"] }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Tunable rules / 可调规则
|
|
59
|
+
|
|
60
|
+
Each mode's judgement is a set of 12 rules; override any per mode in settings.json (`rule = "block" | "confirm" | "pass"`; invalid values are ignored):
|
|
61
|
+
每个模式的判定由 12 条规则组成;可在 settings.json 里按模式覆盖(`rule` 取 `"block"|"confirm"|"pass"`,非法值忽略):
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
"pathGuard": {
|
|
65
|
+
"mode": "normal",
|
|
66
|
+
"rules": {
|
|
67
|
+
"normal": { "deleteOutside": "confirm", "confirmGroup": "pass" },
|
|
68
|
+
"naked": { "blockGroup": "block" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Rule IDs: `blockGroup`, `confirmGroup`, `writeOutside`, `writeHome`, `writeInProject`, `deleteOutside`, `deleteInProject`, `overwriteOutsideExisting`, `overwriteOutsideNew`, `overwriteInProject`, `truncate`, `gitDestructive`. Defaults reproduce the matrix above exactly.
|
|
74
|
+
|
|
41
75
|
### Guard mode matrix / 防护模式矩阵
|
|
42
76
|
|
|
43
77
|
| Checkpoint / 判定点 | strict | normal | loose | trusted | naked |
|
|
@@ -73,13 +107,13 @@ After installing, run `/reload` or restart pi. 安装后 `/reload` 或重启 pi
|
|
|
73
107
|
|
|
74
108
|
## Development / 开发与测试
|
|
75
109
|
|
|
76
|
-
Automated tests (
|
|
110
|
+
Automated tests (117 assertions) load the real extension with a mocked pi API, covering the 5 modes × protected paths / dangerous commands / truncation / git destructive matrix, plus `/guard` command interaction, trusted-mode confirmation, naked-mode double confirmation, the footer status indicator, settings.json mode persistence, custom protected paths (incl. naked), and per-mode rule overrides:
|
|
77
111
|
|
|
78
112
|
```bash
|
|
79
113
|
cd tests && node --experimental-strip-types test-pathguard.ts
|
|
80
114
|
```
|
|
81
115
|
|
|
82
|
-
自动化测试(
|
|
116
|
+
自动化测试(117 断言)模拟 pi API 加载真实扩展,覆盖 5 种模式 × 受保护路径 / 危险命令 / 截断 / git 破坏性等判定矩阵,以及 `/guard` 命令交互、trusted 确认与 naked 两级确认、底部状态栏指示、settings.json 模式持久化、自定义受保护路径(含 naked)、按模式规则覆盖等流程:
|
|
83
117
|
|
|
84
118
|
```bash
|
|
85
119
|
cd tests && node --experimental-strip-types test-pathguard.ts
|
package/extensions/path-guard.ts
CHANGED
|
@@ -42,6 +42,18 @@
|
|
|
42
42
|
* checks, git destructive, truncate, outside deletes/overwrites); only system-destructive
|
|
43
43
|
* Block-group commands (mkfs/reboot/block-device writes/bulk delete) are still confirmed.
|
|
44
44
|
* Switching to naked requires a double confirmation (stronger than trusted's single warning).
|
|
45
|
+
*
|
|
46
|
+
* v3.2 adds (upgrade from v3.1): mode persistence across sessions. The active mode is read from
|
|
47
|
+
* settings.json on session_start (project .pi/settings.json overrides the global
|
|
48
|
+
* ~/.pi/agent/settings.json, falling back to normal) and written back when /guard switches
|
|
49
|
+
* mode — project settings in a trusted project, otherwise global settings. Key: pathGuard.mode.
|
|
50
|
+
*
|
|
51
|
+
* v3.3 adds (upgrade from v3.2): configurable protected paths and tunable rules.
|
|
52
|
+
* - User-configured protected paths (pathGuard.extraProtected, or /guard paths add|rm|list|clear)
|
|
53
|
+
* are enforced in EVERY mode including naked.
|
|
54
|
+
* - The 5 modes' judgement rules are tunable per mode via pathGuard.rules.{mode}.{rule} in
|
|
55
|
+
* settings.json (rule = block|confirm|pass; valid rule IDs listed in RULE_IDS). The built-in
|
|
56
|
+
* defaults exactly reproduce v3.2 behaviour; overrides only adjust the listed rule.
|
|
45
57
|
*/
|
|
46
58
|
|
|
47
59
|
import type {
|
|
@@ -64,7 +76,13 @@ import {
|
|
|
64
76
|
sep,
|
|
65
77
|
} from "node:path";
|
|
66
78
|
import { homedir } from "node:os";
|
|
67
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
realpathSync,
|
|
81
|
+
existsSync,
|
|
82
|
+
statSync,
|
|
83
|
+
readFileSync,
|
|
84
|
+
writeFileSync,
|
|
85
|
+
} from "node:fs";
|
|
68
86
|
|
|
69
87
|
// ─── Configuration ──────────────────────────────────────────────────────
|
|
70
88
|
|
|
@@ -189,6 +207,11 @@ const DEVICE_TARGETS = new Set([
|
|
|
189
207
|
|
|
190
208
|
const HOME = homedir();
|
|
191
209
|
|
|
210
|
+
/** pi project config dir name (default CONFIG_DIR_NAME is `.pi`). */
|
|
211
|
+
const CONFIG_DIR = ".pi";
|
|
212
|
+
/** Global settings.json path (default pi agent dir). */
|
|
213
|
+
const GLOBAL_SETTINGS_PATH = join(HOME, ".pi", "agent", "settings.json");
|
|
214
|
+
|
|
192
215
|
// ─── Guard Modes ─────────────────────────────────────────────────────
|
|
193
216
|
|
|
194
217
|
/** Guard mode: strict (full) / normal (default) / loose (relaxed) / trusted (most permissive) / naked (no protection) */
|
|
@@ -243,6 +266,163 @@ const MODE_MATRIX = [
|
|
|
243
266
|
" No UI (headless) B B B B .",
|
|
244
267
|
].join("\n");
|
|
245
268
|
|
|
269
|
+
// ─── Tunable rules & user-configured protected paths ──────────────────
|
|
270
|
+
|
|
271
|
+
/** Decision level a rule can produce. */
|
|
272
|
+
type RuleLevel = "block" | "confirm" | "pass";
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Tunable rule IDs — each is a single decision point in the judgement logic.
|
|
276
|
+
* A rule's effective value for the current mode = settings override ?? default.
|
|
277
|
+
*/
|
|
278
|
+
type RuleId =
|
|
279
|
+
| "blockGroup" // system-destructive mkfs/reboot/dev-write/bulk-delete
|
|
280
|
+
| "confirmGroup" // privilege/remote sudo/ssh/chmod777
|
|
281
|
+
| "writeOutside" // write/edit targeting a path outside the project
|
|
282
|
+
| "writeHome" // write/edit under HOME
|
|
283
|
+
| "writeInProject" // write/edit creating/overwriting in the project
|
|
284
|
+
| "deleteOutside" // rm outside the project
|
|
285
|
+
| "deleteInProject" // rm in the project
|
|
286
|
+
| "overwriteOutsideExisting" // mv/cp over an existing target outside
|
|
287
|
+
| "overwriteOutsideNew" // mv/cp creating a target outside
|
|
288
|
+
| "overwriteInProject" // mv/cp overwrite in the project
|
|
289
|
+
| "truncate" // `> existing file` / truncate
|
|
290
|
+
| "gitDestructive"; // git clean -f / reset --hard / checkout . / push --force …
|
|
291
|
+
|
|
292
|
+
const RULE_IDS: readonly RuleId[] = [
|
|
293
|
+
"blockGroup",
|
|
294
|
+
"confirmGroup",
|
|
295
|
+
"writeOutside",
|
|
296
|
+
"writeHome",
|
|
297
|
+
"writeInProject",
|
|
298
|
+
"deleteOutside",
|
|
299
|
+
"deleteInProject",
|
|
300
|
+
"overwriteOutsideExisting",
|
|
301
|
+
"overwriteOutsideNew",
|
|
302
|
+
"overwriteInProject",
|
|
303
|
+
"truncate",
|
|
304
|
+
"gitDestructive",
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
function isRuleLevel(v: string | undefined): v is RuleLevel {
|
|
308
|
+
return v === "block" || v === "confirm" || v === "pass";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Default rules per built-in mode — reproduces the v3.1 hardcoded behaviour exactly. */
|
|
312
|
+
const DEFAULT_MODES: Record<GuardMode, Record<RuleId, RuleLevel>> = {
|
|
313
|
+
strict: {
|
|
314
|
+
blockGroup: "block",
|
|
315
|
+
confirmGroup: "block",
|
|
316
|
+
writeOutside: "confirm",
|
|
317
|
+
writeHome: "confirm",
|
|
318
|
+
writeInProject: "confirm",
|
|
319
|
+
deleteOutside: "block",
|
|
320
|
+
deleteInProject: "confirm",
|
|
321
|
+
overwriteOutsideExisting: "block",
|
|
322
|
+
overwriteOutsideNew: "confirm",
|
|
323
|
+
overwriteInProject: "confirm",
|
|
324
|
+
truncate: "confirm",
|
|
325
|
+
gitDestructive: "confirm",
|
|
326
|
+
},
|
|
327
|
+
normal: {
|
|
328
|
+
blockGroup: "block",
|
|
329
|
+
confirmGroup: "confirm",
|
|
330
|
+
writeOutside: "confirm",
|
|
331
|
+
writeHome: "confirm",
|
|
332
|
+
writeInProject: "pass",
|
|
333
|
+
deleteOutside: "block",
|
|
334
|
+
deleteInProject: "confirm",
|
|
335
|
+
overwriteOutsideExisting: "block",
|
|
336
|
+
overwriteOutsideNew: "confirm",
|
|
337
|
+
overwriteInProject: "confirm",
|
|
338
|
+
truncate: "confirm",
|
|
339
|
+
gitDestructive: "confirm",
|
|
340
|
+
},
|
|
341
|
+
loose: {
|
|
342
|
+
blockGroup: "block",
|
|
343
|
+
confirmGroup: "confirm",
|
|
344
|
+
writeOutside: "pass",
|
|
345
|
+
writeHome: "pass",
|
|
346
|
+
writeInProject: "pass",
|
|
347
|
+
deleteOutside: "confirm",
|
|
348
|
+
deleteInProject: "pass",
|
|
349
|
+
overwriteOutsideExisting: "confirm",
|
|
350
|
+
overwriteOutsideNew: "pass",
|
|
351
|
+
overwriteInProject: "confirm",
|
|
352
|
+
truncate: "confirm",
|
|
353
|
+
gitDestructive: "confirm",
|
|
354
|
+
},
|
|
355
|
+
trusted: {
|
|
356
|
+
blockGroup: "block",
|
|
357
|
+
confirmGroup: "confirm",
|
|
358
|
+
writeOutside: "pass",
|
|
359
|
+
writeHome: "pass",
|
|
360
|
+
writeInProject: "pass",
|
|
361
|
+
deleteOutside: "pass",
|
|
362
|
+
deleteInProject: "pass",
|
|
363
|
+
overwriteOutsideExisting: "pass",
|
|
364
|
+
overwriteOutsideNew: "pass",
|
|
365
|
+
overwriteInProject: "confirm",
|
|
366
|
+
truncate: "confirm",
|
|
367
|
+
gitDestructive: "confirm",
|
|
368
|
+
},
|
|
369
|
+
naked: {
|
|
370
|
+
blockGroup: "confirm",
|
|
371
|
+
confirmGroup: "pass",
|
|
372
|
+
writeOutside: "pass",
|
|
373
|
+
writeHome: "pass",
|
|
374
|
+
writeInProject: "pass",
|
|
375
|
+
deleteOutside: "pass",
|
|
376
|
+
deleteInProject: "pass",
|
|
377
|
+
overwriteOutsideExisting: "pass",
|
|
378
|
+
overwriteOutsideNew: "pass",
|
|
379
|
+
overwriteInProject: "pass",
|
|
380
|
+
truncate: "pass",
|
|
381
|
+
gitDestructive: "pass",
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/** Effective rule level for the current mode (settings override ?? built-in default). */
|
|
386
|
+
function rl(rule: RuleId): RuleLevel {
|
|
387
|
+
return config.rules[currentMode]?.[rule] ?? DEFAULT_MODES[currentMode][rule];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Map a rule to a segment verdict: block (with reason) / confirm / pass. */
|
|
391
|
+
function ruleVerdict(rule: RuleId, blockReason: string): SegmentVerdict {
|
|
392
|
+
const lvl = rl(rule);
|
|
393
|
+
if (lvl === "block") return { kind: "block", reason: blockReason };
|
|
394
|
+
if (lvl === "confirm") return { kind: "confirm" };
|
|
395
|
+
return { kind: "pass" };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Whether the current mode is naked (many conservative confirms become pass). */
|
|
399
|
+
const inNaked = () => currentMode === "naked";
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* User-configured protected paths (pathGuard.extraProtected). Unlike built-in
|
|
403
|
+
* protected paths, these are enforced in EVERY mode — including naked.
|
|
404
|
+
*/
|
|
405
|
+
let extraProtected: string[] = [];
|
|
406
|
+
|
|
407
|
+
/** Match a resolved absolute path against a user-configured protected entry. */
|
|
408
|
+
function isUserProtectedPath(absolutePath: string): boolean {
|
|
409
|
+
for (const entry of extraProtected) {
|
|
410
|
+
const e = normalize(resolveReal(entry));
|
|
411
|
+
if (absolutePath === e) return true;
|
|
412
|
+
if (absolutePath.startsWith(e + sep)) return true;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Expand ~, resolve relative entries against cwd, and resolve symlinks (so the stored path matches the real path used in checks). */
|
|
418
|
+
function normalizeProtectedEntry(
|
|
419
|
+
entry: string,
|
|
420
|
+
cwd: string | undefined,
|
|
421
|
+
): string {
|
|
422
|
+
const expanded = expandHome(entry.trim());
|
|
423
|
+
return resolveReal(resolve(cwd ?? HOME, expanded));
|
|
424
|
+
}
|
|
425
|
+
|
|
246
426
|
/** Guard verdict: { block, reason } to block / undefined to allow (askConfirm returns a Promise) */
|
|
247
427
|
type GuardVerdict =
|
|
248
428
|
| ToolCallEventResult
|
|
@@ -268,18 +448,232 @@ function refreshModeStatus(ui: ExtensionUIContext) {
|
|
|
268
448
|
ui.setStatus("path-guard", t.fg(color, label));
|
|
269
449
|
}
|
|
270
450
|
|
|
451
|
+
// ─── Settings persistence (mode survives across sessions) ─────────────
|
|
452
|
+
|
|
453
|
+
/** Global settings.json path (~/.pi/agent/settings.json). */
|
|
454
|
+
function globalSettingsPath(): string {
|
|
455
|
+
return GLOBAL_SETTINGS_PATH;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Project settings.json path (cwd/.pi/settings.json), or undefined when no cwd. */
|
|
459
|
+
function projectSettingsPath(cwd: string | undefined): string | undefined {
|
|
460
|
+
return cwd ? join(cwd, CONFIG_DIR, "settings.json") : undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Loaded path-guard config: active mode, user-configured protected paths, and
|
|
465
|
+
* per-mode rule overrides. Repopulated from settings.json on every session_start.
|
|
466
|
+
*/
|
|
467
|
+
interface PathGuardConfig {
|
|
468
|
+
mode: GuardMode;
|
|
469
|
+
extraProtected: string[];
|
|
470
|
+
rules: Partial<Record<GuardMode, Partial<Record<RuleId, RuleLevel>>>>;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let config: PathGuardConfig = { mode: "normal", extraProtected: [], rules: {} };
|
|
474
|
+
|
|
475
|
+
/** Read and validate the raw pathGuard block from a settings.json file, or undefined. */
|
|
476
|
+
function readSettingsGuard(
|
|
477
|
+
filePath: string | undefined,
|
|
478
|
+
): Partial<PathGuardConfig> | undefined {
|
|
479
|
+
if (!filePath) return undefined;
|
|
480
|
+
try {
|
|
481
|
+
if (!existsSync(filePath)) return undefined;
|
|
482
|
+
const data = JSON.parse(readFileSync(filePath, "utf8")) as {
|
|
483
|
+
pathGuard?: {
|
|
484
|
+
mode?: string;
|
|
485
|
+
extraProtected?: string[];
|
|
486
|
+
rules?: Record<string, Record<string, string>>;
|
|
487
|
+
};
|
|
488
|
+
};
|
|
489
|
+
const g = data?.pathGuard;
|
|
490
|
+
if (!g) return undefined;
|
|
491
|
+
const out: Partial<PathGuardConfig> = {};
|
|
492
|
+
if (typeof g.mode === "string" && isGuardMode(g.mode)) out.mode = g.mode;
|
|
493
|
+
if (Array.isArray(g.extraProtected)) {
|
|
494
|
+
out.extraProtected = g.extraProtected.filter(
|
|
495
|
+
(p): p is string => typeof p === "string",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
if (g.rules && typeof g.rules === "object") {
|
|
499
|
+
const rules: PathGuardConfig["rules"] = {};
|
|
500
|
+
for (const [m, overrides] of Object.entries(g.rules)) {
|
|
501
|
+
if (!isGuardMode(m) || !overrides || typeof overrides !== "object")
|
|
502
|
+
continue;
|
|
503
|
+
const clean: Partial<Record<RuleId, RuleLevel>> = {};
|
|
504
|
+
for (const [r, lvl] of Object.entries(overrides)) {
|
|
505
|
+
if ((RULE_IDS as readonly string[]).includes(r) && isRuleLevel(lvl)) {
|
|
506
|
+
clean[r as RuleId] = lvl;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (Object.keys(clean).length > 0) rules[m] = clean;
|
|
510
|
+
}
|
|
511
|
+
if (Object.keys(rules).length > 0) out.rules = rules;
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
} catch {
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Effective config at session start: project settings override global, falling
|
|
521
|
+
* back to normal. Project-local config is only honored for trusted projects;
|
|
522
|
+
* otherwise only the global setting applies. extraProtected entries are resolved
|
|
523
|
+
* against cwd (~ and relative paths expanded); project entries are appended after
|
|
524
|
+
* global ones.
|
|
525
|
+
*/
|
|
526
|
+
function readSavedConfig(
|
|
527
|
+
cwd: string | undefined,
|
|
528
|
+
trusted: boolean,
|
|
529
|
+
): PathGuardConfig {
|
|
530
|
+
const global = readSettingsGuard(globalSettingsPath()) ?? {};
|
|
531
|
+
const project = trusted
|
|
532
|
+
? (readSettingsGuard(projectSettingsPath(cwd)) ?? {})
|
|
533
|
+
: {};
|
|
534
|
+
const mode = project.mode ?? global.mode ?? "normal";
|
|
535
|
+
const extraProtected = [
|
|
536
|
+
...(global.extraProtected ?? []),
|
|
537
|
+
...(project.extraProtected ?? []),
|
|
538
|
+
].map((e) => normalizeProtectedEntry(e, cwd));
|
|
539
|
+
const rules = { ...global.rules, ...project.rules };
|
|
540
|
+
return { mode, extraProtected, rules };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Persist the whole config to settings.json. In a trusted project it writes the
|
|
545
|
+
* project settings file (cwd/.pi/settings.json); otherwise the global settings
|
|
546
|
+
* file. Requires a cwd; returns "project" | "global" | "none".
|
|
547
|
+
*/
|
|
548
|
+
function persistConfig(cwd: string | undefined, trusted: boolean): string {
|
|
549
|
+
if (!cwd) return "none";
|
|
550
|
+
const target = trusted ? projectSettingsPath(cwd) : globalSettingsPath();
|
|
551
|
+
if (!target) return "none";
|
|
552
|
+
try {
|
|
553
|
+
let data: Record<string, unknown> = {};
|
|
554
|
+
if (existsSync(target)) {
|
|
555
|
+
data = JSON.parse(readFileSync(target, "utf8")) as Record<string, unknown>;
|
|
556
|
+
}
|
|
557
|
+
const guard = (data.pathGuard as Record<string, unknown>) ?? {};
|
|
558
|
+
guard.mode = config.mode;
|
|
559
|
+
if (extraProtected.length > 0) {
|
|
560
|
+
guard.extraProtected = extraProtected;
|
|
561
|
+
} else {
|
|
562
|
+
delete guard.extraProtected;
|
|
563
|
+
}
|
|
564
|
+
if (Object.keys(config.rules).length > 0) {
|
|
565
|
+
guard.rules = config.rules;
|
|
566
|
+
} else {
|
|
567
|
+
delete guard.rules;
|
|
568
|
+
}
|
|
569
|
+
data.pathGuard = guard;
|
|
570
|
+
writeFileSync(target, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
571
|
+
return trusted ? "project" : "global";
|
|
572
|
+
} catch {
|
|
573
|
+
return "none";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Human-readable persistence note for notify messages. */
|
|
578
|
+
function persistNote(where: string): string {
|
|
579
|
+
if (where === "project")
|
|
580
|
+
return "saved to project settings (.pi/settings.json)";
|
|
581
|
+
if (where === "global") return "saved to global settings";
|
|
582
|
+
return "session-only (not persisted)";
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Path Guard paths usage message. */
|
|
586
|
+
const PATHS_USAGE =
|
|
587
|
+
"Path Guard paths usage:\n" +
|
|
588
|
+
" /guard paths list\n" +
|
|
589
|
+
" /guard paths add <path>\n" +
|
|
590
|
+
" /guard paths rm <path>\n" +
|
|
591
|
+
" /guard paths clear\n\n" +
|
|
592
|
+
"Custom protected paths are guarded in EVERY mode (including naked).";
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* /guard paths … subcommand handler: list / add / rm / clear user-configured
|
|
596
|
+
* protected paths. Updates the in-memory config and persists to settings.json.
|
|
597
|
+
*/
|
|
598
|
+
async function handlePathsCommand(raw: string, ctx: ExtensionCommandContext) {
|
|
599
|
+
const rest = raw.replace(/^paths\s*/i, "").trim();
|
|
600
|
+
const spaceIdx = rest.indexOf(" ");
|
|
601
|
+
const sub = (spaceIdx === -1 ? rest : rest.slice(0, spaceIdx)).toLowerCase();
|
|
602
|
+
const arg = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
|
|
603
|
+
const show = (msg: string) => ctx.ui.notify(msg, "info");
|
|
604
|
+
|
|
605
|
+
switch (sub) {
|
|
606
|
+
case "list":
|
|
607
|
+
case "show":
|
|
608
|
+
if (extraProtected.length === 0) {
|
|
609
|
+
return show("Path Guard: no custom protected paths configured");
|
|
610
|
+
}
|
|
611
|
+
return show(
|
|
612
|
+
`Path Guard custom protected paths (${extraProtected.length}):\n` +
|
|
613
|
+
extraProtected.map((p) => `· ${p}`).join("\n"),
|
|
614
|
+
);
|
|
615
|
+
case "add": {
|
|
616
|
+
if (!arg) return show("Usage: /guard paths add <path>");
|
|
617
|
+
const norm = normalizeProtectedEntry(arg, ctx.cwd);
|
|
618
|
+
if (extraProtected.includes(norm)) {
|
|
619
|
+
return show(`Path Guard: already protected — ${norm}`);
|
|
620
|
+
}
|
|
621
|
+
extraProtected.push(norm);
|
|
622
|
+
const where = persistConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
623
|
+
return show(
|
|
624
|
+
`Path Guard: added protected path ${norm} (${persistNote(where)})`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
case "rm":
|
|
628
|
+
case "remove": {
|
|
629
|
+
if (!arg) return show("Usage: /guard paths rm <path>");
|
|
630
|
+
const norm = normalizeProtectedEntry(arg, ctx.cwd);
|
|
631
|
+
const idx = extraProtected.indexOf(norm);
|
|
632
|
+
if (idx === -1) {
|
|
633
|
+
return show(`Path Guard: not a custom protected path — ${norm}`);
|
|
634
|
+
}
|
|
635
|
+
extraProtected.splice(idx, 1);
|
|
636
|
+
const where = persistConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
637
|
+
return show(
|
|
638
|
+
`Path Guard: removed protected path ${norm} (${persistNote(where)})`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
case "clear": {
|
|
642
|
+
if (extraProtected.length === 0) {
|
|
643
|
+
return show("Path Guard: no custom protected paths to clear");
|
|
644
|
+
}
|
|
645
|
+
extraProtected = [];
|
|
646
|
+
const where = persistConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
647
|
+
return show(
|
|
648
|
+
`Path Guard: cleared all custom protected paths (${persistNote(where)})`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
default:
|
|
652
|
+
return show(PATHS_USAGE);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
271
656
|
export default function (pi: ExtensionAPI) {
|
|
272
|
-
//
|
|
657
|
+
// Restore the persisted config on every new session (startup, /new, /resume all
|
|
658
|
+
// fire session_start): active mode + user protected paths + rule overrides.
|
|
273
659
|
pi.on("session_start", (_event, ctx) => {
|
|
274
|
-
|
|
660
|
+
config = readSavedConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
661
|
+
extraProtected = config.extraProtected;
|
|
662
|
+
setMode(config.mode, ctx.ui);
|
|
275
663
|
});
|
|
276
664
|
|
|
277
|
-
// /guard slash command: view / switch
|
|
665
|
+
// /guard slash command: view / switch mode, and manage custom protected paths
|
|
278
666
|
pi.registerCommand("guard", {
|
|
279
667
|
description:
|
|
280
|
-
"Path Guard
|
|
668
|
+
"Path Guard: /guard shows mode, /guard <strict|normal|loose|trusted|naked> switches, /guard paths add|rm|list|clear <path> manages custom protected paths",
|
|
281
669
|
handler: async (args, ctx) => {
|
|
282
|
-
const
|
|
670
|
+
const raw = args?.trim() ?? "";
|
|
671
|
+
const m = raw.toLowerCase();
|
|
672
|
+
|
|
673
|
+
// ── /guard paths … : manage user-configured protected paths (any mode) ──
|
|
674
|
+
if (m === "paths" || m.startsWith("paths ")) {
|
|
675
|
+
return handlePathsCommand(raw, ctx);
|
|
676
|
+
}
|
|
283
677
|
|
|
284
678
|
// Valid argument → switch directly (shortcut, no picker); trusted/naked require a warning confirmation
|
|
285
679
|
if (isGuardMode(m)) {
|
|
@@ -290,10 +684,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
290
684
|
);
|
|
291
685
|
return;
|
|
292
686
|
}
|
|
293
|
-
|
|
294
|
-
|
|
687
|
+
config.mode = m;
|
|
688
|
+
setMode(m, ctx.ui);
|
|
689
|
+
const where = persistConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
295
690
|
ctx.ui.notify(
|
|
296
|
-
`Path Guard switched to: ${m} (
|
|
691
|
+
`Path Guard switched to: ${m} (${persistNote(where)})`,
|
|
297
692
|
"info",
|
|
298
693
|
);
|
|
299
694
|
return;
|
|
@@ -327,10 +722,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
327
722
|
);
|
|
328
723
|
return;
|
|
329
724
|
}
|
|
330
|
-
|
|
331
|
-
|
|
725
|
+
config.mode = picked;
|
|
726
|
+
setMode(picked, ctx.ui);
|
|
727
|
+
const where = persistConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
332
728
|
ctx.ui.notify(
|
|
333
|
-
`Path Guard switched to: ${picked} (
|
|
729
|
+
`Path Guard switched to: ${picked} (${persistNote(where)})`,
|
|
334
730
|
"info",
|
|
335
731
|
);
|
|
336
732
|
}
|
|
@@ -358,15 +754,23 @@ function checkWriteEdit(
|
|
|
358
754
|
const path = input.path;
|
|
359
755
|
if (!path) return;
|
|
360
756
|
|
|
361
|
-
// naked disables ALL checks (incl. protected paths & the write/edit tools) → pass everything
|
|
362
|
-
if (currentMode === "naked") return;
|
|
363
|
-
|
|
364
757
|
// Resolve the real cwd first (cwd may itself be a symlink), then the real target path,
|
|
365
758
|
// preventing symlink escape to protected locations and symlink-cwd false positives
|
|
366
759
|
const realCwd = resolveReal(ctx.cwd);
|
|
367
760
|
const real = resolveReal(resolve(realCwd, expandHome(path)));
|
|
368
761
|
|
|
369
|
-
// ①
|
|
762
|
+
// ① User-configured protected paths are guarded in EVERY mode (incl. naked).
|
|
763
|
+
if (isUserProtectedPath(real)) {
|
|
764
|
+
return {
|
|
765
|
+
block: true,
|
|
766
|
+
reason: `Path "${real}" is user-protected; write blocked.`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ② naked passes everything else (built-in protected paths & the write/edit tools).
|
|
771
|
+
if (currentMode === "naked") return;
|
|
772
|
+
|
|
773
|
+
// ③ Built-in protected path (incl. HOME-level credentials/config, inside or outside project) → block
|
|
370
774
|
if (matchesProtectedPath(real)) {
|
|
371
775
|
return {
|
|
372
776
|
block: true,
|
|
@@ -374,32 +778,43 @@ function checkWriteEdit(
|
|
|
374
778
|
};
|
|
375
779
|
}
|
|
376
780
|
|
|
377
|
-
|
|
378
|
-
if (isOutsideCwd(real, realCwd)) {
|
|
379
|
-
if (currentMode === "loose" || currentMode === "trusted") return;
|
|
380
|
-
return askConfirm(
|
|
381
|
-
ctx,
|
|
382
|
-
`⚠️ File path is outside the project directory\n\nPath: ${real}\nProject: ${realCwd}`,
|
|
383
|
-
);
|
|
384
|
-
}
|
|
781
|
+
const outside = isOutsideCwd(real, realCwd);
|
|
385
782
|
|
|
386
|
-
//
|
|
387
|
-
if (realCwd === HOME) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
783
|
+
// ④ Outside the project dir OR cwd is HOME → per writeOutside / writeHome rule
|
|
784
|
+
if (outside || realCwd === HOME) {
|
|
785
|
+
const rule = outside ? "writeOutside" : "writeHome";
|
|
786
|
+
const lvl = rl(rule);
|
|
787
|
+
if (lvl === "block") {
|
|
788
|
+
return {
|
|
789
|
+
block: true,
|
|
790
|
+
reason: `Write blocked by rule (${rule}): ${real}`,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
if (lvl === "confirm") {
|
|
794
|
+
return askConfirm(
|
|
795
|
+
ctx,
|
|
796
|
+
outside
|
|
797
|
+
? `⚠️ File path is outside the project directory\n\nPath: ${real}\nProject: ${realCwd}`
|
|
798
|
+
: `⚠️ Write operation in HOME directory\n\nPath: ${real}\nHOME: ${HOME}\n\nConfirm write?`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
return; // pass
|
|
393
802
|
}
|
|
394
803
|
|
|
395
|
-
//
|
|
396
|
-
|
|
804
|
+
// ⑤ In-project → per writeInProject rule
|
|
805
|
+
const lvl = rl("writeInProject");
|
|
806
|
+
if (lvl === "block") {
|
|
807
|
+
return {
|
|
808
|
+
block: true,
|
|
809
|
+
reason: `Write blocked by rule (writeInProject): ${real}`,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
if (lvl === "confirm") {
|
|
397
813
|
return askConfirm(
|
|
398
814
|
ctx,
|
|
399
815
|
`⚠️ strict mode: in-project write operation\n\nPath: ${real}\n\nConfirm write?`,
|
|
400
816
|
);
|
|
401
817
|
}
|
|
402
|
-
|
|
403
818
|
return; // In-project and safe: allow
|
|
404
819
|
}
|
|
405
820
|
|
|
@@ -465,21 +880,19 @@ function classifySegment(
|
|
|
465
880
|
// Recursion depth guard (bash -c / eval nested too deep to statically check → conservative confirm)
|
|
466
881
|
if (depth > 4) return { kind: "confirm" };
|
|
467
882
|
|
|
468
|
-
// naked: pass everything EXCEPT system-destructive Block-group commands, which are confirmed
|
|
469
|
-
// (protected paths, write/edit, git destructive, truncate, etc. all pass in naked mode)
|
|
470
|
-
if (currentMode === "naked") {
|
|
471
|
-
return dangerousLevel(trimmed) === "block"
|
|
472
|
-
? { kind: "confirm" }
|
|
473
|
-
: { kind: "pass" };
|
|
474
|
-
}
|
|
475
|
-
|
|
476
883
|
// ① Redirect check:
|
|
477
|
-
// - Write to a protected path (echo x > .env etc.) → block
|
|
478
|
-
// - "> existing file" (truncate, not >> append, not a device) →
|
|
884
|
+
// - Write to a protected path (echo x > .env etc.) → block in every mode (user paths too)
|
|
885
|
+
// - "> existing file" (truncate, not >> append, not a device) → per truncate rule
|
|
479
886
|
const redirect = extractRedirectTarget(trimmed);
|
|
480
887
|
if (redirect) {
|
|
481
888
|
const real = resolveReal(resolve(realCwd, expandHome(redirect.target)));
|
|
482
|
-
if (
|
|
889
|
+
if (isUserProtectedPath(real)) {
|
|
890
|
+
return {
|
|
891
|
+
kind: "block",
|
|
892
|
+
reason: `Redirect writes to user-protected path: ${trimmed}`,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (!inNaked() && matchesProtectedPath(real)) {
|
|
483
896
|
return {
|
|
484
897
|
kind: "block",
|
|
485
898
|
reason: `Redirect writes to protected path: ${trimmed}`,
|
|
@@ -490,33 +903,35 @@ function classifySegment(
|
|
|
490
903
|
!DEVICE_TARGETS.has(redirect.target) &&
|
|
491
904
|
existsSync(real)
|
|
492
905
|
) {
|
|
493
|
-
return
|
|
906
|
+
return ruleVerdict("truncate", `Truncate blocked by rule: ${trimmed}`);
|
|
494
907
|
}
|
|
495
908
|
}
|
|
496
909
|
|
|
497
|
-
// ② Dangerous commands
|
|
498
|
-
// - Block group (format/shutdown/bulk-delete/block-device writes) → blocked in every mode
|
|
499
|
-
// - Confirm group (sudo/ssh/chmod 777) → blocked in strict, confirmed otherwise
|
|
910
|
+
// ② Dangerous commands → per blockGroup / confirmGroup rule
|
|
500
911
|
const danger = dangerousLevel(trimmed);
|
|
501
912
|
if (danger === "block") {
|
|
502
|
-
return
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
913
|
+
return ruleVerdict(
|
|
914
|
+
"blockGroup",
|
|
915
|
+
`System-destructive command blocked: ${trimmed}`,
|
|
916
|
+
);
|
|
506
917
|
}
|
|
507
918
|
if (danger === "confirm") {
|
|
508
|
-
|
|
919
|
+
const lvl = rl("confirmGroup");
|
|
920
|
+
if (lvl === "block") {
|
|
509
921
|
return {
|
|
510
922
|
kind: "block",
|
|
511
|
-
reason: `Dangerous command blocked
|
|
923
|
+
reason: `Dangerous command blocked by rule: ${trimmed}`,
|
|
512
924
|
};
|
|
513
925
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
926
|
+
if (lvl === "confirm") {
|
|
927
|
+
return hasUI
|
|
928
|
+
? { kind: "confirm" }
|
|
929
|
+
: {
|
|
930
|
+
kind: "block",
|
|
931
|
+
reason: `Dangerous command blocked (no interactive UI): ${trimmed}`,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
return { kind: "pass" }; // confirmGroup = pass
|
|
520
935
|
}
|
|
521
936
|
|
|
522
937
|
const cmdInfo = parseCommand(trimmed);
|
|
@@ -532,9 +947,9 @@ function classifySegment(
|
|
|
532
947
|
);
|
|
533
948
|
if (wrapperVerdict.kind !== "pass") return wrapperVerdict;
|
|
534
949
|
|
|
535
|
-
// ④ source / .: runs a script file whose contents can't be statically analyzed → conservative confirm
|
|
950
|
+
// ④ source / .: runs a script file whose contents can't be statically analyzed → conservative confirm (pass in naked)
|
|
536
951
|
if (cmdInfo.command === "source" || cmdInfo.command === ".") {
|
|
537
|
-
return { kind: "confirm" };
|
|
952
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
538
953
|
}
|
|
539
954
|
|
|
540
955
|
// ⑤-⑪ Pipeline for target-writing commands (git / dd / download / truncate / in-place edit / delete / overwrite / unzip -o)
|
|
@@ -590,9 +1005,9 @@ function judgeWriters(
|
|
|
590
1005
|
const v = judge(trimmed, cmdInfo, realCwd);
|
|
591
1006
|
if (v.kind !== "pass") return v;
|
|
592
1007
|
}
|
|
593
|
-
// Forced extraction overwrite (unzip -o): archive contents unknowable → conservative confirm
|
|
1008
|
+
// Forced extraction overwrite (unzip -o): archive contents unknowable → conservative confirm (pass in naked)
|
|
594
1009
|
if (cmdInfo.command === "unzip" && hasShortFlag(cmdInfo.args, "o")) {
|
|
595
|
-
return { kind: "confirm" };
|
|
1010
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
596
1011
|
}
|
|
597
1012
|
return { kind: "pass" };
|
|
598
1013
|
}
|
|
@@ -628,20 +1043,30 @@ function judgeGit(
|
|
|
628
1043
|
}
|
|
629
1044
|
const sub = args[i];
|
|
630
1045
|
|
|
631
|
-
|
|
632
|
-
if (sub === "
|
|
1046
|
+
// Destructive git ops → per gitDestructive rule (block / confirm / pass)
|
|
1047
|
+
if (sub === "clean" && hasForceFlag(args))
|
|
1048
|
+
return ruleVerdict("gitDestructive", "git clean --force blocked by rule");
|
|
1049
|
+
if (sub === "reset" && args.includes("--hard"))
|
|
1050
|
+
return ruleVerdict("gitDestructive", "git reset --hard blocked by rule");
|
|
633
1051
|
if (sub === "checkout" && (args.includes("--") || args.includes(".")))
|
|
634
|
-
return
|
|
1052
|
+
return ruleVerdict(
|
|
1053
|
+
"gitDestructive",
|
|
1054
|
+
"git checkout destructive blocked by rule",
|
|
1055
|
+
);
|
|
635
1056
|
if (sub === "restore" && (args.includes(".") || args.includes("--source")))
|
|
636
|
-
return
|
|
1057
|
+
return ruleVerdict(
|
|
1058
|
+
"gitDestructive",
|
|
1059
|
+
"git restore destructive blocked by rule",
|
|
1060
|
+
);
|
|
637
1061
|
if (sub === "branch" && args.some((a) => a === "-D"))
|
|
638
|
-
return
|
|
1062
|
+
return ruleVerdict("gitDestructive", "git branch -D blocked by rule");
|
|
639
1063
|
if (
|
|
640
1064
|
sub === "push" &&
|
|
641
1065
|
args.some((a) => a === "-f" || a === "--force" || a === "--force-with-lease")
|
|
642
1066
|
)
|
|
643
|
-
return
|
|
644
|
-
if (sub === "stash" && args.includes("drop"))
|
|
1067
|
+
return ruleVerdict("gitDestructive", "git push --force blocked by rule");
|
|
1068
|
+
if (sub === "stash" && args.includes("drop"))
|
|
1069
|
+
return ruleVerdict("gitDestructive", "git stash drop blocked by rule");
|
|
645
1070
|
|
|
646
1071
|
return { kind: "pass" };
|
|
647
1072
|
}
|
|
@@ -664,9 +1089,15 @@ function judgeDelete(
|
|
|
664
1089
|
|
|
665
1090
|
const pathArgs = extractPathArgs(cmdInfo.args, realCwd);
|
|
666
1091
|
|
|
667
|
-
// Protected paths first:
|
|
1092
|
+
// Protected paths first: user paths block in EVERY mode (incl. naked); built-in paths block except in naked
|
|
668
1093
|
for (const p of pathArgs) {
|
|
669
|
-
if (
|
|
1094
|
+
if (isUserProtectedPath(p.path)) {
|
|
1095
|
+
return {
|
|
1096
|
+
kind: "block",
|
|
1097
|
+
reason: `Delete command targets user-protected path: ${p.path}`,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
if (!inNaked() && matchesProtectedPath(p.path)) {
|
|
670
1101
|
return {
|
|
671
1102
|
kind: "block",
|
|
672
1103
|
reason: `Delete command targets protected path: ${p.path}`,
|
|
@@ -674,28 +1105,23 @@ function judgeDelete(
|
|
|
674
1105
|
}
|
|
675
1106
|
}
|
|
676
1107
|
|
|
677
|
-
// No concrete path (rm "$HOME/.ssh", rm ./* — variable/wildcard, not statically resolvable) → conservative confirm (
|
|
1108
|
+
// No concrete path (rm "$HOME/.ssh", rm ./* — variable/wildcard, not statically resolvable) → conservative confirm (pass in naked)
|
|
678
1109
|
if (pathArgs.length === 0) {
|
|
679
|
-
return { kind: "confirm" };
|
|
1110
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
680
1111
|
}
|
|
681
1112
|
|
|
682
1113
|
const externalPaths = pathArgs.filter((p) => p.isOutside);
|
|
683
1114
|
if (externalPaths.length > 0) {
|
|
684
1115
|
const list = externalPaths.map((p) => p.path).join(", ");
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
reason: `Delete command targets paths outside the project directory: ${list}`,
|
|
691
|
-
};
|
|
1116
|
+
// per deleteOutside rule: strict/normal block, loose confirm, trusted/naked pass
|
|
1117
|
+
return ruleVerdict(
|
|
1118
|
+
"deleteOutside",
|
|
1119
|
+
`Delete command targets paths outside the project directory: ${list}`,
|
|
1120
|
+
);
|
|
692
1121
|
}
|
|
693
1122
|
|
|
694
|
-
// In-project delete
|
|
695
|
-
|
|
696
|
-
return { kind: "pass" };
|
|
697
|
-
}
|
|
698
|
-
return { kind: "confirm" };
|
|
1123
|
+
// In-project delete → per deleteInProject rule (strict/normal confirm; loose/trusted/naked pass)
|
|
1124
|
+
return ruleVerdict("deleteInProject", "Delete command blocked by rule");
|
|
699
1125
|
}
|
|
700
1126
|
|
|
701
1127
|
/**
|
|
@@ -727,9 +1153,9 @@ function judgeOverwrite(
|
|
|
727
1153
|
) {
|
|
728
1154
|
return { kind: "pass" };
|
|
729
1155
|
}
|
|
730
|
-
// rsync --delete: removes extra files in the target dir → conservative confirm
|
|
1156
|
+
// rsync --delete: removes extra files in the target dir → conservative confirm (pass in naked)
|
|
731
1157
|
if (cmdInfo.command === "rsync" && cmdInfo.args.includes("--delete")) {
|
|
732
|
-
return { kind: "confirm" };
|
|
1158
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
733
1159
|
}
|
|
734
1160
|
|
|
735
1161
|
// Resolve target: -t dir src... form vs the regular form (last operand is the target)
|
|
@@ -748,14 +1174,20 @@ function judgeOverwrite(
|
|
|
748
1174
|
}
|
|
749
1175
|
if (!target || sources.length === 0) return { kind: "pass" };
|
|
750
1176
|
|
|
751
|
-
// Variable/wildcard not statically resolvable → conservative confirm
|
|
1177
|
+
// Variable/wildcard not statically resolvable → conservative confirm (pass in naked)
|
|
752
1178
|
if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
|
|
753
|
-
return { kind: "confirm" };
|
|
1179
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
754
1180
|
}
|
|
755
1181
|
|
|
756
1182
|
const real = resolveReal(resolve(realCwd, expandHome(target)));
|
|
757
|
-
// ① Target hits a protected path → block
|
|
758
|
-
if (
|
|
1183
|
+
// ① Target hits a protected path → block (user paths in every mode; built-in except naked)
|
|
1184
|
+
if (isUserProtectedPath(real)) {
|
|
1185
|
+
return {
|
|
1186
|
+
kind: "block",
|
|
1187
|
+
reason: `Command may overwrite user-protected path: ${cmdInfo.command} ${target}`,
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
if (!inNaked() && matchesProtectedPath(real)) {
|
|
759
1191
|
return {
|
|
760
1192
|
kind: "block",
|
|
761
1193
|
reason: `Command may overwrite protected path: ${cmdInfo.command} ${target}`,
|
|
@@ -764,15 +1196,12 @@ function judgeOverwrite(
|
|
|
764
1196
|
|
|
765
1197
|
const outside = isOutsideCwd(real, realCwd);
|
|
766
1198
|
|
|
767
|
-
// Outside overwrite of an existing target
|
|
768
|
-
const outsideOverwriteVerdict = (): SegmentVerdict =>
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
reason: `Command will overwrite a target outside the project directory: ${cmdInfo.command} ${target}`,
|
|
774
|
-
};
|
|
775
|
-
};
|
|
1199
|
+
// Outside overwrite of an existing target → per overwriteOutsideExisting rule
|
|
1200
|
+
const outsideOverwriteVerdict = (): SegmentVerdict =>
|
|
1201
|
+
ruleVerdict(
|
|
1202
|
+
"overwriteOutsideExisting",
|
|
1203
|
+
`Command will overwrite a target outside the project directory: ${cmdInfo.command} ${target}`,
|
|
1204
|
+
);
|
|
776
1205
|
|
|
777
1206
|
// ② Target is an existing directory: check each source basename for conflicts
|
|
778
1207
|
if (existsSync(real) && isDirectory(real)) {
|
|
@@ -783,23 +1212,27 @@ function judgeOverwrite(
|
|
|
783
1212
|
return existsSync(join(real, basename(srcReal)));
|
|
784
1213
|
});
|
|
785
1214
|
if (!conflict) return { kind: "pass" };
|
|
786
|
-
// Overwriting an existing target: outside per
|
|
787
|
-
return outside
|
|
1215
|
+
// Overwriting an existing target: outside per overwriteOutsideExisting; in-project per overwriteInProject
|
|
1216
|
+
return outside
|
|
1217
|
+
? outsideOverwriteVerdict()
|
|
1218
|
+
: ruleVerdict("overwriteInProject", "Overwrite in project blocked by rule");
|
|
788
1219
|
}
|
|
789
1220
|
|
|
790
1221
|
// ③ Target is an existing file: will be overwritten
|
|
791
1222
|
if (existsSync(real)) {
|
|
792
|
-
return outside
|
|
1223
|
+
return outside
|
|
1224
|
+
? outsideOverwriteVerdict()
|
|
1225
|
+
: ruleVerdict("overwriteInProject", "Overwrite in project blocked by rule");
|
|
793
1226
|
}
|
|
794
1227
|
|
|
795
|
-
// ④ Target missing: outside →
|
|
796
|
-
// in-project → strict confirm, others pass (pure rename/create)
|
|
1228
|
+
// ④ Target missing: outside → per overwriteOutsideNew; in-project → per writeInProject (strict confirm, others pass)
|
|
797
1229
|
if (outside) {
|
|
798
|
-
return
|
|
799
|
-
|
|
800
|
-
|
|
1230
|
+
return ruleVerdict(
|
|
1231
|
+
"overwriteOutsideNew",
|
|
1232
|
+
`Write outside the project blocked by rule: ${cmdInfo.command} ${target}`,
|
|
1233
|
+
);
|
|
801
1234
|
}
|
|
802
|
-
return
|
|
1235
|
+
return ruleVerdict("writeInProject", "Write in project blocked by rule");
|
|
803
1236
|
}
|
|
804
1237
|
|
|
805
1238
|
/** Unwrap a shell wrapper: bash/sh/zsh -c 'code', eval 'code' → inner code; else null */
|
|
@@ -847,10 +1280,16 @@ function judgeDd(
|
|
|
847
1280
|
const target = a.slice(3);
|
|
848
1281
|
if (!target) continue;
|
|
849
1282
|
if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
|
|
850
|
-
return { kind: "confirm" };
|
|
1283
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
851
1284
|
}
|
|
852
1285
|
const real = resolveReal(resolve(realCwd, expandHome(target)));
|
|
853
|
-
if (
|
|
1286
|
+
if (isUserProtectedPath(real)) {
|
|
1287
|
+
return {
|
|
1288
|
+
kind: "block",
|
|
1289
|
+
reason: `dd writes to user-protected path: ${trimmed}`,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
if (!inNaked() && matchesProtectedPath(real)) {
|
|
854
1293
|
return { kind: "block", reason: `dd writes to protected path: ${trimmed}` };
|
|
855
1294
|
}
|
|
856
1295
|
}
|
|
@@ -869,10 +1308,16 @@ function judgeDownload(
|
|
|
869
1308
|
const target = downloadTarget(cmdInfo.command, cmdInfo.args);
|
|
870
1309
|
if (!target) return { kind: "pass" };
|
|
871
1310
|
if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
|
|
872
|
-
return { kind: "confirm" };
|
|
1311
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
873
1312
|
}
|
|
874
1313
|
const real = resolveReal(resolve(realCwd, expandHome(target)));
|
|
875
|
-
if (
|
|
1314
|
+
if (isUserProtectedPath(real)) {
|
|
1315
|
+
return {
|
|
1316
|
+
kind: "block",
|
|
1317
|
+
reason: `Download writes to user-protected path: ${cmdInfo.command} ${target}`,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
if (!inNaked() && matchesProtectedPath(real)) {
|
|
876
1321
|
return {
|
|
877
1322
|
kind: "block",
|
|
878
1323
|
reason: `Download writes to protected path: ${cmdInfo.command} ${target}`,
|
|
@@ -932,7 +1377,7 @@ function judgeTruncate(
|
|
|
932
1377
|
realCwd: string,
|
|
933
1378
|
): SegmentVerdict {
|
|
934
1379
|
if (cmdInfo.command !== "truncate") return { kind: "pass" };
|
|
935
|
-
// Any target not statically resolvable (variable/wildcard) → conservative confirm
|
|
1380
|
+
// Any target not statically resolvable (variable/wildcard) → conservative confirm (pass in naked)
|
|
936
1381
|
if (
|
|
937
1382
|
cmdInfo.args.some(
|
|
938
1383
|
(a) =>
|
|
@@ -940,18 +1385,24 @@ function judgeTruncate(
|
|
|
940
1385
|
(a.startsWith("$") || a.includes("*") || a.includes("?")),
|
|
941
1386
|
)
|
|
942
1387
|
) {
|
|
943
|
-
return { kind: "confirm" };
|
|
1388
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
944
1389
|
}
|
|
945
1390
|
for (const t of extractPathArgs(cmdInfo.args, realCwd)) {
|
|
946
|
-
if (
|
|
1391
|
+
if (isUserProtectedPath(t.path)) {
|
|
1392
|
+
return {
|
|
1393
|
+
kind: "block",
|
|
1394
|
+
reason: `truncate truncates user-protected path: ${t.raw}`,
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
if (!inNaked() && matchesProtectedPath(t.path)) {
|
|
947
1398
|
return {
|
|
948
1399
|
kind: "block",
|
|
949
1400
|
reason: `truncate truncates protected path: ${t.raw}`,
|
|
950
1401
|
};
|
|
951
1402
|
}
|
|
952
|
-
// Existing ordinary file truncated →
|
|
1403
|
+
// Existing ordinary file truncated → per truncate rule
|
|
953
1404
|
if (!DEVICE_TARGETS.has(t.path) && existsSync(t.path)) {
|
|
954
|
-
return
|
|
1405
|
+
return ruleVerdict("truncate", `Truncate blocked by rule: ${t.raw}`);
|
|
955
1406
|
}
|
|
956
1407
|
}
|
|
957
1408
|
return { kind: "pass" };
|
|
@@ -974,10 +1425,16 @@ function judgeInPlace(
|
|
|
974
1425
|
const dest = lastDestArg(cmdInfo.args);
|
|
975
1426
|
if (!dest) return { kind: "pass" };
|
|
976
1427
|
if (dest.startsWith("$") || dest.includes("*") || dest.includes("?")) {
|
|
977
|
-
return { kind: "confirm" };
|
|
1428
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
978
1429
|
}
|
|
979
1430
|
const real = resolveReal(resolve(realCwd, expandHome(dest)));
|
|
980
|
-
if (
|
|
1431
|
+
if (isUserProtectedPath(real)) {
|
|
1432
|
+
return {
|
|
1433
|
+
kind: "block",
|
|
1434
|
+
reason: `In-place edit of user-protected path: ${cmdInfo.command} ${dest}`,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
if (!inNaked() && matchesProtectedPath(real)) {
|
|
981
1438
|
return {
|
|
982
1439
|
kind: "block",
|
|
983
1440
|
reason: `In-place edit of protected path: ${cmdInfo.command} ${dest}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaosu/pi-path-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Path Guard for pi — blocks destructive commands & path overwrites, protects .env/keys, with strict/normal/loose/trusted/naked guard modes (/guard). pi 防误删/防误覆盖扩展,支持 5 种防护模式。",
|
|
6
6
|
"keywords": [
|