@andares/pi-toolkits 0.7.3 → 0.9.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.
Files changed (35) hide show
  1. package/README.md +78 -6
  2. package/README.zh-CN.md +77 -6
  3. package/package.json +13 -7
  4. package/src/features/ask/index.test.ts +118 -0
  5. package/src/features/ask/index.ts +31 -13
  6. package/src/features/ask/reasons.ts +2 -2
  7. package/src/features/autostart/autostart.test.ts +45 -2
  8. package/src/features/autostart/autostart.ts +4 -2
  9. package/src/features/autostart/index.ts +100 -48
  10. package/src/features/compact/index.ts +3 -3
  11. package/src/features/compact/prompt.ts +2 -2
  12. package/src/features/favorites/constants.ts +3 -3
  13. package/src/features/favorites/patch-cycle.ts +6 -6
  14. package/src/features/favorites/patch-selector.ts +4 -4
  15. package/src/features/permission/constants.test.ts +60 -0
  16. package/src/features/permission/constants.ts +158 -0
  17. package/src/features/permission/index.test.ts +340 -0
  18. package/src/features/permission/index.ts +333 -0
  19. package/src/features/permission/modes.test.ts +66 -0
  20. package/src/features/permission/modes.ts +82 -0
  21. package/src/features/permission/panel.ts +118 -0
  22. package/src/features/permission/paths.test.ts +111 -0
  23. package/src/features/permission/paths.ts +126 -0
  24. package/src/features/permission/policy.test.ts +237 -0
  25. package/src/features/permission/policy.ts +338 -0
  26. package/src/features/permission/prompt.ts +146 -0
  27. package/src/features/permission/session-rules.ts +99 -0
  28. package/src/features/permission/units.test.ts +205 -0
  29. package/src/features/permission/units.ts +995 -0
  30. package/src/features/stash/stash.ts +1 -1
  31. package/src/index.ts +15 -2
  32. package/src/lib/gates.test.ts +171 -0
  33. package/src/lib/gates.ts +153 -0
  34. package/src/lib/model-config.ts +2 -2
  35. package/src/lib/model-picker.ts +2 -2
@@ -16,6 +16,7 @@ import {
16
16
  AUTOSTART_CONFIG_PATH,
17
17
  AutostartParseError,
18
18
  commandNameOf,
19
+ GLOBAL_AUTOSTART_FILENAME,
19
20
  parseAutostartConfig,
20
21
  RUNNABLE_REASONS,
21
22
  } from "./autostart.js";
