@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 +1 -0
- package/extensions/gemini-image/index.test.ts +43 -0
- package/extensions/gemini-image/index.ts +43 -14
- package/extensions/gemini-image/pricing.test.ts +43 -0
- package/extensions/gemini-image/pricing.ts +32 -0
- package/extensions/gemini-image/resolution.test.ts +56 -0
- package/extensions/gemini-image/resolution.ts +62 -0
- package/extensions/security-guard/config.test.ts +107 -0
- package/extensions/security-guard/config.ts +120 -0
- package/extensions/security-guard/defaults.ts +38 -0
- package/extensions/security-guard/index.test.ts +296 -0
- package/extensions/security-guard/index.ts +173 -0
- package/extensions/security-guard/rules.test.ts +96 -0
- package/extensions/security-guard/rules.ts +90 -0
- package/extensions/security-guard/types.ts +12 -0
- package/package.json +1 -1
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<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
lines.push(`
|
|
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
|
|
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: {
|
|
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
|
+
};
|