@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 +1 -0
- package/extensions/gemini-image/index.test.ts +43 -0
- package/extensions/gemini-image/index.ts +35 -11
- package/extensions/gemini-image/pricing.test.ts +43 -0
- package/extensions/gemini-image/pricing.ts +32 -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,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<
|
|
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
|
-
|
|
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<
|
|
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
|
|
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: {
|
|
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
|
+
}
|