@yaosu/pi-path-guard 1.1.0 → 1.2.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
@@ -22,7 +22,7 @@ pi install /path/to/pi-path-guard
22
22
  # Try without installing (no settings change / 临时试用,不写入 settings)
23
23
  pi -e ./pi-path-guard
24
24
 
25
- # npm (after publishing / 发布后): scope package
25
+ # npm: scope package
26
26
  # pi install npm:@yaosu/pi-path-guard
27
27
  ```
28
28
 
@@ -35,7 +35,8 @@ 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
+ - The active mode is shown in the footer status bar (`🛡 <mode>`, `🛡 NAKED` in warning color) 当前模式显示在底部状态栏(`🛡 <mode>`,naked 用警示色 `🛡 NAKED`)
39
40
 
40
41
  ### Guard mode matrix / 防护模式矩阵
41
42
 
@@ -72,13 +73,13 @@ After installing, run `/reload` or restart pi. 安装后 `/reload` 或重启 pi
72
73
 
73
74
  ## Development / 开发与测试
74
75
 
75
- Automated tests (96 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, and naked-mode double confirmation:
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:
76
77
 
77
78
  ```bash
78
79
  cd tests && node --experimental-strip-types test-pathguard.ts
79
80
  ```
80
81
 
81
- 自动化测试(96 断言)模拟 pi API 加载真实扩展,覆盖 5 种模式 × 受保护路径 / 危险命令 / 截断 / git 破坏性等判定矩阵,以及 `/guard` 命令交互、trusted 确认与 naked 两级确认流程:
82
+ 自动化测试(104 断言)模拟 pi API 加载真实扩展,覆盖 5 种模式 × 受保护路径 / 危险命令 / 截断 / git 破坏性等判定矩阵,以及 `/guard` 命令交互、trusted 确认与 naked 两级确认、底部状态栏指示、settings.json 模式持久化等流程:
82
83
 
83
84
  ```bash
84
85
  cd tests && node --experimental-strip-types test-pathguard.ts
@@ -42,6 +42,11 @@
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.
45
50
  */
46
51
 
