@astrofoundry/pi-astro 0.6.5 → 0.6.6

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
@@ -61,6 +61,7 @@ pi # launch; confirm [Extensions] lists astro-agents, claude-glob
61
61
  - `grimoire` — registers `grimoire` tool
62
62
  - `multi-edit` — registers the enhanced `edit` tool
63
63
  - `gemini-image` — registers `gemini_image` tool (requires a Gemini API key; prompts and saves on first use)
64
+ - `security-guard` — blocks/prompts destructive bash commands and sensitive file access; configure at `~/.pi/agent/security-guard.json` (example written on first run); `/security-guard status|reload|test`
64
65
  - `claude-globals` — auto-injects `~/.claude/CLAUDE.md` into every pi session's system prompt
65
66
 
66
67
  **Bundled subagents** (callable via `astro_agent`):
@@ -371,6 +371,49 @@ describe("gemini-image extension", () => {
371
371
  rmSync(sourceDir, { recursive: true, force: true });
372
372
  });
373
373
 
374
+ it("summary includes exact cost for Imagen (flat pricing)", async () => {
375
+ generateImagesMock.mockResolvedValue({
376
+ generatedImages: [
377
+ { image: { imageBytes: "A", mimeType: "image/png" } },
378
+ { image: { imageBytes: "B", mimeType: "image/png" } },
379
+ ],
380
+ });
381
+ const { tool } = await load();
382
+ const res = await tool.execute(
383
+ "t",
384
+ {
385
+ prompt: "x",
386
+ model: "imagen-4.0-fast-generate-001",
387
+ number_of_images: 2,
388
+ skip_confirm: true,
389
+ },
390
+ undefined,
391
+ undefined,
392
+ makeCtx({ cwd: fakeCwd }),
393
+ );
394
+ const text = (res.content as Array<{ type: string; text?: string }>)[0].text as string;
395
+ expect(text).toMatch(/Cost:\s+\$0\.0400/); // 2 × 0.02
396
+ expect(res.details.actualCostUsd).toBeCloseTo(0.04, 4);
397
+ });
398
+
399
+ it("summary includes cost using usageMetadata.promptTokenCount when present", async () => {
400
+ generateContentMock.mockResolvedValue({
401
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
402
+ usageMetadata: { promptTokenCount: 1000 },
403
+ });
404
+ const { tool } = await load();
405
+ const res = await tool.execute(
406
+ "t",
407
+ { prompt: "x", model: "gemini-3-pro-image-preview", skip_confirm: true },
408
+ undefined,
409
+ undefined,
410
+ makeCtx({ cwd: fakeCwd }),
411
+ );
412
+ const text = (res.content as Array<{ type: string; text?: string }>)[0].text as string;
413
+ expect(text).toMatch(/Cost:/);
414
+ expect(text).toContain("1000 input tokens");
415
+ });
416
+
374
417
  it("Imagen config: numberOfImages + negative_prompt + seed + person_generation all pass through", async () => {
375
418
  generateImagesMock.mockResolvedValue({
376
419
  generatedImages: [{ image: { imageBytes: "X", mimeType: "image/png" } }],
@@ -19,7 +19,7 @@ import {
19
19
  type ImageModel,
20
20
  } from "./models.ts";
21
21
  import { parseTweaks } from "./parseTweaks.ts";
22
- import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
22
+ import { computeActualCost, estimateCost, formatUsd, hasPricing } from "./pricing.ts";
23
23
  import { formatResolution, resolveResolution } from "./resolution.ts";
24
24
 
25
25
  const DEFAULT_SAVE_SUBDIR = ".gemini-images";
@@ -131,12 +131,17 @@ interface DecodedImage {
131
131
  mimeType: string;
132
132
  }
133
133
 
134
+ interface CallResult {
135
+ images: DecodedImage[];
136
+ inputTokens?: number;
137
+ }
138
+
134
139
  async function callGeminiNative(
135
140
  ai: GoogleGenAI,
136
141
  model: string,
137
142
  p: Params,
138
143
  signal: AbortSignal | undefined,
139
- ): Promise<DecodedImage[]> {
144
+ ): Promise<CallResult> {
140
145
  const imageConfig: Record<string, string> = {};
141
146
  if (p.aspect_ratio) imageConfig.aspectRatio = p.aspect_ratio;
142
147
  if (p.image_size) imageConfig.imageSize = p.image_size;
@@ -176,7 +181,9 @@ async function callGeminiNative(
176
181
  }
177
182
  }
178
183
  }
179
- return images;
184
+ const inputTokens = (response as { usageMetadata?: { promptTokenCount?: number } }).usageMetadata
185
+ ?.promptTokenCount;
186
+ return { images, inputTokens };
180
187
  }
181
188
 
182
189
  async function callImagen(
@@ -184,7 +191,7 @@ async function callImagen(
184
191
  model: string,
185
192
  p: Params,
186
193
  signal: AbortSignal | undefined,
187
- ): Promise<DecodedImage[]> {
194
+ ): Promise<CallResult> {
188
195
  const config: Record<string, unknown> = {};
189
196
  if (p.aspect_ratio) config.aspectRatio = p.aspect_ratio;
190
197
  if (p.image_size) config.imageSize = p.image_size;
@@ -209,7 +216,7 @@ async function callImagen(
209
216
  });
210
217
  }
211
218
  }
212
- return images;
219
+ return { images };
213
220
  }
214
221
 
