@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 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 (104 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, and settings.json mode persistence:
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
- 自动化测试(104 断言)模拟 pi API 加载真实扩展,覆盖 5 种模式 × 受保护路径 / 危险命令 / 截断 / git 破坏性等判定矩阵,以及 `/guard` 命令交互、trusted 确认与 naked 两级确认、底部状态栏指示、settings.json 模式持久化等流程:
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
@@ -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
- /** Read the saved guard mode from a settings.json file, or undefined if absent/invalid. */
300
- function readSettingsMode(filePath: string | undefined): GuardMode | undefined {
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?: { mode?: string };
483
+ pathGuard?: {
484
+ mode?: string;
485
+ extraProtected?: string[];
486
+ rules?: Record<string, Record<string, string>>;
487
+ };
306
488
  };
307
- const mode = data?.pathGuard?.mode;
308
- return typeof mode === "string" && isGuardMode(mode) ? mode : undefined;
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 saved mode at session start: project settings override global,
316
- * falling back to normal. Project-local config is only honored for trusted
317
- * projects; otherwise only the global setting applies.
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 readSavedMode(cwd: string | undefined, trusted: boolean): GuardMode {
320
- const proj = projectSettingsPath(cwd);
321
- if (proj && trusted) {
322
- const pm = readSettingsMode(proj);
323
- if (pm) return pm;
324
- }
325
- return readSettingsMode(globalSettingsPath()) ?? "normal";
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 a mode to settings.json. In a trusted project it writes the project
330
- * settings file (cwd/.pi/settings.json); otherwise the global settings file.
331
- * Requires a cwd; returns "project" | "global" | "none" (none = not persisted).
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 persistMode(
334
- mode: GuardMode,
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 mode on every new session (startup, /new, /resume all
366
- // fire session_start). Project settings override global; falls back to normal.
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
- setMode(readSavedMode(ctx.cwd, ctx.isProjectTrusted?.() === true), ctx.ui);
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 guard mode
665
+ // /guard slash command: view / switch mode, and manage custom protected paths
372
666
  pi.registerCommand("guard", {
373
667
  description:
374
- "Path Guard modes: /guard shows the current mode, /guard <strict|normal|loose|trusted|naked> switches (persisted to settings)",
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 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
+ }
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
- currentMode = m;
388
- refreshModeStatus(ctx.ui);
389
- const where = persistMode(m, ctx.cwd, ctx.isProjectTrusted?.() === true);
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
- currentMode = picked;
426
- refreshModeStatus(ctx.ui);
427
- const where = persistMode(
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
- // ① 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
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
- // Outside the project dir → strict/normal confirm; loose/trusted pass (judged on the real path, so symlink escape also matches)
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
- // cwd is HOME (write lands under HOME)strict/normal confirm; loose/trusted pass
487
- if (realCwd === HOME) {
488
- if (currentMode === "loose" || currentMode === "trusted") return;
489
- return askConfirm(
490
- ctx,
491
- `⚠️ Write operation in HOME directory\n\nPath: ${real}\nHOME: ${HOME}\n\nConfirm write?`,
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
- // In-project → strict prompts for everything; other modes pass
496
- 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") {
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) → 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
579
886
  const redirect = extractRedirectTarget(trimmed);
580
887
  if (redirect) {
581
888
  const real = resolveReal(resolve(realCwd, expandHome(redirect.target)));
582
- 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)) {
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 { kind: "confirm" };
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
- kind: "block",
604
- reason: `System-destructive command blocked: ${trimmed}`,
605
- };
913
+ return ruleVerdict(
914
+ "blockGroup",
915
+ `System-destructive command blocked: ${trimmed}`,
916
+ );
606
917
  }
607
918
  if (danger === "confirm") {
608
- if (currentMode === "strict") {
919
+ const lvl = rl("confirmGroup");
920
+ if (lvl === "block") {
609
921
  return {
610
922
  kind: "block",
611
- reason: `Dangerous command blocked (strict mode): ${trimmed}`,
923
+ reason: `Dangerous command blocked by rule: ${trimmed}`,
612
924
  };
613
925
  }
614
- return hasUI
615
- ? { kind: "confirm" }
616
- : {
617
- kind: "block",
618
- reason: `Dangerous command blocked (no interactive UI): ${trimmed}`,
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
- if (sub === "clean" && hasForceFlag(args)) return { kind: "confirm" }; // -f/-fd/-fdx/--force
732
- 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");
733
1051
  if (sub === "checkout" && (args.includes("--") || args.includes(".")))
734
- return { kind: "confirm" };
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 { kind: "confirm" };
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 { kind: "confirm" };
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 { kind: "confirm" };
744
- 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");
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: 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
768
1093
  for (const p of pathArgs) {
769
- 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)) {
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 (all modes)
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
- // trusted pass (ordinary files, protected already filtered); loose confirm; strict/normal → block
786
- if (currentMode === "trusted") return { kind: "pass" };
787
- if (currentMode === "loose") return { kind: "confirm" };
788
- return {
789
- kind: "block",
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: strict/normal confirm; loose/trusted pass
795
- if (currentMode === "loose" || currentMode === "trusted") {
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 (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)) {
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: normal/strict block; loose → confirm; trusted → pass
868
- const outsideOverwriteVerdict = (): SegmentVerdict => {
869
- if (currentMode === "trusted") return { kind: "pass" };
870
- if (currentMode === "loose") return { kind: "confirm" };
871
- return {
872
- kind: "block",
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 mode, in-project confirm (overwrites are never silently passed)
887
- 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");
888
1219
  }
889
1220
 
890
1221
  // ③ Target is an existing file: will be overwritten
891
1222
  if (existsSync(real)) {
892
- return outside ? outsideOverwriteVerdict() : { kind: "confirm" };
1223
+ return outside
1224
+ ? outsideOverwriteVerdict()
1225
+ : ruleVerdict("overwriteInProject", "Overwrite in project blocked by rule");
893
1226
  }
894
1227
 
895
- // ④ Target missing: outside → normal/strict confirm, loose/trusted pass;
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 currentMode === "loose" || currentMode === "trusted"
899
- ? { kind: "pass" }
900
- : { kind: "confirm" };
1230
+ return ruleVerdict(
1231
+ "overwriteOutsideNew",
1232
+ `Write outside the project blocked by rule: ${cmdInfo.command} ${target}`,
1233
+ );
901
1234
  }
902
- return currentMode === "strict" ? { kind: "confirm" } : { kind: "pass" };
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 (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)) {
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 (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)) {
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 (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)) {
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 → confirm (prevent accidental overwrite)
1403
+ // Existing ordinary file truncated → per truncate rule
1053
1404
  if (!DEVICE_TARGETS.has(t.path) && existsSync(t.path)) {
1054
- return { kind: "confirm" };
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 (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)) {
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.2.0",
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": [