@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,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
|
+
}
|