215
222
  function saveImages(images: DecodedImage[], dir: string, prompt: string): string[] {
@@ -342,19 +349,27 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
342
349
  const finalModel = (final.model as ImageModel | undefined) ?? DEFAULT_MODEL;
343
350
 
344
351
  const ai = new GoogleGenAI({ apiKey });
345
- const images = isImagen(finalModel)
352
+ const call = isImagen(finalModel)
346
353
  ? await callImagen(ai, finalModel, final, signal)
347
354
  : await callGeminiNative(ai, finalModel, final, signal);
348
355
 
349
- if (images.length === 0) {
356
+ if (call.images.length === 0) {
350
357
  throw new Error("Model returned no images.");
351
358
  }
352
359
 
353
360
  const saveDir = final.save_to ? resolve(ctx.cwd, final.save_to) : join(ctx.cwd, DEFAULT_SAVE_SUBDIR);
354
- const savedPaths = saveImages(images, saveDir, final.prompt);
361
+ const savedPaths = saveImages(call.images, saveDir, final.prompt);
362
+
363
+ const actual = hasPricing(finalModel)
364
+ ? computeActualCost(finalModel, {
365
+ numberOfImages: call.images.length,
366
+ imageSize: final.image_size,
367
+ inputTokens: call.inputTokens,
368
+ })
369
+ : undefined;
355
370
 
356
371
  const summaryLines = [
357
- `Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${finalModel}.`,
372
+ `Generated ${call.images.length} image${call.images.length > 1 ? "s" : ""} with ${finalModel}.`,
358
373
  `Prompt: "${final.prompt}"`,
359
374
  ];
360
375
  if (final.aspect_ratio) summaryLines.push(`Aspect ratio: ${final.aspect_ratio}`);
@@ -362,16 +377,25 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
362
377
  if (final.negative_prompt) summaryLines.push(`Negative: "${final.negative_prompt}"`);
363
378
  if (final.person_generation) summaryLines.push(`People: ${final.person_generation}`);
364
379
  if (final.seed !== undefined) summaryLines.push(`Seed: ${final.seed}`);
380
+ if (actual) {
381
+ const exact = isImagen(finalModel) || call.inputTokens !== undefined;
382
+ summaryLines.push(`Cost: ${exact ? formatUsd(actual.usd) : `~${formatUsd(actual.usd)}`} (${actual.breakdown})`);
383
+ }
365
384
  summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
366
385
 
367
386
  const content: AgentToolResult<unknown>["content"] = [
368
387
  { type: "text", text: summaryLines.join("\n") },
369
- ...images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
388
+ ...call.images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
370
389
  ];
371
390
 
372
391
  return {
373
392
  content,
374
- details: { model: finalModel, images: images.length, savedPaths },
393
+ details: {
394
+ model: finalModel,
395
+ images: call.images.length,
396
+ savedPaths,
397
+ actualCostUsd: actual?.usd,
398
+ },
375
399
  };
376
400
  },
377
401
  });
@@ -93,3 +93,46 @@ describe("hasPricing", () => {
93
93
  expect(hasPricing("imagen-4.0-fast-generate-001")).toBe(true);
94
94
  });
95
95
  });
96
+
97
+ describe("computeActualCost", () => {
98
+ it("Imagen: flat × count, exact", async () => {
99
+ const { computeActualCost } = await import("./pricing.ts");
100
+ const r = computeActualCost("imagen-4.0-fast-generate-001", { numberOfImages: 3 });
101
+ expect(r.usd).toBeCloseTo(0.06, 5);
102
+ expect(r.breakdown).toContain("0.02");
103
+ });
104
+
105
+ it("Gemini native with real input tokens: exact formula", async () => {
106
+ const { computeActualCost } = await import("./pricing.ts");
107
+ const r = computeActualCost("gemini-3-pro-image-preview", {
108
+ numberOfImages: 2,
109
+ imageSize: "2K",
110
+ inputTokens: 500,
111
+ });
112
+ // output: 2 × 0.134 = 0.268; input: 500 / 1M × 2.00 = 0.001; total ~0.269
113
+ expect(r.usd).toBeCloseTo(0.269, 3);
114
+ expect(r.breakdown).toContain("500 input tokens");
115
+ });
116
+
117
+ it("Gemini native with missing input tokens: falls back to 0", async () => {
118
+ const { computeActualCost } = await import("./pricing.ts");
119
+ const r = computeActualCost("gemini-2.5-flash-image", { numberOfImages: 1 });
120
+ expect(r.usd).toBeGreaterThanOrEqual(0);
121
+ expect(r.breakdown).toContain("0 input tokens");
122
+ });
123
+
124
+ it("4K output uses higher rate", async () => {
125
+ const { computeActualCost } = await import("./pricing.ts");
126
+ const at2k = computeActualCost("gemini-3-pro-image-preview", {
127
+ numberOfImages: 1,
128
+ imageSize: "2K",
129
+ inputTokens: 0,
130
+ });
131
+ const at4k = computeActualCost("gemini-3-pro-image-preview", {
132
+ numberOfImages: 1,
133
+ imageSize: "4K",
134
+ inputTokens: 0,
135
+ });
136
+ expect(at4k.usd).toBeGreaterThan(at2k.usd);
137
+ });
138
+ });
@@ -97,6 +97,38 @@ export function formatUsd(usd: number): string {
97
97
  return `$${usd.toFixed(4)}`;
98
98
  }
99
99
 
100
+ export interface ActualUsage {
101
+ inputTokens?: number;
102
+ numberOfImages: number;
103
+ imageSize?: "1K" | "2K" | "4K";
104
+ }
105
+
106
+ export interface ActualCostResult {
107
+ usd: number;
108
+ breakdown: string;
109
+ }
110
+
111
+ export function computeActualCost(model: ImageModel, usage: ActualUsage): ActualCostResult {
112
+ const price = PRICES[model];
113
+ if (price.kind === "flat") {
114
+ return {
115
+ usd: price.perImage * usage.numberOfImages,
116
+ breakdown: `${usage.numberOfImages} × $${price.perImage.toFixed(2)}/image`,
117
+ };
118
+ }
119
+ const perImage =
120
+ usage.imageSize === "4K" ? price.outputPerImage4K : price.outputPerImage1Kor2K;
121
+ const outputCost = perImage * usage.numberOfImages;
122
+ const inputTokens = usage.inputTokens ?? 0;
123
+ const inputCost = (inputTokens / 1_000_000) * price.inputPer1M;
124
+ return {
125
+ usd: inputCost + outputCost,
126
+ breakdown:
127
+ `${usage.numberOfImages} × $${perImage.toFixed(3)} (${usage.imageSize ?? "1K/2K"}) + ` +
128
+ `${inputTokens} input tokens`,
129
+ };
130
+ }
131
+
100
132
  export function hasPricing(model: ImageModel): boolean {
101
133
  return model in PRICES;
102
134
  }