@@ -138,8 +139,16 @@ interface Harness {
138
139
  async function makeHarness(
139
140
  config: string | null,
140
141
  configAsDir = false,
142
+ globalConfig: string | null = null,
141
143
  ): Promise<Harness> {
142
144
  const cwd = await mkdtemp(join(tmpdir(), "pi-autostart-test-"));
145
+ // The agent dir lives inside the temp project so cleanup covers it; passing
146
+ // it explicitly keeps the real ~/.pi/agent out of the test.
147
+ const agentDir = join(cwd, "agent");
148
+ await mkdir(agentDir, { recursive: true });
149
+ if (globalConfig !== null) {
150
+ await writeFile(join(agentDir, GLOBAL_AUTOSTART_FILENAME), globalConfig, "utf8");
151
+ }
143
152
  if (configAsDir) {
144
153
  // EISDIR setup: the config path exists but is a directory.
145
154
  await mkdir(join(cwd, ".pi"), { recursive: true });
@@ -172,7 +181,7 @@ async function makeHarness(
172
181
  }
173
182
  },
174
183
  } as unknown as ExtensionAPI;
175
- registerAutostart(pi);
184
+ registerAutostart(pi, { agentDir });
176
185
  return {
177
186
  handler,
178
187
  sent,
@@ -209,8 +218,9 @@ afterEach(async () => {
209
218
  async function harness(
210
219
  config: string | null,
211
220
  configAsDir = false,
221
+ globalConfig: string | null = null,
212
222
  ): Promise<Harness> {
213
- const h = await makeHarness(config, configAsDir);
223
+ const h = await makeHarness(config, configAsDir, globalConfig);
214
224
  current = h;
215
225
  return h;
216
226
  }
@@ -312,4 +322,37 @@ describe("registerAutostart", () => {
312
322
  expect(h.notifications).toHaveLength(1);
313
323
  expect(h.notifications[0]?.type).toBe("error");
314
324
  });
325
+
326
+ it("runs the global config before the project config", async () => {
327
+ const h = await harness(
328
+ '[command]\nrun = ["/add-dir ../project"]',
329
+ false,
330
+ '[command]\nrun = ["/ask on"]',
331
+ );
332
+ await h.start("startup");
333
+ expect(h.sent.map((entry) => entry.command)).toEqual([
334
+ "/ask on",
335
+ "/add-dir ../project",
336
+ ]);
337
+ expect(h.notifications).toEqual([]);
338
+ });
339
+
340
+ it("runs the global config in an untrusted project but skips the project one", async () => {
341
+ const h = await harness(
342
+ '[command]\nrun = ["/add-dir ../project"]',
343
+ false,
344
+ '[command]\nrun = ["/ask on"]',
345
+ );
346
+ h.setTrusted(false);
347
+ await h.start("startup");
348
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
349
+ expect(h.notifications).toHaveLength(1);
350
+ expect(h.notifications[0]?.type).toBe("warning");
351
+ });
352
+
353
+ it("keeps running the project config when the global one is absent", async () => {
354
+ const h = await harness('[command]\nrun = ["/ask on"]', false, null);
355
+ await h.start("startup");
356
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
357
+ });
315
358
  });
@@ -18,10 +18,12 @@
18
18
  */
19
19
  import { parse as parseToml } from "smol-toml";
20
20
 
21
- /** Project-relative location of the autostart config (`.pi/` is pi's
22
- * project-config convention, precedent: `.pi/mcp.json`). */
21
+ /** Project-relative location of the project autostart config. */
23
22
  export const AUTOSTART_CONFIG_PATH = ".pi/autostart.toml";
24
23
 
24
+ /** File name of the operator's global autostart config, inside the agent dir. */
25
+ export const GLOBAL_AUTOSTART_FILENAME = "autostart.toml";
26
+
25
27
  /**
26
28
  * session_start reasons that replay autostart commands:
27
29
  * - startup / new: fresh session in the project — replay
@@ -1,81 +1,120 @@
1
1
  /**
2
- * autostart feature — run project-declared slash commands at session start.
2
+ * autostart feature — run declared slash commands at session start.
3
3
  *
4
- * Config lives at `<project root>/.pi/autostart.toml` (see autostart.ts for
5
- * the schema). Execution gates, in order:
4
+ * Two sources, executed in order, never merged:
6
5
  *
7
- * 1. reason filter — only startup/new/fork replay (see RUNNABLE_REASONS)
8
- * 2. config file exists — a missing file is a silent opt-in skip
9
- * 3. `ctx.isProjectTrusted()` — an untrusted project never auto-executes
10
- * 4. per-command pre-validation against `pi.getCommands()`: only commands
11
- * registered by an *extension* are dispatched. This is mandatory:
12
- * sendUserMessage with an unhandled "/x" falls through prompt() to the
13
- * model as a plain user message (pi agent-session.js), so an unknown
14
- * command must be skipped here, never sent.
6
+ * 1. `<agentDir>/autostart.toml` — the operator's own defaults. Runs in every
7
+ * project; no trust gate, because the file lives in pi's config directory
8
+ * and is not controlled by the project.
9
+ * 2. `<project root>/.pi/autostart.toml` — project-declared commands. Gated on
10
+ * `ctx.isProjectTrusted()`: an untrusted repository must not execute
11
+ * anything.
12
+ *
13
+ * Execution gates, in order:
14
+ *
15
+ * - reason filter — only startup/new/fork replay (see RUNNABLE_REASONS)
16
+ * - config file exists — a missing file is a silent opt-in skip
17
+ * - project trust (project source only)
18
+ * - per-command pre-validation against `pi.getCommands()`: only commands
19
+ * registered by an *extension* are dispatched. This is mandatory:
20
+ * sendUserMessage with an unhandled "/x" falls through prompt() to the
21
+ * model as a plain user message (pi agent-session.js), so an unknown
22
+ * command must be skipped here, never sent.
15
23
  *
16
24
  * Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
17
25
  * that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
18
- * instead of the model prompt path. The wrapper defaults it to false
19
- * (each byte-identical across every published version of the supported
20
- * window, 0.99.0 → 1.2.0; see AGENTS.md host-compat
21
- * discipline for the version matrix).
26
+ * instead of the model prompt path. The wrapper defaults it to false in this
27
+ * package (each byte-identical across every published version of the supported
28
+ * window, 1.0.0 → 1.3.0; see AGENTS.md host-compat discipline for the version
29
+ * matrix).
22
30
  *
23
31
  * Only extension commands can run this way. Built-in commands (/compact,
24
32
  * /reload, /resume, …) are dispatched at the TUI layer and cannot be
25
33
  * triggered from the extension layer; skill commands (/skill:name) expand
26
- * into prompts rather than execute. First-party use case: `/add-dir`
27
- * (pi-add-dir), which is idempotent ("Already added" check).
34
+ * into prompts rather than execute. First-party use cases: `/add-dir`
35
+ * (pi-add-dir, idempotent) and `/ask-perm` (session-scoped permission).
28
36
  */
29
37
  import { readFile } from "node:fs/promises";
30
38
  import { join } from "node:path";
31
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
40
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
41
  import {
33
42
  AUTOSTART_CONFIG_PATH,
34
43
  AutostartParseError,
35
44
  commandNameOf,
45
+ GLOBAL_AUTOSTART_FILENAME,
36
46
  parseAutostartConfig,
37
47
  RUNNABLE_REASONS,
38
48
  } from "./autostart.js";
39
49
 
40
- export function registerAutostart(pi: ExtensionAPI): void {
41
- pi.on("session_start", async (event, ctx) => {
42
- if (!RUNNABLE_REASONS.has(event.reason)) return;
50
+ export interface AutostartOptions {
51
+ /** pi's config directory; defaults to `getAgentDir()`. */
52
+ readonly agentDir?: string;
53
+ }
54
+
55
+ interface Source {
56
+ readonly path: string;
57
+ readonly label: string;
58
+ /** Project sources require trust; the operator's global file does not. */
59
+ readonly requiresTrust: boolean;
60
+ }
61
+
62
+ function messageOf(error: unknown): string {
63
+ return error instanceof Error ? error.message : String(error);
64
+ }
65
+
66
+ export function registerAutostart(
67
+ pi: ExtensionAPI,
68
+ options: AutostartOptions = {},
69
+ ): void {
70
+ const agentDir = options.agentDir ?? getAgentDir();
43
71
 
72
+ async function readCommands(
73
+ ctx: ExtensionContext,
74
+ source: Source,
75
+ ): Promise<string[] | undefined> {
44
76
  let text: string;
45
77
  try {
46
- text = await readFile(join(ctx.cwd, AUTOSTART_CONFIG_PATH), "utf8");
78
+ text = await readFile(source.path, "utf8");
47
79
  } catch (err) {
48
80
  // Missing file = opt-in skip. Any other read failure (EACCES, EISDIR,
49
81
  // …) is a misconfiguration the operator should hear about.
50
82
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
51
83
  ctx.ui.notify(
52
- `autostart: skipped — cannot read ${AUTOSTART_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`,
84
+ `autostart: skipped ${source.label} — cannot read ${source.path}: ${messageOf(err)}`,
53
85
  "warning",
54
86
  );
55
87
  }
56
- return;
88
+ return undefined;
57
89
  }
58
90
 
59
- let commands: string[];
60
91
  try {
61
- commands = parseAutostartConfig(text);
92
+ return parseAutostartConfig(text);
62
93
  } catch (err) {
63
94
  const message = err instanceof AutostartParseError ? err.message : String(err);
64
95
  ctx.ui.notify(
65
- `autostart: skipped — ${message} (${AUTOSTART_CONFIG_PATH})`,
96
+ `autostart: skipped ${source.label} — ${message} (${source.path})`,
66
97
  "error",
67
98
  );
68
- return;
99
+ return undefined;
69
100
  }
70
- if (commands.length === 0) return;
101
+ }
71
102
 
72
- if (!ctx.isProjectTrusted()) {
73
- ctx.ui.notify(
74
- `autostart: skipped — project not trusted (${AUTOSTART_CONFIG_PATH})`,
75
- "warning",
76
- );
77
- return;
78
- }
103
+ pi.on("session_start", async (event, ctx) => {
104
+ if (!RUNNABLE_REASONS.has(event.reason)) return;
105
+
106
+ const sources: Source[] = [
107
+ {
108
+ path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
109
+ label: "global config",
110
+ requiresTrust: false,
111
+ },
112
+ {
113
+ path: join(ctx.cwd, AUTOSTART_CONFIG_PATH),
114
+ label: "project config",
115
+ requiresTrust: true,
116
+ },
117
+ ];
79
118
 
80
119
  // Only extension-registered commands are dispatchable; skill and
81
120
  // prompt-template entries expand rather than execute.
@@ -87,23 +126,36 @@ export function registerAutostart(pi: ExtensionAPI): void {
87
126
  ),
88
127
  );
89
128
 
90
- for (const command of commands) {
91
- const name = commandNameOf(command);
92
- if (!name || !dispatchable.has(name)) {
129
+ for (const source of sources) {
130
+ const commands = await readCommands(ctx, source);
131
+ if (commands === undefined || commands.length === 0) continue;
132
+
133
+ if (source.requiresTrust && !ctx.isProjectTrusted()) {
93
134
  ctx.ui.notify(
94
- `autostart: skipped "${command}" — not a registered extension command`,
135
+ `autostart: skipped ${source.label} — project not trusted (${source.path})`,
95
136
  "warning",
96
137
  );
97
138
  continue;
98
139
  }
99
- // One failing command must not cancel the rest of the list.
100
- try {
101
- await pi.sendUserMessage(command, { expandPromptTemplates: true });
102
- } catch (err) {
103
- ctx.ui.notify(
104
- `autostart: "${command}" failed — ${err instanceof Error ? err.message : String(err)}`,
105
- "error",
106
- );
140
+
141
+ for (const command of commands) {
142
+ const name = commandNameOf(command);
143
+ if (!name || !dispatchable.has(name)) {
144
+ ctx.ui.notify(
145
+ `autostart: skipped "${command}" — not a registered extension command`,
146
+ "warning",
147
+ );
148
+ continue;
149
+ }
150
+ // One failing command must not cancel the rest of the list.
151
+ try {
152
+ await pi.sendUserMessage(command, { expandPromptTemplates: true });
153
+ } catch (err) {
154
+ ctx.ui.notify(
155
+ `autostart: "${command}" failed — ${messageOf(err)}`,
156
+ "error",
157
+ );
158
+ }
107
159
  }
108
160
  }
109
161
  });
@@ -12,7 +12,7 @@
12
12
  * - prompts, message assembly, split-turn handling, output budgets, and the
13
13
  * summary file-list tail are verbatim pi built-in behavior (copied from
14
14
  * 0.87.1, byte-identical across every published version of the supported
15
- * window, 0.99.0 → 1.2.0) — only the executing model differs. The one
15
+ * window, 1.0.0 → 1.3.0) — only the executing model differs. The one
16
16
  * exception is `serializeConversation`, which is imported from the host
17
17
  * instead of mirrored, so it tracks whatever the running host does
18
18
  * (1.2.0 stopped feeding assistant thinking into the request).
@@ -119,7 +119,7 @@ async function completeSummary(
119
119
  }
120
120
 
121
121
  /**
122
- * pi's output-budget formula (compaction.js, 0.87.1 → 1.2.0 unchanged): a
122
+ * pi's output-budget formula (compaction.js, 0.87.1 → 1.3.0 unchanged): a
123
123
  * fraction of reserveTokens clamped by the model's own output cap.
124
124
  */
125
125
  function summarizationBudget(
@@ -136,7 +136,7 @@ function summarizationBudget(
136
136
  /**
137
137
  * Run the full compaction summarization with the configured model,
138
138
  * replicating pi's compact() (0.87.1, byte-identical across the supported
139
- * 0.99.0 → 1.2.0 window):
139
+ * 1.0.0 → 1.3.0 window):
140
140
  * history summary (first-connection or iterative-update prompt, custom
141
141
  * focus appended), plus a separate turn-prefix checkpoint when the cut point
142
142
  * splits a turn, merged into one summary with the file-list tail. Returns
@@ -6,7 +6,7 @@
6
6
  * summaries produced by the configured compact model are formatted exactly
7
7
  * like pi's own compaction output. Only the executing model differs.
8
8
  * Verified byte-identical against every published version of the supported
9
- * window (0.99.0 → 1.2.0): dist/core/compaction/compaction.js and
9
+ * window (1.0.0 → 1.3.0): dist/core/compaction/compaction.js and
10
10
  * core/usage-totals.js are unchanged files across that span, and the only
11
11
  * utils.js change (1.2.0 dropping assistant thinking from
12
12
  * serializeConversation) does not touch the strings mirrored here — that
@@ -203,7 +203,7 @@ export function formatFileOperations(
203
203
  /**
204
204
  * Combine two provider usage records field-wise (pi 0.87.1 compaction.js
205
205
  * combineUsage, moved verbatim to core/usage-totals.js in 0.99.0 and
206
- * unchanged through 1.2.0): used when
206
+ * unchanged through 1.3.0): used when
207
207
  * a split turn produces two summarization calls.
208
208
  */
209
209
  export function combineUsage<T extends UsageLike>(first: T, second: T): T {
@@ -9,7 +9,7 @@
9
9
  * keybinding conflicts arise.
10
10
  *
11
11
  * Verified against every published version of the supported window
12
- * (0.99.0 → 1.2.0) keybinding
12
+ * (1.0.0 → 1.3.0) keybinding
13
13
  * tables + pi-tui input dispatch on WSL2 + Windows Terminal; re-run
14
14
  * `pnpm check:keybindings` after every host upgrade before trusting this.
15
15
  */
@@ -30,11 +30,11 @@ export const FAVORITES_FILE = "pi-toolkits-favorites.json";
30
30
  * Windows/WSL `tui.altScreen.search` resolves to ctrl+f, and
31
31
  * `TuiAltScreen.handleViewportInput()` checks it before
32
32
  * `shouldDeferViewportInputToOverlay()`, so the keystroke opens transcript
33
- * search and the selector never sees it (verified in pi-tui 1.2.0
33
+ * search and the selector never sees it (verified in pi-tui 1.3.0
34
34
  * `dist/tui-alt-screen.js`: the search check is the first key branch and
35
35
  * returns `{ consume: true }` unconditionally).
36
36
  *
37
- * Why ctrl+alt+f was discarded: clean at the pi layer across 0.99–1.2 (only
37
+ * Why ctrl+alt+f was discarded: clean at the pi layer across 1.0–1.3 (only
38
38
  * ctrl+alt+] is taken from that family) and byte-distinguishable without
39
39
  * kitty — but grabbed at the OS/app layer on the author's machine by a
40
40
  * Windows screenshot tool, so it never reached the terminal. Lesson: a key
@@ -15,7 +15,7 @@
15
15
  * model available" feedback); otherwise jump to it
16
16
  * - the apply logic mirrors pi's _cycleScopedModel/_cycleAvailableModel
17
17
  * (byte-identical across every published version in the supported window
18
- * 0.99.0 → 1.2.0:
18
+ * 1.0.0 → 1.3.0:
19
19
  * state swap, session persistence, default-model persistence only when
20
20
  * `options.persist` is set, thinking-level re-clamp, model_select emit
21
21
  * with source "cycle")
@@ -31,9 +31,9 @@ import { getFavoritesStore, modelKey } from "./store.js";
31
31
 
32
32
  /**
33
33
  * pi's `Model<any>` — the exact type of `ModelCycleResult.model`
34
- * (pi-coding-agent 0.99–1.2; the field is unchanged across that span).
34
+ * (pi-coding-agent 1.0–1.3; the field is unchanged across that span).
35
35
  * Derived here instead of importing `Model` from @earendil-works/pi-ai,
36
- * whose index.d.ts (0.99 through 1.2.0 alike) re-exports it via an
36
+ * whose index.d.ts (1.0 through 1.3.0 alike) re-exports it via an
37
37
  * extensioned `"./types.ts"` specifier that not every TS resolver substitutes
38
38
  * to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
39
39
  */
@@ -57,7 +57,7 @@ interface CycleSessionInternals {
57
57
  setDefaultModelAndProvider(provider: string, id: string): void;
58
58
  };
59
59
  /**
60
- * pi 0.99–1.2: keeps a persisted default usable when cycling inside a
60
+ * pi 1.0–1.3: keeps a persisted default usable when cycling inside a
61
61
  * non-empty --models scope.
62
62
  */
63
63
  _addPersistedDefaultToNonEmptyScope(model: unknown): void;
@@ -140,7 +140,7 @@ export function applyCyclePatch(): boolean {
140
140
  return original.call(this, direction, options);
141
141
  }
142
142
 
143
- // Same source and availability filtering as pi's stock cycling (0.99–1.2).
143
+ // Same source and availability filtering as pi's stock cycling (1.0–1.3).
144
144
  const scoped = this._scopedModels.length > 0;
145
145
  let candidates: CycleCandidate[];
146
146
  if (scoped) {
@@ -185,7 +185,7 @@ export function applyCyclePatch(): boolean {
185
185
  : this._getThinkingLevelForModelSwitch();
186
186
  this.agent.state.model = next.model;
187
187
  this.sessionManager.appendModelChange(next.model.provider, next.model.id);
188
- // pi 0.99–1.2: only persist the default model when the caller asked for it.
188
+ // pi 1.0–1.3: only persist the default model when the caller asked for it.
189
189
  if (options.persist) {
190
190
  this.settingsManager.setDefaultModelAndProvider(
191
191
  next.model.provider,
@@ -22,7 +22,7 @@
22
22
  * keeps loading and ctrl+p falls back to stock behavior. The prototype
23
23
  * shape touched here (handleInput/updateList/filterModels and the instance
24
24
  * fields below) is byte-identical across every published version in
25
- * pi 0.99.0 → 1.2.0 (and 0.87.0 → 1.1.0 before the floor moved).
25
+ * pi 1.0.0 → 1.3.0 (and 0.99.0 → 1.2.0 before the floor moved).
26
26
  */
27
27
  import {
28
28
  ModelSelectorComponent,
@@ -61,7 +61,7 @@ interface SelectorPatchTarget {
61
61
  handleInput(keyData: string): void;
62
62
  updateList(): void;
63
63
  filterModels(query: string): void;
64
- /** pi 0.99–1.2: default-model badge source. */
64
+ /** pi 1.0–1.3: default-model badge source. */
65
65
  isDefaultModel(model: ModelRef): boolean;
66
66
  listContainer: ListContainerLike;
67
67
  searchInput: SearchInputLike;
@@ -116,7 +116,7 @@ let patched = false;
116
116
  export function applyModelSelectorPatches(): boolean {
117
117
  if (patched) return true;
118
118
  // SAFETY: ModelSelectorComponent's members are TS-private (compile-time
119
- // only); the structural view matches pi 0.99–1.2's prototype shape
119
+ // only); the structural view matches pi 1.0–1.3's prototype shape
120
120
  // (byte-identical across that span) and every touched method is
121
121
  // existence-checked below before any patching happens.
122
122
  const proto =
@@ -155,7 +155,7 @@ export function applyModelSelectorPatches(): boolean {
155
155
  };
156
156
 
157
157
  // ── 2. List rendering: hint row + bold bright yellow favorites ────────
158
- // Replicates the built-in updateList layout (pi 0.99–1.2: fixed-width cursor
158
+ // Replicates the built-in updateList layout (pi 1.0–1.3: fixed-width cursor
159
159
  // and ✓ current-marker columns, provider badge, `· default` badge, scroll
160
160
  // indicator, empty/error states, refresh status, Model Name footer) with
161
161
  // one change: favorited model ids render in the favorite style.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Fixed safety tables: credential paths are denied in every mode, destructive
3
+ * commands always ask.
4
+ */
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { describe, expect, it } from "vitest";
8
+ import { dangerReason, secretReason } from "./constants.js";
9
+
10
+ const HOME = homedir();
11
+
12
+ describe("secrets table", () => {
13
+ it("covers the credential directories exactly", () => {
14
+ expect(secretReason(join(HOME, ".ssh", "id_rsa"))).toBe("credentials");
15
+ expect(secretReason(join(HOME, ".ssh"))).toBe("credentials");
16
+ expect(secretReason(join(HOME, ".gnupg", "private-keys-v1.d"))).toBe("credentials");
17
+ expect(secretReason(join(HOME, ".aws", "credentials"))).toBe("credentials");
18
+ });
19
+
20
+ it("does not over-match a sibling prefix", () => {
21
+ expect(secretReason(join(HOME, ".ssh-easy", "x"))).toBeUndefined();
22
+ expect(secretReason(join(HOME, ".ssh_evil", "x"))).toBeUndefined();
23
+ expect(secretReason(join(HOME, ".awsconfig", "x"))).toBeUndefined();
24
+ });
25
+
26
+ it("covers pi auth files but nothing else under the agent dir", () => {
27
+ expect(secretReason(join(HOME, ".pi", "agent", "auth.json"))).toBe("pi credentials");
28
+ expect(secretReason(join(HOME, ".pi", "agent", "auth-foo.json"))).toBe("pi credentials");
29
+ expect(secretReason(join(HOME, ".pi", "agent", "settings.json"))).toBeUndefined();
30
+ expect(secretReason(join(HOME, ".pi", "agent", "xauth", "auth.json"))).toBeUndefined();
31
+ });
32
+
33
+ it("covers .env files but not the templates", () => {
34
+ expect(secretReason("/proj/.env")).toBe("environment file");
35
+ expect(secretReason("/proj/.env.local")).toBe("environment file");
36
+ expect(secretReason("/proj/.env.example")).toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe("danger table", () => {
41
+ it("catches the destructive core", () => {
42
+ expect(dangerReason("rm", ["-rf", "/"], ["/"], "/proj")).toContain("recursive delete");
43
+ expect(dangerReason("rm", ["-rf", HOME], [HOME], "/proj")).toContain("recursive delete");
44
+ expect(dangerReason("rm", ["-rf", "/proj"], ["/proj"], "/proj")).toContain("recursive delete");
45
+ expect(dangerReason("git", ["push", "--force"], [], "/proj")).toBe("forced git push");
46
+ expect(dangerReason("git", ["push", "-f"], [], "/proj")).toBe("forced git push");
47
+ expect(dangerReason("git", ["reset", "--hard"], [], "/proj")).toBe("git reset --hard");
48
+ expect(dangerReason("git", ["clean", "-fd"], [], "/proj")).toBe("git clean -f");
49
+ expect(dangerReason("npm", ["publish"], [], "/proj")).toBe("package publish");
50
+ expect(dangerReason("dd", ["of=/dev/sda"], ["/dev/sda"], "/proj")).toBe("raw device write");
51
+ expect(dangerReason("sudo", ["rm", "x"], ["/proj/x"], "/proj")).toBe("privileged command");
52
+ });
53
+
54
+ it("does not flag ordinary commands", () => {
55
+ expect(dangerReason("rm", ["-rf", "/proj/build"], ["/proj/build"], "/proj")).toBeUndefined();
56
+ expect(dangerReason("git", ["push"], [], "/proj")).toBeUndefined();
57
+ expect(dangerReason("git", ["log"], [], "/proj")).toBeUndefined();
58
+ expect(dangerReason("npm", ["view", "react"], [], "/proj")).toBeUndefined();
59
+ });
60
+ });