@astrofoundry/pi-astro 0.6.4 → 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,8 @@ 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
+ import { formatResolution, resolveResolution } from "./resolution.ts";
23
24
 
24
25
  const DEFAULT_SAVE_SUBDIR = ".gemini-images";
25
26
 
@@ -130,12 +131,17 @@ interface DecodedImage {
130
131
  mimeType: string;
131
132
  }
132
133
 
134
+ interface CallResult {
135
+ images: DecodedImage[];
136
+ inputTokens?: number;
137
+ }
138
+
133
139
  async function callGeminiNative(
134
140
  ai: GoogleGenAI,
135
141
  model: string,
136
142
  p: Params,
137
143
  signal: AbortSignal | undefined,
138
- ): Promise<DecodedImage[]> {
144
+ ): Promise<CallResult> {
139
145
  const imageConfig: Record<string, string> = {};
140
146
  if (p.aspect_ratio) imageConfig.aspectRatio = p.aspect_ratio;
141
147
  if (p.image_size) imageConfig.imageSize = p.image_size;
@@ -175,7 +181,9 @@ async function callGeminiNative(
175
181
  }
176
182
  }
177
183
  }
178
- return images;
184
+ const inputTokens = (response as { usageMetadata?: { promptTokenCount?: number } }).usageMetadata
185
+ ?.promptTokenCount;
186
+ return { images, inputTokens };
179
187
  }
180
188
 
181
189
  async function callImagen(
@@ -183,7 +191,7 @@ async function callImagen(
183
191
  model: string,
184
192
  p: Params,
185
193
  signal: AbortSignal | undefined,
186
- ): Promise<DecodedImage[]> {
194
+ ): Promise<CallResult> {
187
195
  const config: Record<string, unknown> = {};
188
196
  if (p.aspect_ratio) config.aspectRatio = p.aspect_ratio;
189
197
  if (p.image_size) config.imageSize = p.image_size;
@@ -208,7 +216,7 @@ async function callImagen(
208
216
  });
209
217
  }
210
218
  }
211
- return images;
219
+ return { images };
212
220
  }
213
221
 
214
222
  function saveImages(images: DecodedImage[], dir: string, prompt: string): string[] {
@@ -233,12 +241,16 @@ function saveImages(images: DecodedImage[], dir: string, prompt: string): string
233
241
 
234
242
  function renderPreview(model: ImageModel, p: Params, estimate: { estimatedUsd: number; breakdown: string }): string {
235
243
  const nImages = isImagen(model) ? p.number_of_images ?? 1 : 1;
244
+ const aspect = p.aspect_ratio ?? "1:1";
245
+ const size = p.image_size ?? "1K";
246
+ const resolution = formatResolution(resolveResolution(model, aspect, size));
236
247
  const lines: string[] = [];
237
248
  lines.push(`Model: ${model}`);
238
249
  lines.push(`Prompt: "${p.prompt}"`);
239
- if (p.image_size) lines.push(`Size: ${p.image_size}`);
240
- if (p.aspect_ratio) lines.push(`Aspect ratio: ${p.aspect_ratio}`);
241
- lines.push(`Images: ${nImages}`);
250
+ lines.push(`Aspect: ${aspect}${p.aspect_ratio ? "" : " (default)"}`);
251
+ lines.push(`Size: ${size}${p.image_size ? "" : " (default)"}`);
252
+ lines.push(`Resolution: ${resolution}`);
253
+ lines.push(`Images: ${nImages}${p.number_of_images === undefined && isImagen(model) ? " (default)" : ""}`);
242
254
  if (p.negative_prompt) lines.push(`Negative: "${p.negative_prompt.slice(0, 80)}"`);
243
255
  if (p.seed !== undefined) lines.push(`Seed: ${p.seed}`);
244
256
  if (p.person_generation) lines.push(`People: ${p.person_generation}`);
@@ -337,19 +349,27 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
337
349
  const finalModel = (final.model as ImageModel | undefined) ?? DEFAULT_MODEL;
338
350
 
339
351
  const ai = new GoogleGenAI({ apiKey });
340
- const images = isImagen(finalModel)
352
+ const call = isImagen(finalModel)
341
353
  ? await callImagen(ai, finalModel, final, signal)
342
354
  : await callGeminiNative(ai, finalModel, final, signal);
343
355
 
344
- if (images.length === 0) {
356
+ if (call.images.length === 0) {
345
357
  throw new Error("Model returned no images.");
346
358
  }
347
359
 
348
360
  const saveDir = final.save_to ? resolve(ctx.cwd, final.save_to) : join(ctx.cwd, DEFAULT_SAVE_SUBDIR);
349
- 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;
350
370
 
351
371
  const summaryLines = [
352
- `Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${finalModel}.`,
372
+ `Generated ${call.images.length} image${call.images.length > 1 ? "s" : ""} with ${finalModel}.`,
353
373
  `Prompt: "${final.prompt}"`,
354
374
  ];
355
375
  if (final.aspect_ratio) summaryLines.push(`Aspect ratio: ${final.aspect_ratio}`);
@@ -357,16 +377,25 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
357
377
  if (final.negative_prompt) summaryLines.push(`Negative: "${final.negative_prompt}"`);
358
378
  if (final.person_generation) summaryLines.push(`People: ${final.person_generation}`);
359
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
+ }
360
384
  summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
361
385
 
362
386
  const content: AgentToolResult<unknown>["content"] = [
363
387
  { type: "text", text: summaryLines.join("\n") },
364
- ...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 })),
365
389
  ];