@@ -0,0 +1,107 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ describe("config", () => {
7
+ let root: string;
8
+ const originalDir = process.env.PI_CODING_AGENT_DIR;
9
+ const originalDryRun = process.env.PI_SECURITY_GUARD_DRY_RUN;
10
+
11
+ beforeEach(() => {
12
+ root = mkdtempSync(join(tmpdir(), "sg-cfg-"));
13
+ process.env.PI_CODING_AGENT_DIR = root;
14
+ delete process.env.PI_SECURITY_GUARD_DRY_RUN;
15
+ vi.resetModules();
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(root, { recursive: true, force: true });
20
+ process.env.PI_CODING_AGENT_DIR = originalDir;
21
+ if (originalDryRun === undefined) delete process.env.PI_SECURITY_GUARD_DRY_RUN;
22
+ else process.env.PI_SECURITY_GUARD_DRY_RUN = originalDryRun;
23
+ });
24
+
25
+ it("uses defaults when config file missing", async () => {
26
+ const { loadConfig } = await import("./config.ts");
27
+ const r = loadConfig();
28
+ expect(r.configExists).toBe(false);
29
+ expect(r.rules.operations.length).toBeGreaterThan(0);
30
+ expect(r.errors).toEqual([]);
31
+ });
32
+
33
+ it("loads user JSON rules overriding defaults when non-empty", async () => {
34
+ writeFileSync(
35
+ join(root, "security-guard.json"),
36
+ JSON.stringify({
37
+ operations: [{ pattern: "myop", action: "block" }],
38
+ writes: [{ pattern: "*.secret", action: "prompt" }],
39
+ }),
40
+ "utf-8",
41
+ );
42
+ const { loadConfig } = await import("./config.ts");
43
+ const r = loadConfig();
44
+ expect(r.configExists).toBe(true);
45
+ expect(r.rules.operations).toEqual([{ pattern: "myop", action: "block" }]);
46
+ expect(r.rules.writes).toEqual([{ pattern: "*.secret", action: "prompt" }]);
47
+ // reads missing → defaults used
48
+ expect(r.rules.reads.length).toBeGreaterThan(0);
49
+ });
50
+
51
+ it("invalid JSON falls back to defaults and records error", async () => {
52
+ writeFileSync(join(root, "security-guard.json"), "{not json", "utf-8");
53
+ const { loadConfig } = await import("./config.ts");
54
+ const r = loadConfig();
55
+ expect(r.errors.length).toBeGreaterThan(0);
56
+ expect(r.rules.operations.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ it("malformed rule (bad action) is skipped with error", async () => {
60
+ writeFileSync(
61
+ join(root, "security-guard.json"),
62
+ JSON.stringify({
63
+ operations: [
64
+ { pattern: "ok", action: "block" },
65
+ { pattern: "bad", action: "nuke-from-orbit" },
66
+ { pattern: "", action: "prompt" }, // empty pattern
67
+ "not-an-object",
68
+ ],
69
+ }),
70
+ "utf-8",
71
+ );
72
+ const { loadConfig } = await import("./config.ts");
73
+ const r = loadConfig();
74
+ expect(r.rules.operations).toEqual([{ pattern: "ok", action: "block" }]);
75
+ expect(r.errors.length).toBeGreaterThanOrEqual(3);
76
+ });
77
+
78
+ it("non-array section logs error and falls back to default for that section", async () => {
79
+ writeFileSync(
80
+ join(root, "security-guard.json"),
81
+ JSON.stringify({ operations: "not an array" }),
82
+ "utf-8",
83
+ );
84
+ const { loadConfig } = await import("./config.ts");
85
+ const r = loadConfig();
86
+ expect(r.errors.some((e) => e.includes("operations"))).toBe(true);
87
+ });
88
+
89
+ it("ensureExampleConfig creates .example file once", async () => {
90
+ const { ensureExampleConfig } = await import("./config.ts");
91
+ mkdirSync(root, { recursive: true });
92
+ ensureExampleConfig();
93
+ const p = join(root, "security-guard.json.example");
94
+ expect(existsSync(p)).toBe(true);
95
+ const first = readFileSync(p, "utf-8");
96
+ // Running again doesn't overwrite / doesn't throw
97
+ ensureExampleConfig();
98
+ expect(readFileSync(p, "utf-8")).toBe(first);
99
+ });
100
+
101
+ it("isDryRun flips with env var", async () => {
102
+ const { isDryRun } = await import("./config.ts");
103
+ expect(isDryRun()).toBe(false);
104
+ process.env.PI_SECURITY_GUARD_DRY_RUN = "1";
105
+ expect(isDryRun()).toBe(true);
106
+ });
107
+ });
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { DEFAULT_RULES } from "./defaults.ts";
5
+ import type { RuleAction, SecurityRule, SecurityRules } from "./types.ts";
6
+
7
+ export interface ConfigLoadResult {
8
+ rules: SecurityRules;
9
+ configPath: string;
10
+ configExists: boolean;
11
+ errors: string[];
12
+ }
13
+
14
+ function configPath(): string {
15
+ const dir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
16
+ return join(dir, "security-guard.json");
17
+ }
18
+
19
+ function examplePath(): string {
20
+ return `${configPath()}.example`;
21
+ }
22
+
23
+ const VALID_ACTIONS: readonly RuleAction[] = ["prompt", "block", "allow"];
24
+
25
+ function validateRule(raw: unknown, errors: string[], where: string): SecurityRule | null {
26
+ if (typeof raw !== "object" || raw === null) {
27
+ errors.push(`${where}: rule is not an object`);
28
+ return null;
29
+ }
30
+ const r = raw as Record<string, unknown>;
31
+ if (typeof r.pattern !== "string" || r.pattern.length === 0) {
32
+ errors.push(`${where}: missing or empty 'pattern' string`);
33
+ return null;
34
+ }
35
+ if (typeof r.action !== "string" || !(VALID_ACTIONS as readonly string[]).includes(r.action)) {
36
+ errors.push(`${where}: action must be one of ${VALID_ACTIONS.join(", ")}`);
37
+ return null;
38
+ }
39
+ return { pattern: r.pattern, action: r.action as RuleAction };
40
+ }
41
+
42
+ function validateRules(raw: unknown, errors: string[]): SecurityRules {
43
+ const out: SecurityRules = { operations: [], writes: [], reads: [] };
44
+ if (typeof raw !== "object" || raw === null) {
45
+ errors.push("config root is not an object");
46
+ return out;
47
+ }
48
+ const r = raw as Record<string, unknown>;
49
+ for (const section of ["operations", "writes", "reads"] as const) {
50
+ const list = r[section];
51
+ if (list === undefined) continue;
52
+ if (!Array.isArray(list)) {
53
+ errors.push(`${section}: expected array, got ${typeof list}`);
54
+ continue;
55
+ }
56
+ for (let i = 0; i < list.length; i++) {
57
+ const rule = validateRule(list[i], errors, `${section}[${i}]`);
58
+ if (rule) out[section].push(rule);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export function loadConfig(): ConfigLoadResult {
65
+ const path = configPath();
66
+ const errors: string[] = [];
67
+ if (!existsSync(path)) {
68
+ return { rules: DEFAULT_RULES, configPath: path, configExists: false, errors };
69
+ }
70
+ try {
71
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
72
+ const rules = validateRules(parsed, errors);
73
+ const merged: SecurityRules = {
74
+ operations: rules.operations.length > 0 ? rules.operations : DEFAULT_RULES.operations,
75
+ writes: rules.writes.length > 0 ? rules.writes : DEFAULT_RULES.writes,
76
+ reads: rules.reads.length > 0 ? rules.reads : DEFAULT_RULES.reads,
77
+ };
78
+ return { rules: merged, configPath: path, configExists: true, errors };
79
+ } catch (err) {
80
+ errors.push(`invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
81
+ return { rules: DEFAULT_RULES, configPath: path, configExists: true, errors };
82
+ }
83
+ }
84
+
85
+ const EXAMPLE_CONTENT = `{
86
+ "_comment": "Copy this file to security-guard.json to activate. Patterns support globs: * matches one path segment, ** matches any number, ? matches one char. ~/ expands to your home directory. Longest matching pattern wins, so narrow 'allow' rules override broader 'block' rules.",
87
+ "operations": [
88
+ { "pattern": "rm -rf /", "action": "block" },
89
+ { "pattern": "mkfs", "action": "block" },
90
+ { "pattern": "dd if=", "action": "block" },
91
+ { "pattern": "> /dev/", "action": "block" },
92
+ { "pattern": "> /dev/null", "action": "allow" },
93
+ { "pattern": "rm -rf", "action": "prompt" },
94
+ { "pattern": "sudo", "action": "prompt" }
95
+ ],
96
+ "writes": [
97
+ { "pattern": "**/.env", "action": "block" },
98
+ { "pattern": "**/.env.*", "action": "block" },
99
+ { "pattern": "~/.ssh/**", "action": "block" },
100
+ { "pattern": "~/.aws/**", "action": "block" },
101
+ { "pattern": "/etc/**", "action": "prompt" }
102
+ ],
103
+ "reads": [
104
+ { "pattern": "~/.ssh/**", "action": "block" },
105
+ { "pattern": "~/.aws/credentials", "action": "block" },
106
+ { "pattern": "**/.env", "action": "prompt" }
107
+ ]
108
+ }
109
+ `;
110
+
111
+ export function ensureExampleConfig(): void {
112
+ const path = examplePath();
113
+ if (existsSync(path)) return;
114
+ mkdirSync(dirname(path), { recursive: true });
115
+ writeFileSync(path, EXAMPLE_CONTENT, "utf-8");
116
+ }
117
+
118
+ export function isDryRun(): boolean {
119
+ return process.env.PI_SECURITY_GUARD_DRY_RUN === "1";
120
+ }
@@ -0,0 +1,38 @@
1
+ import type { SecurityRules } from "./types.ts";
2
+
3
+ /**
4
+ * Conservative baseline rules. Users override via ~/.pi/agent/security-guard.json.
5
+ * Patterns support globs (`*`, `**`, `?`) and `~` expansion.
6
+ */
7
+ export const DEFAULT_RULES: SecurityRules = {
8
+ operations: [
9
+ { pattern: "rm -rf /", action: "block" },
10
+ { pattern: ":(){ :|:&", action: "block" }, // fork bomb fragment
11
+ { pattern: "mkfs", action: "block" },
12
+ { pattern: "dd if=", action: "block" },
13
+ { pattern: "> /dev/sd", action: "block" },
14
+ { pattern: "> /dev/null", action: "allow" },
15
+ { pattern: "> /dev/", action: "block" },
16
+ { pattern: "rm -rf", action: "prompt" },
17
+ { pattern: "sudo", action: "prompt" },
18
+ { pattern: "chmod -R 777", action: "prompt" },
19
+ ],
20
+ writes: [
21
+ { pattern: "**/.env", action: "block" },
22
+ { pattern: "**/.env.*", action: "block" },
23
+ { pattern: "~/.ssh/**", action: "block" },
24
+ { pattern: "~/.aws/**", action: "block" },
25
+ { pattern: "~/.gnupg/**", action: "block" },
26
+ { pattern: "/etc/**", action: "prompt" },
27
+ { pattern: "~/.bash_history", action: "prompt" },
28
+ { pattern: "~/.zsh_history", action: "prompt" },
29
+ ],
30
+ reads: [
31
+ { pattern: "~/.ssh/**", action: "block" },
32
+ { pattern: "~/.aws/credentials", action: "block" },
33
+ { pattern: "~/.aws/**", action: "prompt" },
34
+ { pattern: "~/.gnupg/**", action: "block" },
35
+ { pattern: "**/.env", action: "prompt" },
36
+ { pattern: "**/.env.*", action: "prompt" },
37
+ ],
38
+ };
@@ -0,0 +1,296 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ type Handler = (event: Record<string, unknown>, ctx: unknown) => Promise<unknown>;
7
+ type CommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
8
+
9
+ interface CapturedPi {
10
+ commands: Map<string, { description?: string; handler: CommandHandler }>;
11
+ handlers: Record<string, Handler>;
12
+ registerCommand: (name: string, opts: { description?: string; handler: CommandHandler }) => void;
13
+ on: (event: string, handler: Handler) => void;
14
+ }
15
+
16
+ function makePi(): CapturedPi {
17
+ const commands = new Map();
18
+ const handlers: Record<string, Handler> = {};
19
+ return {
20
+ commands,
21
+ handlers,
22
+ registerCommand: (name, opts) => {
23
+ commands.set(name, opts);
24
+ },
25
+ on: (event, handler) => {
26
+ handlers[event] = handler;
27
+ },
28
+ };
29
+ }
30
+
31
+ interface Ctx {
32
+ cwd: string;
33
+ hasUI: boolean;
34
+ ui: {
35
+ notify: ReturnType<typeof vi.fn>;
36
+ confirm: ReturnType<typeof vi.fn>;
37
+ };
38
+ }
39
+
40
+ function makeCtx(opts: { cwd?: string; hasUI?: boolean; allow?: boolean } = {}): Ctx {
41
+ return {
42
+ cwd: opts.cwd ?? "/tmp/cwd",
43
+ hasUI: opts.hasUI ?? true,
44
+ ui: {
45
+ notify: vi.fn(),
46
+ confirm: vi.fn().mockResolvedValue(opts.allow ?? true),
47
+ },
48
+ };
49
+ }
50
+
51
+ describe("security-guard tool_call hook", () => {
52
+ let root: string;
53
+ const originalDir = process.env.PI_CODING_AGENT_DIR;
54
+ const originalDryRun = process.env.PI_SECURITY_GUARD_DRY_RUN;
55
+
56
+ beforeEach(() => {
57
+ root = mkdtempSync(join(tmpdir(), "sg-ext-"));
58
+ process.env.PI_CODING_AGENT_DIR = root;
59
+ delete process.env.PI_SECURITY_GUARD_DRY_RUN;
60
+ vi.resetModules();
61
+ });
62
+
63
+ afterEach(() => {
64
+ rmSync(root, { recursive: true, force: true });
65
+ process.env.PI_CODING_AGENT_DIR = originalDir;
66
+ if (originalDryRun === undefined) delete process.env.PI_SECURITY_GUARD_DRY_RUN;
67
+ else process.env.PI_SECURITY_GUARD_DRY_RUN = originalDryRun;
68
+ });
69
+
70
+ async function load(rules?: Record<string, unknown>): Promise<{ pi: CapturedPi }> {
71
+ if (rules) {
72
+ mkdirSync(root, { recursive: true });
73
+ writeFileSync(join(root, "security-guard.json"), JSON.stringify(rules), "utf-8");
74
+ }
75
+ const mod = await import("./index.ts");
76
+ const pi = makePi();
77
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
78
+ return { pi };
79
+ }
80
+
81
+ it("registers tool_call handler + /security-guard command", async () => {
82
+ const { pi } = await load();
83
+ expect(pi.handlers.tool_call).toBeDefined();
84
+ expect(pi.commands.has("security-guard")).toBe(true);
85
+ });
86
+
87
+ it("blocks bash command matching 'rm -rf /' (default block rule)", async () => {
88
+ const { pi } = await load();
89
+ const ctx = makeCtx();
90
+ const result = (await pi.handlers.tool_call(
91
+ { toolName: "bash", input: { command: "rm -rf / && echo done" } },
92
+ ctx,
93
+ )) as { block?: boolean; reason?: string } | undefined;
94
+ expect(result?.block).toBe(true);
95
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Blocked/), "warning");
96
+ });
97
+
98
+ it("allows benign bash commands (no rule match)", async () => {
99
+ const { pi } = await load();
100
+ const ctx = makeCtx();
101
+ const result = await pi.handlers.tool_call(
102
+ { toolName: "bash", input: { command: "ls -la" } },
103
+ ctx,
104
+ );
105
+ expect(result).toBeUndefined();
106
+ });
107
+
108
+ it("'> /dev/null' allow rule overrides broader '> /dev/' block rule", async () => {
109
+ const { pi } = await load();
110
+ const ctx = makeCtx();
111
+ const result = await pi.handlers.tool_call(
112
+ { toolName: "bash", input: { command: "echo hi > /dev/null" } },
113
+ ctx,
114
+ );
115
+ expect(result).toBeUndefined();
116
+ });
117
+
118
+ it("prompt rule: user confirms → allowed", async () => {
119
+ const { pi } = await load({
120
+ operations: [{ pattern: "testcmd", action: "prompt" }],
121
+ });
122
+ const ctx = makeCtx({ allow: true });
123
+ const result = await pi.handlers.tool_call(
124
+ { toolName: "bash", input: { command: "testcmd now" } },
125
+ ctx,
126
+ );
127
+ expect(result).toBeUndefined();
128
+ expect(ctx.ui.confirm).toHaveBeenCalled();
129
+ });
130
+
131
+ it("prompt rule: user denies → blocked", async () => {
132
+ const { pi } = await load({
133
+ operations: [{ pattern: "testcmd", action: "prompt" }],
134
+ });
135
+ const ctx = makeCtx({ allow: false });
136
+ const result = (await pi.handlers.tool_call(
137
+ { toolName: "bash", input: { command: "testcmd now" } },
138
+ ctx,
139
+ )) as { block?: boolean; reason?: string };
140
+ expect(result?.block).toBe(true);
141
+ });
142
+
143
+ it("prompt rule without UI → blocked with reason", async () => {
144
+ const { pi } = await load({
145
+ operations: [{ pattern: "testcmd", action: "prompt" }],
146
+ });
147
+ const ctx = makeCtx({ hasUI: false });
148
+ const result = (await pi.handlers.tool_call(
149
+ { toolName: "bash", input: { command: "testcmd" } },
150
+ ctx,
151
+ )) as { block?: boolean };
152
+ expect(result?.block).toBe(true);
153
+ });
154
+
155
+ it("write to .env is blocked", async () => {
156
+ const { pi } = await load();
157
+ const ctx = makeCtx({ cwd: "/repo" });
158
+ const result = (await pi.handlers.tool_call(
159
+ { toolName: "write", input: { path: ".env" } },
160
+ ctx,
161
+ )) as { block?: boolean };
162
+ expect(result?.block).toBe(true);
163
+ });
164
+
165
+ it("edit batch: one entry hits a protected path → whole edit blocked", async () => {
166
+ const { pi } = await load({
167
+ writes: [{ pattern: "**/protected.ts", action: "block" }],
168
+ });
169
+ const ctx = makeCtx({ cwd: "/repo" });
170
+ const result = (await pi.handlers.tool_call(
171
+ {
172
+ toolName: "edit",
173
+ input: {
174
+ multi: [
175
+ { path: "safe.ts", oldText: "a", newText: "b" },
176
+ { path: "sub/protected.ts", oldText: "x", newText: "y" },
177
+ ],
178
+ },
179
+ },
180
+ ctx,
181
+ )) as { block?: boolean };
182
+ expect(result?.block).toBe(true);
183
+ });
184
+
185
+ it("edit patch mode: extracts paths from '*** Add/Update/Delete File:' headers", async () => {
186
+ const { pi } = await load({
187
+ writes: [{ pattern: "**/secret.ts", action: "block" }],
188
+ });
189
+ const ctx = makeCtx({ cwd: "/repo" });
190
+ const patch = "*** Begin Patch\n*** Add File: src/secret.ts\n+x\n*** End Patch";
191
+ const result = (await pi.handlers.tool_call(
192
+ { toolName: "edit", input: { patch } },
193
+ ctx,
194
+ )) as { block?: boolean };
195
+ expect(result?.block).toBe(true);
196
+ });
197
+
198
+ it("read of ~/.ssh/id_rsa is blocked", async () => {
199
+ const { pi } = await load();
200
+ const ctx = makeCtx();
201
+ const result = (await pi.handlers.tool_call(
202
+ { toolName: "read", input: { path: "~/.ssh/id_rsa" } },
203
+ ctx,
204
+ )) as { block?: boolean };
205
+ expect(result?.block).toBe(true);
206
+ });
207
+
208
+ it("other tool names pass through untouched", async () => {
209
+ const { pi } = await load();
210
+ const ctx = makeCtx();
211
+ const result = await pi.handlers.tool_call(
212
+ { toolName: "grep", input: { pattern: "foo" } },
213
+ ctx,
214
+ );
215
+ expect(result).toBeUndefined();
216
+ });
217
+
218
+ it("DRY-RUN mode: would-be-blocked passes through with notify prefix", async () => {
219
+ process.env.PI_SECURITY_GUARD_DRY_RUN = "1";
220
+ const { pi } = await load();
221
+ const ctx = makeCtx();
222
+ const result = await pi.handlers.tool_call(
223
+ { toolName: "bash", input: { command: "rm -rf /" } },
224
+ ctx,
225
+ );
226
+ expect(result).toBeUndefined(); // NOT blocked
227
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/DRY-RUN/), "warning");
228
+ });
229
+ });
230
+
231
+ describe("security-guard command", () => {
232
+ let root: string;
233
+ const originalDir = process.env.PI_CODING_AGENT_DIR;
234
+
235
+ beforeEach(() => {
236
+ root = mkdtempSync(join(tmpdir(), "sg-cmd-"));
237
+ process.env.PI_CODING_AGENT_DIR = root;
238
+ vi.resetModules();
239
+ });
240
+
241
+ afterEach(() => {
242
+ rmSync(root, { recursive: true, force: true });
243
+ process.env.PI_CODING_AGENT_DIR = originalDir;
244
+ });
245
+
246
+ it("status sub-command prints rule counts", async () => {
247
+ const mod = await import("./index.ts");
248
+ const pi = makePi();
249
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
250
+ const ctx = makeCtx();
251
+ await pi.commands.get("security-guard")!.handler("status", ctx);
252
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/operations=/), "info");
253
+ });
254
+
255
+ it("reload re-reads config + notifies", async () => {
256
+ const mod = await import("./index.ts");
257
+ const pi = makePi();
258
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
259
+ mkdirSync(root, { recursive: true });
260
+ writeFileSync(
261
+ join(root, "security-guard.json"),
262
+ JSON.stringify({ operations: [{ pattern: "new-one", action: "block" }] }),
263
+ "utf-8",
264
+ );
265
+ const ctx = makeCtx();
266
+ await pi.commands.get("security-guard")!.handler("reload", ctx);
267
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/reloaded/), "info");
268
+ });
269
+
270
+ it("test sub-command reports match", async () => {
271
+ const mod = await import("./index.ts");
272
+ const pi = makePi();
273
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
274
+ const ctx = makeCtx();
275
+ await pi.commands.get("security-guard")!.handler("test bash rm -rf /", ctx);
276
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/block/), "info");
277
+ });
278
+
279
+ it("test with unknown kind warns", async () => {
280
+ const mod = await import("./index.ts");
281
+ const pi = makePi();
282
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
283
+ const ctx = makeCtx();
284
+ await pi.commands.get("security-guard")!.handler("test foo bar", ctx);
285
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/usage/), "warning");
286
+ });
287
+
288
+ it("test reports no match as 'would be allowed'", async () => {
289
+ const mod = await import("./index.ts");
290
+ const pi = makePi();
291
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
292
+ const ctx = makeCtx();
293
+ await pi.commands.get("security-guard")!.handler("test bash ls -la", ctx);
294
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/no match/), "info");
295
+ });
296
+ });
@@ -0,0 +1,173 @@
1
+ import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@mariozechner/pi-coding-agent";
2
+ import { ensureExampleConfig, isDryRun, loadConfig } from "./config.ts";
3
+ import { findMatchingRule, normalizePath } from "./rules.ts";
4
+ import type { SecurityRule, SecurityRules } from "./types.ts";
5
+
6
+ interface Decision {
7
+ block: boolean;
8
+ reason?: string;
9
+ }
10
+
11
+ async function evaluate(
12
+ label: string,
13
+ target: string,
14
+ rules: readonly SecurityRule[],
15
+ ctx: ExtensionContext,
16
+ ): Promise<Decision> {
17
+ const rule = findMatchingRule(target, rules);
18
+ if (!rule || rule.action === "allow") return { block: false };
19
+
20
+ const dryRun = isDryRun();
21
+ const prefix = dryRun ? "[security-guard DRY-RUN] " : "";
22
+
23
+ if (rule.action === "block") {
24
+ ctx.ui.notify(
25
+ `${prefix}Blocked ${label}: matched pattern "${rule.pattern}"`,
26
+ "warning",
27
+ );
28
+ if (dryRun) return { block: false };
29
+ return {
30
+ block: true,
31
+ reason: `Blocked by security-guard: pattern "${rule.pattern}"`,
32
+ };
33
+ }
34
+
35
+ // prompt
36
+ if (!ctx.hasUI) {
37
+ if (dryRun) return { block: false };
38
+ return {
39
+ block: true,
40
+ reason: "security-guard: confirmation required but no UI available",
41
+ };
42
+ }
43
+ const confirmed = await ctx.ui.confirm(
44
+ `Security check — ${label}`,
45
+ `Matched pattern "${rule.pattern}"\n\nTarget: ${target}\n\nAllow this operation?`,
46
+ );
47
+ if (confirmed) return { block: false };
48
+ ctx.ui.notify(`${prefix}Denied ${label}`, "warning");
49
+ if (dryRun) return { block: false };
50
+ return { block: true, reason: "Denied by user via security-guard" };
51
+ }
52
+
53
+ function extractEditPaths(input: Record<string, unknown>): string[] {
54
+ const paths: string[] = [];
55
+ if (typeof input.path === "string") paths.push(input.path);
56
+ const multi = input.multi;
57
+ if (Array.isArray(multi)) {
58
+ for (const item of multi) {
59
+ if (item && typeof item === "object" && typeof (item as { path?: unknown }).path === "string") {
60
+ paths.push((item as { path: string }).path);
61
+ }
62
+ }
63
+ }
64
+ const patch = input.patch;
65
+ if (typeof patch === "string") {
66
+ const re = /^\*\*\* (?:Add File|Update File|Delete File):\s+(.+)$/gm;
67
+ let m: RegExpExecArray | null;
68
+ while ((m = re.exec(patch)) !== null) {
69
+ paths.push(m[1].trim());
70
+ }
71
+ }
72
+ return paths;
73
+ }
74
+
75
+ async function handleToolCall(
76
+ event: ToolCallEvent,
77
+ ctx: ExtensionContext,
78
+ rules: SecurityRules,
79
+ ): Promise<{ block?: boolean; reason?: string } | undefined> {
80
+ switch (event.toolName) {
81
+ case "bash": {
82
+ const command = (event.input as { command?: string }).command ?? "";
83
+ const decision = await evaluate("bash command", command, rules.operations, ctx);
84
+ if (decision.block) return { block: true, reason: decision.reason };
85
+ return undefined;
86
+ }
87
+ case "write": {
88
+ const target = normalizePath(String((event.input as { path?: string }).path ?? ""), ctx.cwd);
89
+ const decision = await evaluate(`write to ${target}`, target, rules.writes, ctx);
90
+ if (decision.block) return { block: true, reason: decision.reason };
91
+ return undefined;
92
+ }
93
+ case "edit": {
94
+ const paths = extractEditPaths(event.input as Record<string, unknown>);
95
+ for (const raw of paths) {
96
+ const target = normalizePath(raw, ctx.cwd);
97
+ const decision = await evaluate(`edit of ${target}`, target, rules.writes, ctx);
98
+ if (decision.block) return { block: true, reason: decision.reason };
99
+ }
100
+ return undefined;
101
+ }
102
+ case "read": {
103
+ const target = normalizePath(String((event.input as { path?: string }).path ?? ""), ctx.cwd);
104
+ const decision = await evaluate(`read of ${target}`, target, rules.reads, ctx);
105
+ if (decision.block) return { block: true, reason: decision.reason };
106
+ return undefined;
107
+ }
108
+ default:
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ export default function securityGuardExtension(pi: ExtensionAPI): void {
114
+ ensureExampleConfig();
115
+ let loaded = loadConfig();
116
+
117
+ pi.registerCommand("security-guard", {
118
+ description: "Security guard status / reload / test. Usage: /security-guard [status|reload|test <bash|write|read> <input>]",
119
+ handler: async (args, ctx) => {
120
+ const parts = args.trim().split(/\s+/).filter(Boolean);
121
+ const sub = parts[0] ?? "status";
122
+
123
+ if (sub === "reload") {
124
+ loaded = loadConfig();
125
+ const n =
126
+ loaded.rules.operations.length +
127
+ loaded.rules.writes.length +
128
+ loaded.rules.reads.length;
129
+ ctx.ui.notify(
130
+ `security-guard reloaded: ${n} rules${loaded.errors.length > 0 ? `; ${loaded.errors.length} errors` : ""}`,
131
+ loaded.errors.length > 0 ? "warning" : "info",
132
+ );
133
+ return;
134
+ }
135
+
136
+ if (sub === "test") {
137
+ const kind = parts[1];
138
+ const input = parts.slice(2).join(" ");
139
+ if (!kind || !input || !["bash", "write", "read"].includes(kind)) {
140
+ ctx.ui.notify("usage: /security-guard test <bash|write|read> <input>", "warning");
141
+ return;
142
+ }
143
+ const rules =
144
+ kind === "bash"
145
+ ? loaded.rules.operations
146
+ : kind === "write"
147
+ ? loaded.rules.writes
148
+ : loaded.rules.reads;
149
+ const target = kind === "bash" ? input : normalizePath(input, ctx.cwd);
150
+ const rule = findMatchingRule(target, rules);
151
+ ctx.ui.notify(
152
+ rule
153
+ ? `match: pattern "${rule.pattern}" -> ${rule.action}`
154
+ : `no match (would be allowed)`,
155
+ "info",
156
+ );
157
+ return;
158
+ }
159
+
160
+ // status (default)
161
+ const { rules, configPath, configExists, errors } = loaded;
162
+ const lines = [
163
+ `config: ${configPath}${configExists ? "" : " (not found — using defaults)"}`,
164
+ `operations=${rules.operations.length} writes=${rules.writes.length} reads=${rules.reads.length}`,
165
+ isDryRun() ? "dry-run: ON (PI_SECURITY_GUARD_DRY_RUN=1)" : "dry-run: off",
166
+ ];
167
+ if (errors.length > 0) lines.push(`errors: ${errors.join("; ")}`);
168
+ ctx.ui.notify(lines.join(" | "), "info");
169
+ },
170
+ });
171
+
172
+ pi.on("tool_call", async (event, ctx) => handleToolCall(event, ctx, loaded.rules));
173
+ }
@@ -0,0 +1,96 @@
1
+ import { homedir } from "node:os";
2
+ import { describe, expect, it } from "vitest";
3
+ import { expandHome, findMatchingRule, matchesPattern, normalizePath } from "./rules.ts";
4
+
5
+ describe("expandHome", () => {
6
+ it("expands ~/foo", () => {
7
+ expect(expandHome("~/foo")).toBe(`${homedir()}/foo`);
8
+ });
9
+
10
+ it("expands bare ~", () => {
11
+ expect(expandHome("~")).toBe(homedir());
12
+ });
13
+
14
+ it("leaves non-tilde paths unchanged", () => {
15
+ expect(expandHome("/etc/hosts")).toBe("/etc/hosts");
16
+ expect(expandHome("foo/bar")).toBe("foo/bar");
17
+ });
18
+ });
19
+
20
+ describe("normalizePath", () => {
21
+ it("resolves relative paths against cwd", () => {
22
+ expect(normalizePath("foo.txt", "/tmp")).toBe("/tmp/foo.txt");
23
+ });
24
+
25
+ it("keeps absolute paths", () => {
26
+ expect(normalizePath("/abs/path", "/tmp")).toBe("/abs/path");
27
+ });
28
+
29
+ it("expands ~", () => {
30
+ expect(normalizePath("~/a", "/tmp")).toBe(`${homedir()}/a`);
31
+ });
32
+ });
33
+
34
+ describe("matchesPattern", () => {
35
+ it("substring match on bash command", () => {
36
+ expect(matchesPattern("sudo rm -rf /tmp/x", { pattern: "rm -rf", action: "prompt" })).toBe(true);
37
+ });
38
+
39
+ it("substring does NOT match when absent", () => {
40
+ expect(matchesPattern("ls -la", { pattern: "rm -rf", action: "prompt" })).toBe(false);
41
+ });
42
+
43
+ it("glob ** matches deep paths", () => {
44
+ expect(matchesPattern("/Users/x/.ssh/id_rsa", { pattern: "~/.ssh/**", action: "block" })).toBe(false);
45
+ // Reason: the test path's home isn't the CURRENT user's home. Glob is anchored.
46
+ const real = `${homedir()}/.ssh/id_rsa`;
47
+ expect(matchesPattern(real, { pattern: "~/.ssh/**", action: "block" })).toBe(true);
48
+ });
49
+
50
+ it("glob * does NOT cross /", () => {
51
+ expect(matchesPattern("/a/b/c", { pattern: "/a/*", action: "block" })).toBe(false);
52
+ expect(matchesPattern("/a/b", { pattern: "/a/*", action: "block" })).toBe(true);
53
+ });
54
+
55
+ it("glob ? matches single char", () => {
56
+ expect(matchesPattern("/tmp/a.env", { pattern: "/tmp/?.env", action: "block" })).toBe(true);
57
+ expect(matchesPattern("/tmp/ab.env", { pattern: "/tmp/?.env", action: "block" })).toBe(false);
58
+ });
59
+
60
+ it("character class [abc]", () => {
61
+ expect(matchesPattern("/tmp/a", { pattern: "/tmp/[abc]", action: "block" })).toBe(true);
62
+ expect(matchesPattern("/tmp/z", { pattern: "/tmp/[abc]", action: "block" })).toBe(false);
63
+ });
64
+
65
+ it("glob **/.env matches any depth", () => {
66
+ expect(matchesPattern("/src/app/.env", { pattern: "**/.env", action: "block" })).toBe(true);
67
+ expect(matchesPattern("/.env", { pattern: "**/.env", action: "block" })).toBe(true);
68
+ });
69
+
70
+ it("metacharacters in non-glob pattern are escaped (fall back to substring)", () => {
71
+ // Pattern has no *, ?, [ — substring path used; no regex injection
72
+ expect(matchesPattern("something", { pattern: "some(thing)", action: "block" })).toBe(false);
73
+ expect(matchesPattern("some(thing)", { pattern: "some(thing)", action: "block" })).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe("findMatchingRule — longest wins", () => {
78
+ const rules = [
79
+ { pattern: "> /dev/", action: "block" as const },
80
+ { pattern: "> /dev/null", action: "allow" as const },
81
+ ];
82
+
83
+ it("allow /dev/null overrides block /dev/", () => {
84
+ const r = findMatchingRule("echo hi > /dev/null", rules);
85
+ expect(r?.action).toBe("allow");
86
+ });
87
+
88
+ it("block /dev/sda still blocks when no narrower rule exists", () => {
89
+ const r = findMatchingRule("echo hi > /dev/sda", rules);
90
+ expect(r?.action).toBe("block");
91
+ });
92
+
93
+ it("no match returns null", () => {
94
+ expect(findMatchingRule("ls -la", rules)).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,90 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import type { SecurityRule } from "./types.ts";
4
+
5
+ export function expandHome(pattern: string): string {
6
+ if (pattern === "~") return homedir();
7
+ if (pattern.startsWith("~/")) return `${homedir()}/${pattern.slice(2)}`;
8
+ return pattern;
9
+ }
10
+
11
+ /**
12
+ * Normalize a filesystem path for matching: expand `~`, resolve relative paths
13
+ * against cwd. Non-filesystem strings (bash commands) should NOT go through this.
14
+ */
15
+ export function normalizePath(p: string, cwd: string): string {
16
+ const expanded = expandHome(p);
17
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
18
+ }
19
+
20
+ /**
21
+ * Convert a glob pattern to a RegExp source. Supports:
22
+ * ? any single non-separator char
23
+ * * any run of non-separator chars
24
+ * ** any run of chars including separators
25
+ * [abc] character class
26
+ * All other regex metacharacters are escaped.
27
+ */
28
+ function globToRegex(glob: string): RegExp {
29
+ let src = "";
30
+ for (let i = 0; i < glob.length; i++) {
31
+ const c = glob[i];
32
+ if (c === "*") {
33
+ if (glob[i + 1] === "*") {
34
+ src += ".*";
35
+ i++;
36
+ } else {
37
+ src += "[^/]*";
38
+ }
39
+ } else if (c === "?") {
40
+ src += "[^/]";
41
+ } else if (c === "[") {
42
+ const end = glob.indexOf("]", i);
43
+ if (end === -1) {
44
+ src += "\\[";
45
+ } else {
46
+ src += glob.slice(i, end + 1);
47
+ i = end;
48
+ }
49
+ } else if (/[.+^${}()|\\]/.test(c)) {
50
+ src += `\\${c}`;
51
+ } else {
52
+ src += c;
53
+ }
54
+ }
55
+ return new RegExp(`^${src}$`);
56
+ }
57
+
58
+ function isGlob(pattern: string): boolean {
59
+ return /[*?[]/.test(pattern);
60
+ }
61
+
62
+ /**
63
+ * Does `text` match `rule.pattern`? For globs, matches as a whole path / whole
64
+ * string. For non-glob patterns, falls back to substring match (friendlier for
65
+ * bash commands and filename fragments).
66
+ */
67
+ export function matchesPattern(text: string, rule: SecurityRule): boolean {
68
+ const expanded = expandHome(rule.pattern);
69
+ if (isGlob(rule.pattern)) {
70
+ const re = globToRegex(expanded);
71
+ return re.test(text);
72
+ }
73
+ return text.includes(expanded) || text.includes(rule.pattern);
74
+ }
75
+
76
+ /**
77
+ * Longest-match wins, so narrow `allow` exceptions override broad `block` rules
78
+ * (e.g. `> /dev/` = block, `> /dev/null` = allow).
79
+ */
80
+ export function findMatchingRule(text: string, rules: readonly SecurityRule[]): SecurityRule | null {
81
+ let best: SecurityRule | null = null;
82
+ for (const rule of rules) {
83
+ if (matchesPattern(text, rule)) {
84
+ if (best === null || rule.pattern.length > best.pattern.length) {
85
+ best = rule;
86
+ }
87
+ }
88
+ }
89
+ return best;
90
+ }
@@ -0,0 +1,12 @@
1
+ export type RuleAction = "prompt" | "block" | "allow";
2
+
3
+ export interface SecurityRule {
4
+ pattern: string;
5
+ action: RuleAction;
6
+ }
7
+
8
+ export interface SecurityRules {
9
+ operations: SecurityRule[];
10
+ writes: SecurityRule[];
11
+ reads: SecurityRule[];
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"