47
52
  import type {
@@ -52,6 +57,7 @@ import type {
52
57
  EditToolInput,
53
58
  WriteToolInput,
54
59
  ToolCallEventResult,
60
+ ExtensionUIContext,
55
61
  } from "@earendil-works/pi-coding-agent";
56
62
  import {
57
63
  resolve,
@@ -63,7 +69,13 @@ import {
63
69
  sep,
64
70
  } from "node:path";
65
71
  import { homedir } from "node:os";
66
- import { realpathSync, existsSync, statSync } from "node:fs";
72
+ import {
73
+ realpathSync,
74
+ existsSync,
75
+ statSync,
76
+ readFileSync,
77
+ writeFileSync,
78
+ } from "node:fs";
67
79
 
68
80
  // ─── Configuration ──────────────────────────────────────────────────────
69
81
 
@@ -188,6 +200,11 @@ const DEVICE_TARGETS = new Set([
188
200
 
189
201
  const HOME = homedir();
190
202
 
203
+ /** pi project config dir name (default CONFIG_DIR_NAME is `.pi`). */
204
+ const CONFIG_DIR = ".pi";
205
+ /** Global settings.json path (default pi agent dir). */
206
+ const GLOBAL_SETTINGS_PATH = join(HOME, ".pi", "agent", "settings.json");
207
+
191
208
  // ─── Guard Modes ─────────────────────────────────────────────────────
192
209
 
193
210
  /** Guard mode: strict (full) / normal (default) / loose (relaxed) / trusted (most permissive) / naked (no protection) */
@@ -250,16 +267,111 @@ type GuardVerdict =
250
267
 
251
268
  // ─── Entry ────────────────────────────────────────────────────────────
252
269
 
270
+ /** Set the current guard mode and mirror it into the TUI footer status bar. */
271
+ function setMode(mode: GuardMode, ui: ExtensionUIContext) {
272
+ currentMode = mode;
273
+ refreshModeStatus(ui);
274
+ }
275
+
276
+ /**
277
+ * Show the active guard mode in the footer status bar (persists across renders).
278
+ * naked is highlighted in warning color so the "bare" state is unmissable.
279
+ */
280
+ function refreshModeStatus(ui: ExtensionUIContext) {
281
+ const t = ui.theme;
282
+ const color = currentMode === "naked" ? "warning" : "accent";
283
+ const label = currentMode === "naked" ? "🛡 NAKED" : `🛡 ${currentMode}`;
284
+ ui.setStatus("path-guard", t.fg(color, label));
285
+ }
286
+
287
+ // ─── Settings persistence (mode survives across sessions) ─────────────
288
+
289
+ /** Global settings.json path (~/.pi/agent/settings.json). */
290
+ function globalSettingsPath(): string {
291
+ return GLOBAL_SETTINGS_PATH;
292
+ }
293
+
294
+ /** Project settings.json path (cwd/.pi/settings.json), or undefined when no cwd. */
295
+ function projectSettingsPath(cwd: string | undefined): string | undefined {
296
+ return cwd ? join(cwd, CONFIG_DIR, "settings.json") : undefined;
297
+ }
298
+
299
+ /** Read the saved guard mode from a settings.json file, or undefined if absent/invalid. */
300
+ function readSettingsMode(filePath: string | undefined): GuardMode | undefined {
301
+ if (!filePath) return undefined;
302
+ try {
303
+ if (!existsSync(filePath)) return undefined;
304
+ const data = JSON.parse(readFileSync(filePath, "utf8")) as {
305
+ pathGuard?: { mode?: string };
306
+ };
307
+ const mode = data?.pathGuard?.mode;
308
+ return typeof mode === "string" && isGuardMode(mode) ? mode : undefined;
309
+ } catch {
310
+ return undefined;
311
+ }
312
+ }
313
+
314
+ /**
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.
318
+ */
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";
326
+ }
327
+
328
+ /**
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).
332
+ */
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
339
+ const target = trusted ? projectSettingsPath(cwd) : globalSettingsPath();
340
+ if (!target) return "none";
341
+ try {
342
+ let data: Record<string, unknown> = {};
343
+ if (existsSync(target)) {
344
+ data = JSON.parse(readFileSync(target, "utf8")) as Record<string, unknown>;
345
+ }
346
+ const guard = (data.pathGuard as Record<string, unknown>) ?? {};
347
+ guard.mode = mode;
348
+ data.pathGuard = guard;
349
+ writeFileSync(target, JSON.stringify(data, null, 2) + "\n", "utf8");
350
+ return trusted ? "project" : "global";
351
+ } catch {
352
+ return "none";
353
+ }
354
+ }
355
+
356
+ /** Human-readable persistence note for notify messages. */
357
+ function persistNote(where: string): string {
358
+ if (where === "project")
359
+ return "saved to project settings (.pi/settings.json)";
360
+ if (where === "global") return "saved to global settings";
361
+ return "session-only (not persisted)";
362
+ }
363
+
253
364
  export default function (pi: ExtensionAPI) {
254
- // Reset to normal on every new session (startup, /new, /resume all fire session_start)
255
- pi.on("session_start", () => {
256
- currentMode = "normal";
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.
367
+ pi.on("session_start", (_event, ctx) => {
368
+ setMode(readSavedMode(ctx.cwd, ctx.isProjectTrusted?.() === true), ctx.ui);
257
369
  });
258
370
 
259
371
  // /guard slash command: view / switch guard mode
260
372
  pi.registerCommand("guard", {
261
373
  description:
262
- "Path Guard modes: /guard shows the current mode, /guard <strict|normal|loose|trusted|naked> switches",
374
+ "Path Guard modes: /guard shows the current mode, /guard <strict|normal|loose|trusted|naked> switches (persisted to settings)",
263
375
  handler: async (args, ctx) => {
264
376
  const m = args?.trim().toLowerCase() ?? "";
265
377
 
@@ -273,8 +385,10 @@ export default function (pi: ExtensionAPI) {
273
385
  return;
274
386
  }
275
387
  currentMode = m;
388
+ refreshModeStatus(ctx.ui);
389
+ const where = persistMode(m, ctx.cwd, ctx.isProjectTrusted?.() === true);
276
390
  ctx.ui.notify(
277
- `Path Guard switched to: ${m} (session-only; new sessions reset to normal)`,
391
+ `Path Guard switched to: ${m} (${persistNote(where)})`,
278
392
  "info",
279
393
  );
280
394
  return;
@@ -309,8 +423,14 @@ export default function (pi: ExtensionAPI) {
309
423
  return;
310
424
  }
311
425
  currentMode = picked;
426
+ refreshModeStatus(ctx.ui);
427
+ const where = persistMode(
428
+ picked,
429
+ ctx.cwd,
430
+ ctx.isProjectTrusted?.() === true,
431
+ );
312
432
  ctx.ui.notify(
313
- `Path Guard switched to: ${picked} (session-only; new sessions reset to normal)`,
433
+ `Path Guard switched to: ${picked} (${persistNote(where)})`,
314
434
  "info",
315
435
  );
316
436
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaosu/pi-path-guard",
3
- "version": "1.1.0",
3
+ "version": "1.2.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": [