@andares/pi-toolkits 0.8.0 → 0.9.1

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.
@@ -16,8 +16,10 @@ import {
16
16
  AUTOSTART_CONFIG_PATH,
17
17
  AutostartParseError,
18
18
  commandNameOf,
19
+ GLOBAL_AUTOSTART_FILENAME,
19
20
  parseAutostartConfig,
20
21
  RUNNABLE_REASONS,
22
+ shouldReplay,
21
23
  } from "./autostart.js";
22
24
  import { registerAutostart } from "./index.js";
23
25
 
@@ -111,6 +113,28 @@ describe("RUNNABLE_REASONS", () => {
111
113
  });
112
114
  });
113
115
 
116
+ describe("shouldReplay", () => {
117
+ it("replays a genuinely new session", () => {
118
+ expect(shouldReplay("startup", false)).toBe(true);
119
+ expect(shouldReplay("new", false)).toBe(true);
120
+ });
121
+
122
+ it("skips a startup that already has history (a resumed process)", () => {
123
+ // `pi -c`, `-r`, `--session` and `--fork` all report `startup` for a
124
+ // session that already carries history.
125
+ expect(shouldReplay("startup", true)).toBe(false);
126
+ });
127
+
128
+ it("replays a fork even though the branch has history", () => {
129
+ expect(shouldReplay("fork", true)).toBe(true);
130
+ });
131
+
132
+ it("skips in-process resume and reload", () => {
133
+ expect(shouldReplay("resume", true)).toBe(false);
134
+ expect(shouldReplay("reload", true)).toBe(false);
135
+ });
136
+ });
137
+
114
138
  /* ------------------------------------------------------------------ */
115
139
  /* registerAutostart integration (mocked pi surface) */
116
140
  /* ------------------------------------------------------------------ */
@@ -121,6 +145,7 @@ type SessionStartHandler = (
121
145
  cwd: string;
122
146
  isProjectTrusted: () => boolean;
123
147
  ui: { notify: (message: string, type?: string) => void };
148
+ sessionManager: { buildContextEntries: () => Array<{ type: string }> };
124
149
  },
125
150
  ) => Promise<void>;
126
151
 
@@ -132,14 +157,24 @@ interface Harness {
132
157
  commands: Array<{ name: string; source: string }>;
133
158
  failCommands: Set<string>;
134
159
  setTrusted(trusted: boolean): void;
160
+ /** Simulate a session that already had a conversation (resumed process). */
161
+ setHistory(entries: Array<{ type: string }>): void;
135
162
  start(reason: string): Promise<void>;
136
163
  }
137
164
 
138
165
  async function makeHarness(
139
166
  config: string | null,
140
167
  configAsDir = false,
168
+ globalConfig: string | null = null,
141
169
  ): Promise<Harness> {
142
170
  const cwd = await mkdtemp(join(tmpdir(), "pi-autostart-test-"));
171
+ // The agent dir lives inside the temp project so cleanup covers it; passing
172
+ // it explicitly keeps the real ~/.pi/agent out of the test.
173
+ const agentDir = join(cwd, "agent");
174
+ await mkdir(agentDir, { recursive: true });
175
+ if (globalConfig !== null) {
176
+ await writeFile(join(agentDir, GLOBAL_AUTOSTART_FILENAME), globalConfig, "utf8");
177
+ }
143
178
  if (configAsDir) {
144
179
  // EISDIR setup: the config path exists but is a directory.
145
180
  await mkdir(join(cwd, ".pi"), { recursive: true });
@@ -149,6 +184,12 @@ async function makeHarness(
149
184
  await writeFile(join(cwd, AUTOSTART_CONFIG_PATH), config, "utf8");
150
185
  }
151
186
  let trusted = true;
187
+ // Boot-level entries every session has; a resumed session adds messages.
188
+ let history: Array<{ type: string }> = [
189
+ { type: "session" },
190
+ { type: "model_change" },
191
+ { type: "thinking_level_change" },
192
+ ];
152
193
  let handler: SessionStartHandler = async () => {};
153
194
  const sent: Harness["sent"] = [];
154
195
  const notifications: Harness["notifications"] = [];
@@ -172,7 +213,7 @@ async function makeHarness(
172
213
  }
173
214
  },
174
215
  } as unknown as ExtensionAPI;
