@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.
@@ -0,0 +1,296 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ type Handler = (event: Record<string, unknown>, ctx: unknown) => Promise<unknown>;
7
+ type CommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
8
+
9
+ interface CapturedPi {
10
+ commands: Map<string, { description?: string; handler: CommandHandler }>;
11
+ handlers: Record<string, Handler>;
12
+ registerCommand: (name: string, opts: { description?: string; handler: CommandHandler }) => void;
13
+ on: (event: string, handler: Handler) => void;
14
+ }
15
+
16
+ function makePi(): CapturedPi {
17
+ const commands = new Map();
18
+ const handlers: Record<string, Handler> = {};
19
+ return {
20
+ commands,
21
+ handlers,
22
+ registerCommand: (name, opts) => {
23
+ commands.set(name, opts);
24
+ },
25
+ on: (event, handler) => {
26
+ handlers[event] = handler;
27
+ },
28
+ };
29
+ }
30
+
31
+ interface Ctx {
32
+ cwd: string;
33
+ hasUI: boolean;
34
+ ui: {
35
+ notify: ReturnType<typeof vi.fn>;
36
+ confirm: ReturnType<typeof vi.fn>;
37
+ };
38
+ }
39
+
40
+ function makeCtx(opts: { cwd?: string; hasUI?: boolean; allow?: boolean } = {}): Ctx {
41
+ return {
42
+ cwd: opts.cwd ?? "/tmp/cwd",
43
+ hasUI: opts.hasUI ?? true,
44
+ ui: {
45
+ notify: vi.fn(),
46
+ confirm: vi.fn().mockResolvedValue(opts.allow ?? true),
47
+ },
48
+ };
49
+ }
50
+
51
+ describe("security-guard tool_call hook", () => {
52
+ let root: string;
53
+ const originalDir = process.env.PI_CODING_AGENT_DIR;
54
+ const originalDryRun = process.env.PI_SECURITY_GUARD_DRY_RUN;
55
+
56
+ beforeEach(() => {
57
+ root = mkdtempSync(join(tmpdir(), "sg-ext-"));
58
+ process.env.PI_CODING_AGENT_DIR = root;
59
+ delete process.env.PI_SECURITY_GUARD_DRY_RUN;
60
+ vi.resetModules();
61
+ });
62
+
63
+ afterEach(() => {
64
+ rmSync(root, { recursive: true, force: true });
65
+ process.env.PI_CODING_AGENT_DIR = originalDir;
66
+ if (originalDryRun === undefined) delete process.env.PI_SECURITY_GUARD_DRY_RUN;
67
+ else process.env.PI_SECURITY_GUARD_DRY_RUN = originalDryRun;
68
+ });
69
+
70
+ async function load(rules?: Record<string, unknown>): Promise<{ pi: CapturedPi }> {
71
+ if (rules) {
72
+ mkdirSync(root, { recursive: true });
73
+ writeFileSync(join(root, "security-guard.json"), JSON.stringify(rules), "utf-8");
74
+ }
75
+ const mod = await import("./index.ts");
76
+ const pi = makePi();
77
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
78
+ return { pi };
79
+ }
80
+
81
+ it("registers tool_call handler + /security-guard command", async () => {
82
+ const { pi } = await load();
83
+ expect(pi.handlers.tool_call).toBeDefined();
84
+ expect(pi.commands.has("security-guard")).toBe(true);
85
+ });
86
+
87
+ it("blocks bash command matching 'rm -rf /' (default block rule)", async () => {
88
+ const { pi } = await load();
89
+ const ctx = makeCtx();
90
+ const result = (await pi.handlers.tool_call(
91
+ { toolName: "bash", input: { command: "rm -rf / && echo done" } },
92
+ ctx,
93
+ )) as { block?: boolean; reason?: string } | undefined;
94
+ expect(result?.block).toBe(true);
95
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Blocked/), "warning");
96
+ });
97
+
98
+ it("allows benign bash commands (no rule match)", async () => {
99
+ const { pi } = await load();
100
+ const ctx = makeCtx();
101
+ const result = await pi.handlers.tool_call(
102
+ { toolName: "bash", input: { command: "ls -la" } },
103
+ ctx,
104
+ );
105
+ expect(result).toBeUndefined();
106
+ });
107
+
108
+ it("'> /dev/null' allow rule overrides broader '> /dev/' block rule", async () => {
109
+ const { pi } = await load();
110
+ const ctx = makeCtx();
111
+ const result = await pi.handlers.tool_call(
112
+ { toolName: "bash", input: { command: "echo hi > /dev/null" } },
113
+ ctx,
114
+ );
115
+ expect(result).toBeUndefined();
116
+ });
117
+
118
+ it("prompt rule: user confirms → allowed", async () => {
119
+ const { pi } = await load({
120
+ operations: [{ pattern: "testcmd", action: "prompt" }],
121
+ });
122
+ const ctx = makeCtx({ allow: true });
123
+ const result = await pi.handlers.tool_call(
124
+ { toolName: "bash", input: { command: "testcmd now" } },
125
+ ctx,
126
+ );
127
+ expect(result).toBeUndefined();
128
+ expect(ctx.ui.confirm).toHaveBeenCalled();
129
+ });
130
+
131
+ it("prompt rule: user denies → blocked", async () => {
132
+ const { pi } = await load({
133
+ operations: [{ pattern: "testcmd", action: "prompt" }],
134
+ });
135
+ const ctx = makeCtx({ allow: false });
136
+ const result = (await pi.handlers.tool_call(
137
+ { toolName: "bash", input: { command: "testcmd now" } },
138
+ ctx,
139
+ )) as { block?: boolean; reason?: string };
140
+ expect(result?.block).toBe(true);
141
+ });
142
+
143
+ it("prompt rule without UI → blocked with reason", async () => {
144
+ const { pi } = await load({
145
+ operations: [{ pattern: "testcmd", action: "prompt" }],
146
+ });
147
+ const ctx = makeCtx({ hasUI: false });
148
+ const result = (await pi.handlers.tool_call(
149
+ { toolName: "bash", input: { command: "testcmd" } },
150
+ ctx,
151
+ )) as { block?: boolean };
152
+ expect(result?.block).toBe(true);
153
+ });
154
+
155
+ it("write to .env is blocked", async () => {
156
+ const { pi } = await load();
157
+ const ctx = makeCtx({ cwd: "/repo" });
158
+ const result = (await pi.handlers.tool_call(
159
+ { toolName: "write", input: { path: ".env" } },
160
+ ctx,
161
+ )) as { block?: boolean };
162
+ expect(result?.block).toBe(true);
163
+ });
164
+
165
+ it("edit batch: one entry hits a protected path → whole edit blocked", async () => {
166
+ const { pi } = await load({
167
+ writes: [{ pattern: "**/protected.ts", action: "block" }],
168
+ });
169
+ const ctx = makeCtx({ cwd: "/repo" });
170
+ const result = (await pi.handlers.tool_call(
171
+ {
172
+ toolName: "edit",
173
+ input: {
174
+ multi: [
175
+ { path: "safe.ts", oldText: "a", newText: "b" },
176
+ { path: "sub/protected.ts", oldText: "x", newText: "y" },
177
+ ],
178
+ },
179
+ },
180
+ ctx,
181
+ )) as { block?: boolean };
182
+ expect(result?.block).toBe(true);
183
+ });
184
+
185
+ it("edit patch mode: extracts paths from '*** Add/Update/Delete File:' headers", async () => {
186
+ const { pi } = await load({
187
+ writes: [{ pattern: "**/secret.ts", action: "block" }],
188
+ });
189
+ const ctx = makeCtx({ cwd: "/repo" });
190
+ const patch = "*** Begin Patch\n*** Add File: src/secret.ts\n+x\n*** End Patch";
191
+ const result = (await pi.handlers.tool_call(
192
+ { toolName: "edit", input: { patch } },
193
+ ctx,
194
+ )) as { block?: boolean };
195
+ expect(result?.block).toBe(true);
196
+ });
197
+
198
+ it("read of ~/.ssh/id_rsa is blocked", async () => {
199
+ const { pi } = await load();
200
+ const ctx = makeCtx();
201
+ const result = (await pi.handlers.tool_call(
202
+ { toolName: "read", input: { path: "~/.ssh/id_rsa" } },
203
+ ctx,
204
+ )) as { block?: boolean };
205
+ expect(result?.block).toBe(true);
206
+ });
207
+
208
+ it("other tool names pass through untouched", async () => {
209
+ const { pi } = await load();
210
+ const ctx = makeCtx();
211
+ const result = await pi.handlers.tool_call(
212
+ { toolName: "grep", input: { pattern: "foo" } },
213
+ ctx,
214
+ );
215
+ expect(result).toBeUndefined();
216
+ });
217
+
218
+ it("DRY-RUN mode: would-be-blocked passes through with notify prefix", async () => {
219
+ process.env.PI_SECURITY_GUARD_DRY_RUN = "1";
220
+ const { pi } = await load();
221
+ const ctx = makeCtx();
222
+ const result = await pi.handlers.tool_call(
223
+ { toolName: "bash", input: { command: "rm -rf /" } },
224
+ ctx,
225
+ );
226
+ expect(result).toBeUndefined(); // NOT blocked
227
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/DRY-RUN/), "warning");
228
+ });
229
+ });
230
+
231
+ describe("security-guard command", () => {
232
+ let root: string;
233
+ const originalDir = process.env.PI_CODING_AGENT_DIR;
234
+
235
+ beforeEach(() => {
236
+ root = mkdtempSync(join(tmpdir(), "sg-cmd-"));
237
+ process.env.PI_CODING_AGENT_DIR = root;
238
+ vi.resetModules();
239
+ });
240
+
241
+ afterEach(() => {
242
+ rmSync(root, { recursive: true, force: true });
243
+ process.env.PI_CODING_AGENT_DIR = originalDir;
244
+ });
245
+
246
+ it("status sub-command prints rule counts", async () => {
247
+ const mod = await import("./index.ts");
248
+ const pi = makePi();
249
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
250
+ const ctx = makeCtx();
251
+ await pi.commands.get("security-guard")!.handler("status", ctx);
252
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/operations=/), "info");
253
+ });
254
+
255
+ it("reload re-reads config + notifies", async () => {
256
+ const mod = await import("./index.ts");
257
+ const pi = makePi();
258
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
259
+ mkdirSync(root, { recursive: true });
260
+ writeFileSync(
261
+ join(root, "security-guard.json"),
262
+ JSON.stringify({ operations: [{ pattern: "new-one", action: "block" }] }),
263
+ "utf-8",
264
+ );
265
+ const ctx = makeCtx();
266
+ await pi.commands.get("security-guard")!.handler("reload", ctx);
267
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/reloaded/), "info");
268
+ });
269
+
270
+ it("test sub-command reports match", async () => {
271
+ const mod = await import("./index.ts");
272
+ const pi = makePi();
273
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
274
+ const ctx = makeCtx();
275
+ await pi.commands.get("security-guard")!.handler("test bash rm -rf /", ctx);
276
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/block/), "info");
277
+ });
278
+
279
+ it("test with unknown kind warns", async () => {
280
+ const mod = await import("./index.ts");
281
+ const pi = makePi();
282
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
283
+ const ctx = makeCtx();
284
+ await pi.commands.get("security-guard")!.handler("test foo bar", ctx);
285
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/usage/), "warning");
286
+ });
287
+
288
+ it("test reports no match as 'would be allowed'", async () => {
289
+ const mod = await import("./index.ts");
290
+ const pi = makePi();
291
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
292
+ const ctx = makeCtx();
293
+ await pi.commands.get("security-guard")!.handler("test bash ls -la", ctx);
294
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/no match/), "info");
295
+ });
296
+ });
@@ -0,0 +1,173 @@
1
+ import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@mariozechner/pi-coding-agent";
2
+ import { ensureExampleConfig, isDryRun, loadConfig } from "./config.ts";
3
+ import { findMatchingRule, normalizePath } from "./rules.ts";
4
+ import type { SecurityRule, SecurityRules } from "./types.ts";
5
+
6
+ interface Decision {
7
+ block: boolean;
8
+ reason?: string;
9
+ }
10
+
11
+ async function evaluate(
12
+ label: string,
13
+ target: string,
14
+ rules: readonly SecurityRule[],
15
+ ctx: ExtensionContext,
16
+ ): Promise<Decision> {
17
+ const rule = findMatchingRule(target, rules);
18
+ if (!rule || rule.action === "allow") return { block: false };
19
+
20
+ const dryRun = isDryRun();
21
+ const prefix = dryRun ? "[security-guard DRY-RUN] " : "";
22
+
23
+ if (rule.action === "block") {
24
+ ctx.ui.notify(
25
+ `${prefix}Blocked ${label}: matched pattern "${rule.pattern}"`,
26
+ "warning",
27
+ );
28
+ if (dryRun) return { block: false };
29
+ return {
30
+ block: true,
31
+ reason: `Blocked by security-guard: pattern "${rule.pattern}"`,
32
+ };
33
+ }
34
+
35
+ // prompt
36
+ if (!ctx.hasUI) {
37
+ if (dryRun) return { block: false };
38
+ return {
39
+ block: true,
40
+ reason: "security-guard: confirmation required but no UI available",
41
+ };
42
+ }
43
+ const confirmed = await ctx.ui.confirm(
44
+ `Security check — ${label}`,
45
+ `Matched pattern "${rule.pattern}"\n\nTarget: ${target}\n\nAllow this operation?`,
46
+ );
47
+ if (confirmed) return { block: false };
48
+ ctx.ui.notify(`${prefix}Denied ${label}`, "warning");
49
+ if (dryRun) return { block: false };
50
+ return { block: true, reason: "Denied by user via security-guard" };
51
+ }
52
+
53
+ function extractEditPaths(input: Record<string, unknown>): string[] {
54
+ const paths: string[] = [];
55
+ if (typeof input.path === "string") paths.push(input.path);
56
+ const multi = input.multi;
57
+ if (Array.isArray(multi)) {
58
+ for (const item of multi) {
59
+ if (item && typeof item === "object" && typeof (item as { path?: unknown }).path === "string") {
60
+ paths.push((item as { path: string }).path);
61
+ }
62
+ }
63
+ }
64
+ const patch = input.patch;
65
+ if (typeof patch === "string") {
66
+ const re = /^\*\*\* (?:Add File|Update File|Delete File):\s+(.+)$/gm;
67
+ let m: RegExpExecArray | null;
68
+ while ((m = re.exec(patch)) !== null) {
69
+ paths.push(m[1].trim());
70
+ }
71
+ }
72
+ return paths;
73
+ }
74
+
75
+ async function handleToolCall(
76
+ event: ToolCallEvent,
77
+ ctx: ExtensionContext,
78
+ rules: SecurityRules,
79
+ ): Promise<{ block?: boolean; reason?: string } | undefined> {
80
+ switch (event.toolName) {
81
+ case "bash": {
82
+ const command = (event.input as { command?: string }).command ?? "";
83
+ const decision = await evaluate("bash command", command, rules.operations, ctx);
84
+ if (decision.block) return { block: true, reason: decision.reason };
85
+ return undefined;
86
+ }
87
+ case "write": {
88
+ const target = normalizePath(String((event.input as { path?: string }).path ?? ""), ctx.cwd);
89
+ const decision = await evaluate(`write to ${target}`, target, rules.writes, ctx);
90
+ if (decision.block) return { block: true, reason: decision.reason };
91
+ return undefined;
92
+ }
93
+ case "edit": {
94
+ const paths = extractEditPaths(event.input as Record<string, unknown>);
95
+ for (const raw of paths) {
96
+ const target = normalizePath(raw, ctx.cwd);
97
+ const decision = await evaluate(`edit of ${target}`, target, rules.writes, ctx);
98
+ if (decision.block) return { block: true, reason: decision.reason };
99
+ }
100
+ return undefined;
101
+ }
102
+ case "read": {
103
+ const target = normalizePath(String((event.input as { path?: string }).path ?? ""), ctx.cwd);
104
+ const decision = await evaluate(`read of ${target}`, target, rules.reads, ctx);
105
+ if (decision.block) return { block: true, reason: decision.reason };
106
+ return undefined;
107
+ }
108
+ default:
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ export default function securityGuardExtension(pi: ExtensionAPI): void {
114
+ ensureExampleConfig();
115
+ let loaded = loadConfig();
116
+
117
+ pi.registerCommand("security-guard", {
118
+ description: "Security guard status / reload / test. Usage: /security-guard [status|reload|test <bash|write|read> <input>]",
119
+ handler: async (args, ctx) => {
120
+ const parts = args.trim().split(/\s+/).filter(Boolean);
121
+ const sub = parts[0] ?? "status";
122
+
123
+ if (sub === "reload") {
124
+ loaded = loadConfig();
125
+ const n =
126
+ loaded.rules.operations.length +
127
+ loaded.rules.writes.length +
128
+ loaded.rules.reads.length;
129
+ ctx.ui.notify(
130
+ `security-guard reloaded: ${n} rules${loaded.errors.length > 0 ? `; ${loaded.errors.length} errors` : ""}`,
131
+ loaded.errors.length > 0 ? "warning" : "info",
132
+ );
133
+ return;
134
+ }
135
+
136
+ if (sub === "test") {
137
+ const kind = parts[1];
138
+ const input = parts.slice(2).join(" ");
139
+ if (!kind || !input || !["bash", "write", "read"].includes(kind)) {
140
+ ctx.ui.notify("usage: /security-guard test <bash|write|read> <input>", "warning");
141
+ return;
142
+ }
143
+ const rules =
144
+ kind === "bash"
145
+ ? loaded.rules.operations
146
+ : kind === "write"
147
+ ? loaded.rules.writes
148
+ : loaded.rules.reads;
149
+ const target = kind === "bash" ? input : normalizePath(input, ctx.cwd);
150
+ const rule = findMatchingRule(target, rules);
151
+ ctx.ui.notify(
152
+ rule
153
+ ? `match: pattern "${rule.pattern}" -> ${rule.action}`
154
+ : `no match (would be allowed)`,
155
+ "info",
156
+ );
157
+ return;
158
+ }
159
+
160
+ // status (default)
161
+ const { rules, configPath, configExists, errors } = loaded;
162
+ const lines = [
163
+ `config: ${configPath}${configExists ? "" : " (not found — using defaults)"}`,
164
+ `operations=${rules.operations.length} writes=${rules.writes.length} reads=${rules.reads.length}`,
165
+ isDryRun() ? "dry-run: ON (PI_SECURITY_GUARD_DRY_RUN=1)" : "dry-run: off",
166
+ ];
167
+ if (errors.length > 0) lines.push(`errors: ${errors.join("; ")}`);
168
+ ctx.ui.notify(lines.join(" | "), "info");
169
+ },
170
+ });
171
+
172
+ pi.on("tool_call", async (event, ctx) => handleToolCall(event, ctx, loaded.rules));
173
+ }
@@ -0,0 +1,96 @@
1
+ import { homedir } from "node:os";
2
+ import { describe, expect, it } from "vitest";
3
+ import { expandHome, findMatchingRule, matchesPattern, normalizePath } from "./rules.ts";
4
+
5
+ describe("expandHome", () => {
6
+ it("expands ~/foo", () => {
7
+ expect(expandHome("~/foo")).toBe(`${homedir()}/foo`);
8
+ });
9
+
10
+ it("expands bare ~", () => {
11
+ expect(expandHome("~")).toBe(homedir());
12
+ });
13
+
14
+ it("leaves non-tilde paths unchanged", () => {
15
+ expect(expandHome("/etc/hosts")).toBe("/etc/hosts");
16
+ expect(expandHome("foo/bar")).toBe("foo/bar");
17
+ });
18
+ });
19
+
20
+ describe("normalizePath", () => {
21
+ it("resolves relative paths against cwd", () => {
22
+ expect(normalizePath("foo.txt", "/tmp")).toBe("/tmp/foo.txt");
23
+ });
24
+
25
+ it("keeps absolute paths", () => {
26
+ expect(normalizePath("/abs/path", "/tmp")).toBe("/abs/path");
27
+ });
28
+
29
+ it("expands ~", () => {
30
+ expect(normalizePath("~/a", "/tmp")).toBe(`${homedir()}/a`);
31
+ });
32
+ });
33
+
34
+ describe("matchesPattern", () => {
35
+ it("substring match on bash command", () => {
36
+ expect(matchesPattern("sudo rm -rf /tmp/x", { pattern: "rm -rf", action: "prompt" })).toBe(true);
37
+ });
38
+
39
+ it("substring does NOT match when absent", () => {
40
+ expect(matchesPattern("ls -la", { pattern: "rm -rf", action: "prompt" })).toBe(false);
41
+ });
42
+
43
+ it("glob ** matches deep paths", () => {
44
+ expect(matchesPattern("/Users/x/.ssh/id_rsa", { pattern: "~/.ssh/**", action: "block" })).toBe(false);
45
+ // Reason: the test path's home isn't the CURRENT user's home. Glob is anchored.
46
+ const real = `${homedir()}/.ssh/id_rsa`;
47
+ expect(matchesPattern(real, { pattern: "~/.ssh/**", action: "block" })).toBe(true);
48
+ });
49
+
50
+ it("glob * does NOT cross /", () => {
51
+ expect(matchesPattern("/a/b/c", { pattern: "/a/*", action: "block" })).toBe(false);
52
+ expect(matchesPattern("/a/b", { pattern: "/a/*", action: "block" })).toBe(true);
53
+ });
54
+
55
+ it("glob ? matches single char", () => {
56
+ expect(matchesPattern("/tmp/a.env", { pattern: "/tmp/?.env", action: "block" })).toBe(true);
57
+ expect(matchesPattern("/tmp/ab.env", { pattern: "/tmp/?.env", action: "block" })).toBe(false);
58
+ });
59
+
60
+ it("character class [abc]", () => {
61
+ expect(matchesPattern("/tmp/a", { pattern: "/tmp/[abc]", action: "block" })).toBe(true);
62
+ expect(matchesPattern("/tmp/z", { pattern: "/tmp/[abc]", action: "block" })).toBe(false);
63
+ });
64
+
65
+ it("glob **/.env matches any depth", () => {
66
+ expect(matchesPattern("/src/app/.env", { pattern: "**/.env", action: "block" })).toBe(true);
67
+ expect(matchesPattern("/.env", { pattern: "**/.env", action: "block" })).toBe(true);
68
+ });
69
+
70
+ it("metacharacters in non-glob pattern are escaped (fall back to substring)", () => {
71
+ // Pattern has no *, ?, [ — substring path used; no regex injection
72
+ expect(matchesPattern("something", { pattern: "some(thing)", action: "block" })).toBe(false);
73
+ expect(matchesPattern("some(thing)", { pattern: "some(thing)", action: "block" })).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe("findMatchingRule — longest wins", () => {
78
+ const rules = [
79
+ { pattern: "> /dev/", action: "block" as const },
80
+ { pattern: "> /dev/null", action: "allow" as const },
81
+ ];
82
+
83
+ it("allow /dev/null overrides block /dev/", () => {
84
+ const r = findMatchingRule("echo hi > /dev/null", rules);
85
+ expect(r?.action).toBe("allow");
86
+ });
87
+
88
+ it("block /dev/sda still blocks when no narrower rule exists", () => {
89
+ const r = findMatchingRule("echo hi > /dev/sda", rules);
90
+ expect(r?.action).toBe("block");
91
+ });
92
+
93
+ it("no match returns null", () => {
94
+ expect(findMatchingRule("ls -la", rules)).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,90 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import type { SecurityRule } from "./types.ts";
4
+
5
+ export function expandHome(pattern: string): string {
6
+ if (pattern === "~") return homedir();
7
+ if (pattern.startsWith("~/")) return `${homedir()}/${pattern.slice(2)}`;
8
+ return pattern;
9
+ }
10
+
11
+ /**
12
+ * Normalize a filesystem path for matching: expand `~`, resolve relative paths
13
+ * against cwd. Non-filesystem strings (bash commands) should NOT go through this.
14
+ */
15
+ export function normalizePath(p: string, cwd: string): string {
16
+ const expanded = expandHome(p);
17
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
18
+ }
19
+
20
+ /**
21
+ * Convert a glob pattern to a RegExp source. Supports:
22
+ * ? any single non-separator char
23
+ * * any run of non-separator chars
24
+ * ** any run of chars including separators
25
+ * [abc] character class
26
+ * All other regex metacharacters are escaped.
27
+ */
28
+ function globToRegex(glob: string): RegExp {
29
+ let src = "";
30
+ for (let i = 0; i < glob.length; i++) {
31
+ const c = glob[i];
32
+ if (c === "*") {
33
+ if (glob[i + 1] === "*") {
34
+ src += ".*";
35
+ i++;
36
+ } else {
37
+ src += "[^/]*";
38
+ }
39
+ } else if (c === "?") {
40
+ src += "[^/]";
41
+ } else if (c === "[") {
42
+ const end = glob.indexOf("]", i);
43
+ if (end === -1) {
44
+ src += "\\[";
45
+ } else {
46
+ src += glob.slice(i, end + 1);
47
+ i = end;
48
+ }
49
+ } else if (/[.+^${}()|\\]/.test(c)) {
50
+ src += `\\${c}`;
51
+ } else {
52
+ src += c;
53
+ }
54
+ }
55
+ return new RegExp(`^${src}$`);
56
+ }
57
+
58
+ function isGlob(pattern: string): boolean {
59
+ return /[*?[]/.test(pattern);
60
+ }
61
+
62
+ /**
63
+ * Does `text` match `rule.pattern`? For globs, matches as a whole path / whole
64
+ * string. For non-glob patterns, falls back to substring match (friendlier for
65
+ * bash commands and filename fragments).
66
+ */
67
+ export function matchesPattern(text: string, rule: SecurityRule): boolean {
68
+ const expanded = expandHome(rule.pattern);
69
+ if (isGlob(rule.pattern)) {
70
+ const re = globToRegex(expanded);
71
+ return re.test(text);
72
+ }
73
+ return text.includes(expanded) || text.includes(rule.pattern);
74
+ }
75
+
76
+ /**
77
+ * Longest-match wins, so narrow `allow` exceptions override broad `block` rules
78
+ * (e.g. `> /dev/` = block, `> /dev/null` = allow).
79
+ */
80
+ export function findMatchingRule(text: string, rules: readonly SecurityRule[]): SecurityRule | null {
81
+ let best: SecurityRule | null = null;
82
+ for (const rule of rules) {
83
+ if (matchesPattern(text, rule)) {
84
+ if (best === null || rule.pattern.length > best.pattern.length) {
85
+ best = rule;
86
+ }
87
+ }
88
+ }
89
+ return best;
90
+ }
@@ -0,0 +1,12 @@
1
+ export type RuleAction = "prompt" | "block" | "allow";
2
+
3
+ export interface SecurityRule {
4
+ pattern: string;
5
+ action: RuleAction;
6
+ }
7
+
8
+ export interface SecurityRules {
9
+ operations: SecurityRule[];
10
+ writes: SecurityRule[];
11
+ reads: SecurityRule[];
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"