@yaosu/pi-path-guard 1.2.0 → 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 +36 -2
- package/extensions/path-guard.ts +509 -152
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,8 +36,42 @@ After installing, run `/reload` or restart pi. 安装后 `/reload` 或重启 pi
|
|
|
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
|
@@ -47,6 +47,13 @@
|
|
|
47
47
|
* settings.json on session_start (project .pi/settings.json overrides the global
|
|
48
48
|
* ~/.pi/agent/settings.json, falling back to normal) and written back when /guard switches
|
|
49
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.
|
|
50
57
|
*/
|
|
51
58
|
|
|
52
59
|
import type {
|
|
@@ -259,6 +266,163 @@ const MODE_MATRIX = [
|
|
|
259
266
|
" No UI (headless) B B B B .",
|
|
260
267
|
].join("\n");
|
|
261
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
|
+
|
|
262
426
|
/** Guard verdict: { block, reason } to block / undefined to allow (askConfirm returns a Promise) */
|
|
263
427
|
type GuardVerdict =
|
|
264
428
|
| ToolCallEventResult
|
|
@@ -296,46 +460,93 @@ function projectSettingsPath(cwd: string | undefined): string | undefined {
|
|
|
296
460
|
return cwd ? join(cwd, CONFIG_DIR, "settings.json") : undefined;
|
|
297
461
|
}
|
|
298
462
|
|
|
299
|
-
/**
|
|
300
|
-
|
|
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 {
|
|
301
479
|
if (!filePath) return undefined;
|
|
302
480
|
try {
|
|
303
481
|
if (!existsSync(filePath)) return undefined;
|
|
304
482
|
const data = JSON.parse(readFileSync(filePath, "utf8")) as {
|
|
305
|
-
pathGuard?: {
|
|
483
|
+
pathGuard?: {
|
|
484
|
+
mode?: string;
|
|
485
|
+
extraProtected?: string[];
|
|
486
|
+
rules?: Record<string, Record<string, string>>;
|
|
487
|
+
};
|
|
306
488
|
};
|
|
307
|
-
const
|
|
308
|
-
|
|
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;
|
|
309
514
|
} catch {
|
|
310
515
|
return undefined;
|
|
311
516
|
}
|
|
312
517
|
}
|
|
313
518
|
|
|
314
519
|
/**
|
|
315
|
-
* Effective
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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.
|
|
318
525
|
*/
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 };
|
|
326
541
|
}
|
|
327
542
|
|
|
328
543
|
/**
|
|
329
|
-
* Persist
|
|
330
|
-
* settings file (cwd/.pi/settings.json); otherwise the global settings
|
|
331
|
-
* Requires a cwd; returns "project" | "global" | "none"
|
|
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".
|
|
332
547
|
*/
|
|
333
|
-
function
|
|
334
|
-
|
|
335
|
-
cwd: string | undefined,
|
|
336
|
-
trusted: boolean,
|
|
337
|
-
): string {
|
|
338
|
-
if (!cwd) return "none"; // no context to persist to
|
|
548
|
+
function persistConfig(cwd: string | undefined, trusted: boolean): string {
|
|
549
|
+
if (!cwd) return "none";
|
|
339
550
|
const target = trusted ? projectSettingsPath(cwd) : globalSettingsPath();
|
|
340
551
|
if (!target) return "none";
|
|
341
552
|
try {
|
|
@@ -344,7 +555,17 @@ function persistMode(
|
|
|
344
555
|
data = JSON.parse(readFileSync(target, "utf8")) as Record<string, unknown>;
|
|
345
556
|
}
|
|
346
557
|
const guard = (data.pathGuard as Record<string, unknown>) ?? {};
|
|
347
|
-
guard.mode = mode;
|
|
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
|
+
}
|
|
348
569
|
data.pathGuard = guard;
|
|
349
570
|
writeFileSync(target, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
350
571
|
return trusted ? "project" : "global";
|
|
@@ -361,19 +582,98 @@ function persistNote(where: string): string {
|
|
|
361
582
|
return "session-only (not persisted)";
|
|
362
583
|
}
|
|
363
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
|
+
|
|
364
656
|
export default function (pi: ExtensionAPI) {
|
|
365
|
-
// Restore the persisted
|
|
366
|
-
// fire session_start)
|
|
657
|
+
// Restore the persisted config on every new session (startup, /new, /resume all
|
|
658
|
+
// fire session_start): active mode + user protected paths + rule overrides.
|
|
367
659
|
pi.on("session_start", (_event, ctx) => {
|
|
368
|
-
|
|
660
|
+
config = readSavedConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
661
|
+
extraProtected = config.extraProtected;
|
|
662
|
+
setMode(config.mode, ctx.ui);
|
|
369
663
|
});
|
|
370
664
|
|
|
371
|
-
// /guard slash command: view / switch
|
|
665
|
+
// /guard slash command: view / switch mode, and manage custom protected paths
|
|
372
666
|
pi.registerCommand("guard", {
|
|
373
667
|
description:
|
|
374
|
-
"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",
|
|
375
669
|
handler: async (args, ctx) => {
|
|
376
|
-
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
|
+
}
|
|
377
677
|
|
|
378
678
|
// Valid argument → switch directly (shortcut, no picker); trusted/naked require a warning confirmation
|
|
379
679
|
if (isGuardMode(m)) {
|
|
@@ -384,9 +684,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
384
684
|
);
|
|
385
685
|
return;
|
|
386
686
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const where =
|
|
687
|
+
config.mode = m;
|
|
688
|
+
setMode(m, ctx.ui);
|
|
689
|
+
const where = persistConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
390
690
|
ctx.ui.notify(
|
|
391
691
|
`Path Guard switched to: ${m} (${persistNote(where)})`,
|
|
392
692
|
"info",
|
|
@@ -422,13 +722,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
422
722
|
);
|
|
423
723
|
return;
|
|
424
724
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const where =
|
|
428
|
-
picked,
|
|
429
|
-
ctx.cwd,
|
|
430
|
-
ctx.isProjectTrusted?.() === true,
|
|
431
|
-
);
|
|
725
|
+
config.mode = picked;
|
|
726
|
+
setMode(picked, ctx.ui);
|
|
727
|
+
const where = persistConfig(ctx.cwd, ctx.isProjectTrusted?.() === true);
|
|
432
728
|
ctx.ui.notify(
|
|
433
729
|
`Path Guard switched to: ${picked} (${persistNote(where)})`,
|
|
434
730
|
"info",
|
|
@@ -458,15 +754,23 @@ function checkWriteEdit(
|
|
|
458
754
|
const path = input.path;
|
|
459
755
|
if (!path) return;
|
|
460
756
|
|
|
461
|
-
// naked disables ALL checks (incl. protected paths & the write/edit tools) → pass everything
|
|
462
|
-
if (currentMode === "naked") return;
|
|
463
|
-
|
|
464
757
|
// Resolve the real cwd first (cwd may itself be a symlink), then the real target path,
|
|
465
758
|
// preventing symlink escape to protected locations and symlink-cwd false positives
|
|
466
759
|
const realCwd = resolveReal(ctx.cwd);
|
|
467
760
|
const real = resolveReal(resolve(realCwd, expandHome(path)));
|
|
468
761
|
|
|
469
|
-
// ①
|
|
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
|
|
470
774
|
if (matchesProtectedPath(real)) {
|
|
471
775
|
return {
|
|
472
776
|
block: true,
|
|
@@ -474,32 +778,43 @@ function checkWriteEdit(
|
|
|
474
778
|
};
|
|
475
779
|
}
|
|
476
780
|
|
|
477
|
-
|
|
478
|
-
if (isOutsideCwd(real, realCwd)) {
|
|
479
|
-
if (currentMode === "loose" || currentMode === "trusted") return;
|
|
480
|
-
return askConfirm(
|
|
481
|
-
ctx,
|
|
482
|
-
`⚠️ File path is outside the project directory\n\nPath: ${real}\nProject: ${realCwd}`,
|
|
483
|
-
);
|
|
484
|
-
}
|
|
781
|
+
const outside = isOutsideCwd(real, realCwd);
|
|
485
782
|
|
|
486
|
-
//
|
|
487
|
-
if (realCwd === HOME) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
493
802
|
}
|
|
494
803
|
|
|
495
|
-
//
|
|
496
|
-
|
|
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") {
|
|
497
813
|
return askConfirm(
|
|
498
814
|
ctx,
|
|
499
815
|
`⚠️ strict mode: in-project write operation\n\nPath: ${real}\n\nConfirm write?`,
|
|
500
816
|
);
|
|
501
817
|
}
|
|
502
|
-
|
|
503
818
|
return; // In-project and safe: allow
|
|
504
819
|
}
|
|
505
820
|
|
|
@@ -565,21 +880,19 @@ function classifySegment(
|
|
|
565
880
|
// Recursion depth guard (bash -c / eval nested too deep to statically check → conservative confirm)
|
|
566
881
|
if (depth > 4) return { kind: "confirm" };
|
|
567
882
|
|
|
568
|
-
// naked: pass everything EXCEPT system-destructive Block-group commands, which are confirmed
|
|
569
|
-
// (protected paths, write/edit, git destructive, truncate, etc. all pass in naked mode)
|
|
570
|
-
if (currentMode === "naked") {
|
|
571
|
-
return dangerousLevel(trimmed) === "block"
|
|
572
|
-
? { kind: "confirm" }
|
|
573
|
-
: { kind: "pass" };
|
|
574
|
-
}
|
|
575
|
-
|
|
576
883
|
// ① Redirect check:
|
|
577
|
-
// - Write to a protected path (echo x > .env etc.) → block
|
|
578
|
-
// - "> 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
|
|
579
886
|
const redirect = extractRedirectTarget(trimmed);
|
|
580
887
|
if (redirect) {
|
|
581
888
|
const real = resolveReal(resolve(realCwd, expandHome(redirect.target)));
|
|
582
|
-
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)) {
|
|
583
896
|
return {
|
|
584
897
|
kind: "block",
|
|
585
898
|
reason: `Redirect writes to protected path: ${trimmed}`,
|
|
@@ -590,33 +903,35 @@ function classifySegment(
|
|
|
590
903
|
!DEVICE_TARGETS.has(redirect.target) &&
|
|
591
904
|
existsSync(real)
|
|
592
905
|
) {
|
|
593
|
-
return
|
|
906
|
+
return ruleVerdict("truncate", `Truncate blocked by rule: ${trimmed}`);
|
|
594
907
|
}
|
|
595
908
|
}
|
|
596
909
|
|
|
597
|
-
// ② Dangerous commands
|
|
598
|
-
// - Block group (format/shutdown/bulk-delete/block-device writes) → blocked in every mode
|
|
599
|
-
// - Confirm group (sudo/ssh/chmod 777) → blocked in strict, confirmed otherwise
|
|
910
|
+
// ② Dangerous commands → per blockGroup / confirmGroup rule
|
|
600
911
|
const danger = dangerousLevel(trimmed);
|
|
601
912
|
if (danger === "block") {
|
|
602
|
-
return
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
913
|
+
return ruleVerdict(
|
|
914
|
+
"blockGroup",
|
|
915
|
+
`System-destructive command blocked: ${trimmed}`,
|
|
916
|
+
);
|
|
606
917
|
}
|
|
607
918
|
if (danger === "confirm") {
|
|
608
|
-
|
|
919
|
+
const lvl = rl("confirmGroup");
|
|
920
|
+
if (lvl === "block") {
|
|
609
921
|
return {
|
|
610
922
|
kind: "block",
|
|
611
|
-
reason: `Dangerous command blocked
|
|
923
|
+
reason: `Dangerous command blocked by rule: ${trimmed}`,
|
|
612
924
|
};
|
|
613
925
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
620
935
|
}
|
|
621
936
|
|
|
622
937
|
const cmdInfo = parseCommand(trimmed);
|
|
@@ -632,9 +947,9 @@ function classifySegment(
|
|
|
632
947
|
);
|
|
633
948
|
if (wrapperVerdict.kind !== "pass") return wrapperVerdict;
|
|
634
949
|
|
|
635
|
-
// ④ 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)
|
|
636
951
|
if (cmdInfo.command === "source" || cmdInfo.command === ".") {
|
|
637
|
-
return { kind: "confirm" };
|
|
952
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
638
953
|
}
|
|
639
954
|
|
|
640
955
|
// ⑤-⑪ Pipeline for target-writing commands (git / dd / download / truncate / in-place edit / delete / overwrite / unzip -o)
|
|
@@ -690,9 +1005,9 @@ function judgeWriters(
|
|
|
690
1005
|
const v = judge(trimmed, cmdInfo, realCwd);
|
|
691
1006
|
if (v.kind !== "pass") return v;
|
|
692
1007
|
}
|
|
693
|
-
// Forced extraction overwrite (unzip -o): archive contents unknowable → conservative confirm
|
|
1008
|
+
// Forced extraction overwrite (unzip -o): archive contents unknowable → conservative confirm (pass in naked)
|
|
694
1009
|
if (cmdInfo.command === "unzip" && hasShortFlag(cmdInfo.args, "o")) {
|
|
695
|
-
return { kind: "confirm" };
|
|
1010
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
696
1011
|
}
|
|
697
1012
|
return { kind: "pass" };
|
|
698
1013
|
}
|
|
@@ -728,20 +1043,30 @@ function judgeGit(
|
|
|
728
1043
|
}
|
|
729
1044
|
const sub = args[i];
|
|
730
1045
|
|
|
731
|
-
|
|
732
|
-
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");
|
|
733
1051
|
if (sub === "checkout" && (args.includes("--") || args.includes(".")))
|
|
734
|
-
return
|
|
1052
|
+
return ruleVerdict(
|
|
1053
|
+
"gitDestructive",
|
|
1054
|
+
"git checkout destructive blocked by rule",
|
|
1055
|
+
);
|
|
735
1056
|
if (sub === "restore" && (args.includes(".") || args.includes("--source")))
|
|
736
|
-
return
|
|
1057
|
+
return ruleVerdict(
|
|
1058
|
+
"gitDestructive",
|
|
1059
|
+
"git restore destructive blocked by rule",
|
|
1060
|
+
);
|
|
737
1061
|
if (sub === "branch" && args.some((a) => a === "-D"))
|
|
738
|
-
return
|
|
1062
|
+
return ruleVerdict("gitDestructive", "git branch -D blocked by rule");
|
|
739
1063
|
if (
|
|
740
1064
|
sub === "push" &&
|
|
741
1065
|
args.some((a) => a === "-f" || a === "--force" || a === "--force-with-lease")
|
|
742
1066
|
)
|
|
743
|
-
return
|
|
744
|
-
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");
|
|
745
1070
|
|
|
746
1071
|
return { kind: "pass" };
|
|
747
1072
|
}
|
|
@@ -764,9 +1089,15 @@ function judgeDelete(
|
|
|
764
1089
|
|
|
765
1090
|
const pathArgs = extractPathArgs(cmdInfo.args, realCwd);
|
|
766
1091
|
|
|
767
|
-
// Protected paths first:
|
|
1092
|
+
// Protected paths first: user paths block in EVERY mode (incl. naked); built-in paths block except in naked
|
|
768
1093
|
for (const p of pathArgs) {
|
|
769
|
-
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)) {
|
|
770
1101
|
return {
|
|
771
1102
|
kind: "block",
|
|
772
1103
|
reason: `Delete command targets protected path: ${p.path}`,
|
|
@@ -774,28 +1105,23 @@ function judgeDelete(
|
|
|
774
1105
|
}
|
|
775
1106
|
}
|
|
776
1107
|
|
|
777
|
-
// 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)
|
|
778
1109
|
if (pathArgs.length === 0) {
|
|
779
|
-
return { kind: "confirm" };
|
|
1110
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
780
1111
|
}
|
|
781
1112
|
|
|
782
1113
|
const externalPaths = pathArgs.filter((p) => p.isOutside);
|
|
783
1114
|
if (externalPaths.length > 0) {
|
|
784
1115
|
const list = externalPaths.map((p) => p.path).join(", ");
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
reason: `Delete command targets paths outside the project directory: ${list}`,
|
|
791
|
-
};
|
|
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
|
+
);
|
|
792
1121
|
}
|
|
793
1122
|
|
|
794
|
-
// In-project delete
|
|
795
|
-
|
|
796
|
-
return { kind: "pass" };
|
|
797
|
-
}
|
|
798
|
-
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");
|
|
799
1125
|
}
|
|
800
1126
|
|
|
801
1127
|
/**
|
|
@@ -827,9 +1153,9 @@ function judgeOverwrite(
|
|
|
827
1153
|
) {
|
|
828
1154
|
return { kind: "pass" };
|
|
829
1155
|
}
|
|
830
|
-
// 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)
|
|
831
1157
|
if (cmdInfo.command === "rsync" && cmdInfo.args.includes("--delete")) {
|
|
832
|
-
return { kind: "confirm" };
|
|
1158
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
833
1159
|
}
|
|
834
1160
|
|
|
835
1161
|
// Resolve target: -t dir src... form vs the regular form (last operand is the target)
|
|
@@ -848,14 +1174,20 @@ function judgeOverwrite(
|
|
|
848
1174
|
}
|
|
849
1175
|
if (!target || sources.length === 0) return { kind: "pass" };
|
|
850
1176
|
|
|
851
|
-
// Variable/wildcard not statically resolvable → conservative confirm
|
|
1177
|
+
// Variable/wildcard not statically resolvable → conservative confirm (pass in naked)
|
|
852
1178
|
if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
|
|
853
|
-
return { kind: "confirm" };
|
|
1179
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
854
1180
|
}
|
|
855
1181
|
|
|
856
1182
|
const real = resolveReal(resolve(realCwd, expandHome(target)));
|
|
857
|
-
// ① Target hits a protected path → block
|
|
858
|
-
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)) {
|
|
859
1191
|
return {
|
|
860
1192
|
kind: "block",
|
|
861
1193
|
reason: `Command may overwrite protected path: ${cmdInfo.command} ${target}`,
|
|
@@ -864,15 +1196,12 @@ function judgeOverwrite(
|
|
|
864
1196
|
|
|
865
1197
|
const outside = isOutsideCwd(real, realCwd);
|
|
866
1198
|
|
|
867
|
-
// Outside overwrite of an existing target
|
|
868
|
-
const outsideOverwriteVerdict = (): SegmentVerdict =>
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
reason: `Command will overwrite a target outside the project directory: ${cmdInfo.command} ${target}`,
|
|
874
|
-
};
|
|
875
|
-
};
|
|
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
|
+
);
|
|
876
1205
|
|
|
877
1206
|
// ② Target is an existing directory: check each source basename for conflicts
|
|
878
1207
|
if (existsSync(real) && isDirectory(real)) {
|
|
@@ -883,23 +1212,27 @@ function judgeOverwrite(
|
|
|
883
1212
|
return existsSync(join(real, basename(srcReal)));
|
|
884
1213
|
});
|
|
885
1214
|
if (!conflict) return { kind: "pass" };
|
|
886
|
-
// Overwriting an existing target: outside per
|
|
887
|
-
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");
|
|
888
1219
|
}
|
|
889
1220
|
|
|
890
1221
|
// ③ Target is an existing file: will be overwritten
|
|
891
1222
|
if (existsSync(real)) {
|
|
892
|
-
return outside
|
|
1223
|
+
return outside
|
|
1224
|
+
? outsideOverwriteVerdict()
|
|
1225
|
+
: ruleVerdict("overwriteInProject", "Overwrite in project blocked by rule");
|
|
893
1226
|
}
|
|
894
1227
|
|
|
895
|
-
// ④ Target missing: outside →
|
|
896
|
-
// in-project → strict confirm, others pass (pure rename/create)
|
|
1228
|
+
// ④ Target missing: outside → per overwriteOutsideNew; in-project → per writeInProject (strict confirm, others pass)
|
|
897
1229
|
if (outside) {
|
|
898
|
-
return
|
|
899
|
-
|
|
900
|
-
|
|
1230
|
+
return ruleVerdict(
|
|
1231
|
+
"overwriteOutsideNew",
|
|
1232
|
+
`Write outside the project blocked by rule: ${cmdInfo.command} ${target}`,
|
|
1233
|
+
);
|
|
901
1234
|
}
|
|
902
|
-
return
|
|
1235
|
+
return ruleVerdict("writeInProject", "Write in project blocked by rule");
|
|
903
1236
|
}
|
|
904
1237
|
|
|
905
1238
|
/** Unwrap a shell wrapper: bash/sh/zsh -c 'code', eval 'code' → inner code; else null */
|
|
@@ -947,10 +1280,16 @@ function judgeDd(
|
|
|
947
1280
|
const target = a.slice(3);
|
|
948
1281
|
if (!target) continue;
|
|
949
1282
|
if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
|
|
950
|
-
return { kind: "confirm" };
|
|
1283
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
951
1284
|
}
|
|
952
1285
|
const real = resolveReal(resolve(realCwd, expandHome(target)));
|
|
953
|
-
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)) {
|
|
954
1293
|
return { kind: "block", reason: `dd writes to protected path: ${trimmed}` };
|
|
955
1294
|
}
|
|
956
1295
|
}
|
|
@@ -969,10 +1308,16 @@ function judgeDownload(
|
|
|
969
1308
|
const target = downloadTarget(cmdInfo.command, cmdInfo.args);
|
|
970
1309
|
if (!target) return { kind: "pass" };
|
|
971
1310
|
if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
|
|
972
|
-
return { kind: "confirm" };
|
|
1311
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
973
1312
|
}
|
|
974
1313
|
const real = resolveReal(resolve(realCwd, expandHome(target)));
|
|
975
|
-
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)) {
|
|
976
1321
|
return {
|
|
977
1322
|
kind: "block",
|
|
978
1323
|
reason: `Download writes to protected path: ${cmdInfo.command} ${target}`,
|
|
@@ -1032,7 +1377,7 @@ function judgeTruncate(
|
|
|
1032
1377
|
realCwd: string,
|
|
1033
1378
|
): SegmentVerdict {
|
|
1034
1379
|
if (cmdInfo.command !== "truncate") return { kind: "pass" };
|
|
1035
|
-
// Any target not statically resolvable (variable/wildcard) → conservative confirm
|
|
1380
|
+
// Any target not statically resolvable (variable/wildcard) → conservative confirm (pass in naked)
|
|
1036
1381
|
if (
|
|
1037
1382
|
cmdInfo.args.some(
|
|
1038
1383
|
(a) =>
|
|
@@ -1040,18 +1385,24 @@ function judgeTruncate(
|
|
|
1040
1385
|
(a.startsWith("$") || a.includes("*") || a.includes("?")),
|
|
1041
1386
|
)
|
|
1042
1387
|
) {
|
|
1043
|
-
return { kind: "confirm" };
|
|
1388
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
1044
1389
|
}
|
|
1045
1390
|
for (const t of extractPathArgs(cmdInfo.args, realCwd)) {
|
|
1046
|
-
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)) {
|
|
1047
1398
|
return {
|
|
1048
1399
|
kind: "block",
|
|
1049
1400
|
reason: `truncate truncates protected path: ${t.raw}`,
|
|
1050
1401
|
};
|
|
1051
1402
|
}
|
|
1052
|
-
// Existing ordinary file truncated →
|
|
1403
|
+
// Existing ordinary file truncated → per truncate rule
|
|
1053
1404
|
if (!DEVICE_TARGETS.has(t.path) && existsSync(t.path)) {
|
|
1054
|
-
return
|
|
1405
|
+
return ruleVerdict("truncate", `Truncate blocked by rule: ${t.raw}`);
|
|
1055
1406
|
}
|
|
1056
1407
|
}
|
|
1057
1408
|
return { kind: "pass" };
|
|
@@ -1074,10 +1425,16 @@ function judgeInPlace(
|
|
|
1074
1425
|
const dest = lastDestArg(cmdInfo.args);
|
|
1075
1426
|
if (!dest) return { kind: "pass" };
|
|
1076
1427
|
if (dest.startsWith("$") || dest.includes("*") || dest.includes("?")) {
|
|
1077
|
-
return { kind: "confirm" };
|
|
1428
|
+
return inNaked() ? { kind: "pass" } : { kind: "confirm" };
|
|
1078
1429
|
}
|
|
1079
1430
|
const real = resolveReal(resolve(realCwd, expandHome(dest)));
|
|
1080
|
-
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)) {
|
|
1081
1438
|
return {
|
|
1082
1439
|
kind: "block",
|
|
1083
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": [
|