@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.
@@ -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.5",
3
+ "version": "0.6.7",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"