@astrofoundry/pi-astro 0.6.5 → 0.6.7
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 +2 -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/notify-on-stop/index.test.ts +179 -0
- package/extensions/notify-on-stop/index.ts +71 -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
|
@@ -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
|
+
}
|