366
390
 
367
391
  return {
368
392
  content,
369
- details: { model: finalModel, images: images.length, savedPaths },
393
+ details: {
394
+ model: finalModel,
395
+ images: call.images.length,
396
+ savedPaths,
397
+ actualCostUsd: actual?.usd,
398
+ },
370
399
  };
371
400
  },
372
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,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatResolution, resolveResolution } from "./resolution.ts";
3
+
4
+ describe("resolveResolution", () => {
5
+ it("defaults to 1:1 1K = 1024×1024", () => {
6
+ const r = resolveResolution("gemini-2.5-flash-image", undefined, undefined);
7
+ expect(r.width).toBe(1024);
8
+ expect(r.height).toBe(1024);
9
+ expect(r.approximate).toBe(false);
10
+ });
11
+
12
+ it("scales 2K as 2x", () => {
13
+ const r = resolveResolution("gemini-2.5-flash-image", "1:1", "2K");
14
+ expect(r.width).toBe(2048);
15
+ expect(r.height).toBe(2048);
16
+ });
17
+
18
+ it("scales 4K as 4x", () => {
19
+ const r = resolveResolution("gemini-3-pro-image-preview", "1:1", "4K");
20
+ expect(r.width).toBe(4096);
21
+ expect(r.height).toBe(4096);
22
+ });
23
+
24
+ it("16:9 at 1K → 1344×768", () => {
25
+ const r = resolveResolution("gemini-2.5-flash-image", "16:9", "1K");
26
+ expect(r.width).toBe(1344);
27
+ expect(r.height).toBe(768);
28
+ });
29
+
30
+ it("21:9 at 1K → 1536×672", () => {
31
+ const r = resolveResolution("gemini-2.5-flash-image", "21:9", "1K");
32
+ expect(r.width).toBe(1536);
33
+ expect(r.height).toBe(672);
34
+ });
35
+
36
+ it("Imagen flagged as approximate", () => {
37
+ const r = resolveResolution("imagen-4.0-generate-001", "1:1", "1K");
38
+ expect(r.approximate).toBe(true);
39
+ });
40
+
41
+ it("unknown aspect falls back to 1:1", () => {
42
+ const r = resolveResolution("gemini-2.5-flash-image", "7:3", "1K");
43
+ expect(r.width).toBe(1024);
44
+ expect(r.height).toBe(1024);
45
+ });
46
+ });
47
+
48
+ describe("formatResolution", () => {
49
+ it("exact Gemini is shown without tilde", () => {
50
+ expect(formatResolution({ width: 1024, height: 1024, approximate: false })).toBe("1024×1024");
51
+ });
52
+
53
+ it("approximate Imagen gets tilde prefix", () => {
54
+ expect(formatResolution({ width: 1024, height: 1024, approximate: true })).toBe("~1024×1024");
55
+ });
56
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Resolve the expected output resolution for a given (model, aspect_ratio, image_size).
3
+ *
4
+ * Base table is the documented Gemini 2.5 Flash Image matrix. 2K/4K scale the
5
+ * base by 2x/4x. Imagen's published resolutions vary per model and aren't in
6
+ * the public docs at the same granularity, so we use the same base and flag
7
+ * the result as approximate.
8
+ */
9
+
10
+ import { isImagen, type ImageModel } from "./models.ts";
11
+
12
+ type AspectRatio =
13
+ | "1:1"
14
+ | "2:3"
15
+ | "3:2"
16
+ | "3:4"
17
+ | "4:3"
18
+ | "4:5"
19
+ | "5:4"
20
+ | "9:16"
21
+ | "16:9"
22
+ | "21:9";
23
+
24
+ // Gemini 2.5 Flash Image 1K dimensions, per ai.google.dev/gemini-api/docs/image-generation.
25
+ const BASE_1K: Record<AspectRatio, [number, number]> = {
26
+ "1:1": [1024, 1024],
27
+ "2:3": [832, 1248],
28
+ "3:2": [1248, 832],
29
+ "3:4": [864, 1184],
30
+ "4:3": [1184, 864],
31
+ "4:5": [896, 1152],
32
+ "5:4": [1152, 896],
33
+ "9:16": [768, 1344],
34
+ "16:9": [1344, 768],
35
+ "21:9": [1536, 672],
36
+ };
37
+
38
+ export interface ResolutionInfo {
39
+ width: number;
40
+ height: number;
41
+ approximate: boolean;
42
+ }
43
+
44
+ export function resolveResolution(
45
+ model: ImageModel,
46
+ aspectRatio: string | undefined,
47
+ imageSize: "1K" | "2K" | "4K" | undefined,
48
+ ): ResolutionInfo {
49
+ const ratio = (aspectRatio ?? "1:1") as AspectRatio;
50
+ const base = BASE_1K[ratio] ?? BASE_1K["1:1"];
51
+ const size = imageSize ?? "1K";
52
+ const multiplier = size === "4K" ? 4 : size === "2K" ? 2 : 1;
53
+ return {
54
+ width: base[0] * multiplier,
55
+ height: base[1] * multiplier,
56
+ approximate: isImagen(model),
57
+ };
58
+ }
59
+
60
+ export function formatResolution(info: ResolutionInfo): string {
61
+ return `${info.approximate ? "~" : ""}${info.width}×${info.height}`;
62
+ }
@@ -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
+ };