175
- registerAutostart(pi);
216
+ registerAutostart(pi, { agentDir });
176
217
  return {
177
218
  handler,
178
219
  sent,
@@ -182,6 +223,9 @@ async function makeHarness(
182
223
  setTrusted(value: boolean) {
183
224
  trusted = value;
184
225
  },
226
+ setHistory(entries) {
227
+ history = entries;
228
+ },
185
229
  async start(reason: string) {
186
230
  await handler({ reason }, {
187
231
  cwd,
@@ -191,6 +235,7 @@ async function makeHarness(
191
235
  notifications.push({ message, type });
192
236
  },
193
237
  },
238
+ sessionManager: { buildContextEntries: () => history },
194
239
  });
195
240
  },
196
241
  cwd,
@@ -209,8 +254,9 @@ afterEach(async () => {
209
254
  async function harness(
210
255
  config: string | null,
211
256
  configAsDir = false,
257
+ globalConfig: string | null = null,
212
258
  ): Promise<Harness> {
213
- const h = await makeHarness(config, configAsDir);
259
+ const h = await makeHarness(config, configAsDir, globalConfig);
214
260
  current = h;
215
261
  return h;
216
262
  }
@@ -256,6 +302,38 @@ describe("registerAutostart", () => {
256
302
  expect(h.notifications).toEqual([]);
257
303
  });
258
304
 
305
+ it("skips a process started on an existing session (pi -c reports startup)", async () => {
306
+ const h = await harness(fullConfig);
307
+ h.setHistory([
308
+ { type: "session" },
309
+ { type: "model_change" },
310
+ { type: "message" },
311
+ ]);
312
+ await h.start("startup");
313
+ expect(h.sent).toEqual([]);
314
+ expect(h.notifications).toEqual([]);
315
+ });
316
+
317
+ it("still replays when the session holds only boot-level entries", async () => {
318
+ const h = await harness(fullConfig);
319
+ h.setHistory([
320
+ { type: "session" },
321
+ { type: "model_change" },
322
+ { type: "thinking_level_change" },
323
+ { type: "session_info" },
324
+ { type: "custom" },
325
+ ]);
326
+ await h.start("startup");
327
+ expect(h.sent).toHaveLength(2);
328
+ });
329
+
330
+ it("replays a fork of a session that has history", async () => {
331
+ const h = await harness(fullConfig);
332
+ h.setHistory([{ type: "message" }]);
333
+ await h.start("fork");
334
+ expect(h.sent).toHaveLength(2);
335
+ });
336
+
259
337
  it("silently skips when no config file exists", async () => {
260
338
  const h = await harness(null);
261
339
  await h.start("startup");
@@ -312,4 +390,37 @@ describe("registerAutostart", () => {
312
390
  expect(h.notifications).toHaveLength(1);
313
391
  expect(h.notifications[0]?.type).toBe("error");
314
392
  });
393
+
394
+ it("runs the global config before the project config", async () => {
395
+ const h = await harness(
396
+ '[command]\nrun = ["/add-dir ../project"]',
397
+ false,
398
+ '[command]\nrun = ["/ask on"]',
399
+ );
400
+ await h.start("startup");
401
+ expect(h.sent.map((entry) => entry.command)).toEqual([
402
+ "/ask on",
403
+ "/add-dir ../project",
404
+ ]);
405
+ expect(h.notifications).toEqual([]);
406
+ });
407
+
408
+ it("runs the global config in an untrusted project but skips the project one", async () => {
409
+ const h = await harness(
410
+ '[command]\nrun = ["/add-dir ../project"]',
411
+ false,
412
+ '[command]\nrun = ["/ask on"]',
413
+ );
414
+ h.setTrusted(false);
415
+ await h.start("startup");
416
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
417
+ expect(h.notifications).toHaveLength(1);
418
+ expect(h.notifications[0]?.type).toBe("warning");
419
+ });
420
+
421
+ it("keeps running the project config when the global one is absent", async () => {
422
+ const h = await harness('[command]\nrun = ["/ask on"]', false, null);
423
+ await h.start("startup");
424
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
425
+ });
315
426
  });
@@ -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
@@ -39,6 +41,30 @@ export const RUNNABLE_REASONS: ReadonlySet<string> = new Set([
39
41
  "fork",
40
42
  ]);
41
43
 
44
+ /**
45
+ * Whether this session start should replay the command lists.
46
+ *
47
+ * The reason alone is not enough at process start. `pi -c` (`--continue`),
48
+ * `-r` (`--resume`), `--session <path>` and `--fork <path>` all open an
49
+ * existing session *before* the first runtime exists, and that runtime is
50
+ * created without an explicit session_start event, so extensions see pi's
51
+ * default reason — `startup` — not `resume` (verified on pi 1.0.0–1.3.0:
52
+ * `main.js` builds the initial session manager from those flags and calls
53
+ * `createAgentSessionRuntime` with no `sessionStartEvent`;
54
+ * `agent-session.js` defaults it to `{ reason: "startup" }`). `resume` is only
55
+ * emitted for in-process session switches, never for a resumed process.
56
+ *
57
+ * So the session content decides: a `startup` on a session that already has
58
+ * conversation history is a resumed (or forked) process, and its branch already
59
+ * carries the state a replay would rebuild. `hasHistory` is the same signal pi
60
+ * itself uses to recognise an existing session
61
+ * (`buildSessionContext().messages.length > 0`).
62
+ */
63
+ export function shouldReplay(reason: string, hasHistory: boolean): boolean {
64
+ if (reason === "startup") return !hasHistory;
65
+ return RUNNABLE_REASONS.has(reason);
66
+ }
67
+
42
68
  /** Parse/validation failure with a user-facing message. */
43
69
  export class AutostartParseError extends Error {}
44
70
 
@@ -1,81 +1,157 @@
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 — see `shouldReplay`: startup/new/fork replay, but a
16
+ * `startup` that already carries conversation history is a process started
17
+ * on an existing session (`pi -c`/`-r`/`--session`/`--fork`) and does not
18
+ * - config file exists — a missing file is a silent opt-in skip
19
+ * - project trust (project source only)
20
+ * - per-command pre-validation against `pi.getCommands()`: only commands
21
+ * registered by an *extension* are dispatched. This is mandatory:
22
+ * sendUserMessage with an unhandled "/x" falls through prompt() to the
23
+ * model as a plain user message (pi agent-session.js), so an unknown
24
+ * command must be skipped here, never sent.
15
25
  *
16
26
  * Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
17
27
  * 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, 1.0.0 → 1.3.0; see AGENTS.md host-compat
21
- * discipline for the version matrix).
28
+ * instead of the model prompt path. The wrapper defaults it to false in this
29
+ * package (each byte-identical across every published version of the supported
30
+ * window, 1.0.0 → 1.3.0; see AGENTS.md host-compat discipline for the version
31
+ * matrix).
22
32
  *
23
33
  * Only extension commands can run this way. Built-in commands (/compact,
24
34
  * /reload, /resume, …) are dispatched at the TUI layer and cannot be
25
35
  * 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).
36
+ * into prompts rather than execute. First-party use cases: `/add-dir`
37
+ * (pi-add-dir, idempotent) and `/ask-perm` (session-scoped permission).
28
38
  */
29
39
  import { readFile } from "node:fs/promises";
30
40
  import { join } from "node:path";
31
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
42
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
43
  import {
33
44
  AUTOSTART_CONFIG_PATH,
34
45
  AutostartParseError,
35
46
  commandNameOf,
47
+ GLOBAL_AUTOSTART_FILENAME,
36
48
  parseAutostartConfig,
37
- RUNNABLE_REASONS,
49
+ shouldReplay,
38
50
  } from "./autostart.js";
39
51
 
40
- export function registerAutostart(pi: ExtensionAPI): void {
41
- pi.on("session_start", async (event, ctx) => {
42
- if (!RUNNABLE_REASONS.has(event.reason)) return;
52
+ export interface AutostartOptions {
53
+ /** pi's config directory; defaults to `getAgentDir()`. */
54
+ readonly agentDir?: string;
55
+ }
56
+
57
+ interface Source {
58
+ readonly path: string;
59
+ readonly label: string;
60
+ /** Project sources require trust; the operator's global file does not. */
61
+ readonly requiresTrust: boolean;
62
+ }
63
+
64
+ function messageOf(error: unknown): string {
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
43
67
 
68
+ /**
69
+ * Entry types that prove the session already held a conversation.
70
+ *
71
+ * `buildContextEntries()` returns the active branch's entries including the
72
+ * boot-level ones (`session`, `model_change`, `thinking_level_change`), so
73
+ * presence alone is not enough — a brand-new session has those too.
74
+ */
75
+ const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
76
+ "message",
77
+ "custom_message",
78
+ "compaction",
79
+ "branch_summary",
80
+ ]);
81
+
82
+ /**
83
+ * Whether the session already carries conversation history.
84
+ *
85
+ * This is how a `startup` on an existing session (a resumed process) is told
86
+ * apart from a genuinely new one — see {@link shouldReplay}. An unreadable
87
+ * session manager counts as new, which keeps the feature working in hosts where
88
+ * the session surface is unavailable.
89
+ */
90
+ function hasConversationHistory(ctx: ExtensionContext): boolean {
91
+ try {
92
+ return ctx.sessionManager
93
+ .buildContextEntries()
94
+ .some((entry) => HISTORY_ENTRY_TYPES.has(entry.type));
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ export function registerAutostart(
101
+ pi: ExtensionAPI,
102
+ options: AutostartOptions = {},
103
+ ): void {
104
+ const agentDir = options.agentDir ?? getAgentDir();
105
+
106
+ async function readCommands(
107
+ ctx: ExtensionContext,
108
+ source: Source,
109
+ ): Promise<string[] | undefined> {
44
110
  let text: string;
45
111
  try {
46
- text = await readFile(join(ctx.cwd, AUTOSTART_CONFIG_PATH), "utf8");
112
+ text = await readFile(source.path, "utf8");
47
113
  } catch (err) {
48
114
  // Missing file = opt-in skip. Any other read failure (EACCES, EISDIR,
49
115
  // …) is a misconfiguration the operator should hear about.
50
116
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
51
117
  ctx.ui.notify(
52
- `autostart: skipped — cannot read ${AUTOSTART_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`,
118
+ `autostart: skipped ${source.label} — cannot read ${source.path}: ${messageOf(err)}`,
53
119
  "warning",
54
120
  );
55
121
  }
56
- return;
122
+ return undefined;
57
123
  }
58
124
 
59
- let commands: string[];
60
125
  try {
61
- commands = parseAutostartConfig(text);
126
+ return parseAutostartConfig(text);
62
127
  } catch (err) {
63
128
  const message = err instanceof AutostartParseError ? err.message : String(err);
64
129
  ctx.ui.notify(
65
- `autostart: skipped — ${message} (${AUTOSTART_CONFIG_PATH})`,
130
+ `autostart: skipped ${source.label} — ${message} (${source.path})`,
66
131
  "error",
67
132
  );
68
- return;
133
+ return undefined;
69
134
  }
70
- if (commands.length === 0) return;
135
+ }
71
136
 
72
- if (!ctx.isProjectTrusted()) {
73
- ctx.ui.notify(
74
- `autostart: skipped — project not trusted (${AUTOSTART_CONFIG_PATH})`,
75
- "warning",
76
- );
77
- return;
78
- }
137
+ pi.on("session_start", async (event, ctx) => {
138
+ // `pi -c` / `-r` / `--session` / `--fork` report `startup` for a session
139
+ // that already has history; the content is what tells them apart. See
140
+ // shouldReplay in autostart.ts for the host-side evidence.
141
+ if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
142
+
143
+ const sources: Source[] = [
144
+ {
145
+ path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
146
+ label: "global config",
147
+ requiresTrust: false,
148
+ },
149
+ {
150
+ path: join(ctx.cwd, AUTOSTART_CONFIG_PATH),
151
+ label: "project config",
152
+ requiresTrust: true,
153
+ },
154
+ ];
79
155
 
80
156
  // Only extension-registered commands are dispatchable; skill and
81
157
  // prompt-template entries expand rather than execute.
@@ -87,23 +163,36 @@ export function registerAutostart(pi: ExtensionAPI): void {
87
163
  ),
88
164
  );
89
165
 
90
- for (const command of commands) {
91
- const name = commandNameOf(command);
92
- if (!name || !dispatchable.has(name)) {
166
+ for (const source of sources) {
167
+ const commands = await readCommands(ctx, source);
168
+ if (commands === undefined || commands.length === 0) continue;
169
+
170
+ if (source.requiresTrust && !ctx.isProjectTrusted()) {
93
171
  ctx.ui.notify(
94
- `autostart: skipped "${command}" — not a registered extension command`,
172
+ `autostart: skipped ${source.label} — project not trusted (${source.path})`,
95
173
  "warning",
96
174
  );
97
175
  continue;
98
176
  }
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
- );
177
+
178
+ for (const command of commands) {
179
+ const name = commandNameOf(command);
180
+ if (!name || !dispatchable.has(name)) {
181
+ ctx.ui.notify(
182
+ `autostart: skipped "${command}" — not a registered extension command`,
183
+ "warning",
184
+ );
185
+ continue;
186
+ }
187
+ // One failing command must not cancel the rest of the list.
188
+ try {
189
+ await pi.sendUserMessage(command, { expandPromptTemplates: true });
190
+ } catch (err) {
191
+ ctx.ui.notify(
192
+ `autostart: "${command}" failed — ${messageOf(err)}`,
193
+ "error",
194
+ );
195
+ }
107
196
  }
108
197
  }
109
198
  });
@@ -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
+ });