@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 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
- - Every new session resets to `normal` 每次新会话自动回到 `normal`
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 (99 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, and the footer status indicator:
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
- 自动化测试(99 断言)模拟 pi API 加载真实扩展,覆盖 5 种模式 × 受保护路径 / 危险命令 / 截断 / git 破坏性等判定矩阵,以及 `/guard` 命令交互、trusted 确认与 naked 两级确认、底部状态栏指示等流程:
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
@@ -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 { realpathSync, existsSync, statSync } from "node:fs";
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
- // Reset to normal on every new session (startup, /new, /resume all 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.
273
659
  pi.on("session_start", (_event, ctx) => {
274
- setMode("normal", ctx.ui);
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 guard mode
665
+ // /guard slash command: view / switch mode, and manage custom protected paths
278
666
  pi.registerCommand("guard", {
279
667
  description:
280
- "Path Guard modes: /guard shows the current mode, /guard <strict|normal|loose|trusted|naked> switches",
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 m = args?.trim().toLowerCase() ?? "";
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
- currentMode = m;
294
- refreshModeStatus(ctx.ui);
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} (session-only; new sessions reset to normal)`,
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
- currentMode = picked;
331
- refreshModeStatus(ctx.ui);
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} (session-only; new sessions reset to normal)`,
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
- // ① Protected path (incl. HOME-level credentials/config, inside or outside project) → block
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
- // Outside the project dir → strict/normal confirm; loose/trusted pass (judged on the real path, so symlink escape also matches)
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
- // cwd is HOME (write lands under HOME)strict/normal confirm; loose/trusted pass
387
- if (realCwd === HOME) {
388
- if (currentMode === "loose" || currentMode === "trusted") return;
389
- return askConfirm(
390
- ctx,
391
- `⚠️ Write operation in HOME directory\n\nPath: ${real}\nHOME: ${HOME}\n\nConfirm write?`,
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
- // In-project → strict prompts for everything; other modes pass
396
- if (currentMode === "strict") {
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) → confirm
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 (matchesProtectedPath(real)) {
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 { kind: "confirm" };
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
- kind: "block",
504
- reason: `System-destructive command blocked: ${trimmed}`,
505
- };
913
+ return ruleVerdict(
914
+ "blockGroup",
915
+ `System-destructive command blocked: ${trimmed}`,
916
+ );
506
917
  }
507
918
  if (danger === "confirm") {
508
- if (currentMode === "strict") {
919
+ const lvl = rl("confirmGroup");
920
+ if (lvl === "block") {
509
921
  return {
510
922
  kind: "block",
511
- reason: `Dangerous command blocked (strict mode): ${trimmed}`,
923
+ reason: `Dangerous command blocked by rule: ${trimmed}`,
512
924
  };
513
925
  }
514
- return hasUI
515
- ? { kind: "confirm" }
516
- : {
517
- kind: "block",
518
- reason: `Dangerous command blocked (no interactive UI): ${trimmed}`,
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
- if (sub === "clean" && hasForceFlag(args)) return { kind: "confirm" }; // -f/-fd/-fdx/--force
632
- if (sub === "reset" && args.includes("--hard")) return { kind: "confirm" };
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 { kind: "confirm" };
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 { kind: "confirm" };
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 { kind: "confirm" };
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 { kind: "confirm" };
644
- if (sub === "stash" && args.includes("drop")) return { kind: "confirm" };
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: blocked in every mode (trusted filters protected before passing outside deletes)
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 (matchesProtectedPath(p.path)) {
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 (all modes)
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
- // trusted pass (ordinary files, protected already filtered); loose confirm; strict/normal → block
686
- if (currentMode === "trusted") return { kind: "pass" };
687
- if (currentMode === "loose") return { kind: "confirm" };
688
- return {
689
- kind: "block",
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: strict/normal confirm; loose/trusted pass
695
- if (currentMode === "loose" || currentMode === "trusted") {
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 (matchesProtectedPath(real)) {
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: normal/strict block; loose → confirm; trusted → pass
768
- const outsideOverwriteVerdict = (): SegmentVerdict => {
769
- if (currentMode === "trusted") return { kind: "pass" };
770
- if (currentMode === "loose") return { kind: "confirm" };
771
- return {
772
- kind: "block",
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 mode, in-project confirm (overwrites are never silently passed)
787
- return outside ? outsideOverwriteVerdict() : { kind: "confirm" };
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 ? outsideOverwriteVerdict() : { kind: "confirm" };
1223
+ return outside
1224
+ ? outsideOverwriteVerdict()
1225
+ : ruleVerdict("overwriteInProject", "Overwrite in project blocked by rule");
793
1226
  }
794
1227
 
795
- // ④ Target missing: outside → normal/strict confirm, loose/trusted pass;
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 currentMode === "loose" || currentMode === "trusted"
799
- ? { kind: "pass" }
800
- : { kind: "confirm" };
1230
+ return ruleVerdict(
1231
+ "overwriteOutsideNew",
1232
+ `Write outside the project blocked by rule: ${cmdInfo.command} ${target}`,
1233
+ );
801
1234
  }
802
- return currentMode === "strict" ? { kind: "confirm" } : { kind: "pass" };
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 (matchesProtectedPath(real)) {
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 (matchesProtectedPath(real)) {
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 (matchesProtectedPath(t.path)) {
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 → confirm (prevent accidental overwrite)
1403
+ // Existing ordinary file truncated → per truncate rule
953
1404
  if (!DEVICE_TARGETS.has(t.path) && existsSync(t.path)) {
954
- return { kind: "confirm" };
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 (matchesProtectedPath(real)) {
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.1.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": [