a3d77a27bcbafb8bb7aecdb1dd895c51ab50fbd5
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/README.md b/README.md
2index 847e959b19117660a510c07973a31dae97887a0c..d6625ec14b0c259d85a48e25b92f533c01126927 100644
3--- a/README.md
4+++ b/README.md
5@@ -88,7 +88,7 @@ Pi uses a root-level `tools` map and `externalDirectories` list. Any tool name c
6 }
7 ```
8
9diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
10index 20a72afd5e0218752d668d4fa0f278e04a4d53f7..d6032f4cfe40476de565855f92a74edbd2c68839 100644
11--- a/src/core/deterministic.ts
12+++ b/src/core/deterministic.ts
13@@ -1,3 +1,4 @@
14+import { normalize as normalizePath } from "node:path";
15 import type { Decision } from "./types";
16 import { ALLOW_COMMANDS, ASK_PATTERNS, SHELL_CONTROL_RE, STDERR_REDIRECT_RE, DEVNULL_REDIRECT_RE } from "./rules";
17 import { stripRtkPrefix, expandHome } from "./normalize";
18@@ -87,13 +88,103 @@ function shellWords(command: string): string[] {
19 });
20 }
21
22+function variableCharacter(character: string | undefined): boolean {
23+ return character !== undefined && /[A-Za-z0-9_]/.test(character);
24+}
25+
26+function hasUnsafeWordSyntax(command: string): boolean {
27+ let quote: "'" | '"' | undefined;
28+ let wordStarted = false;
29+ let quotedWord = false;
30+
31+ for (let index = 0; index < command.length; index += 1) {
32+ const character = command[index];
33+ const nextCharacter = command[index + 1];
34+
35+ if (quote === "'") {
36+ if (character === "'") {
37+ quote = undefined;
38+ continue;
39+ }
40+ if (character === "$" || character === "~") return true;
41+ continue;
42+ }
43+
44+ if (quote === '"') {
45+ if (character === '"') {
46+ quote = undefined;
47+ continue;
48+ }
49+ if (character === "\\") {
50+ if (nextCharacter === '"' || nextCharacter === "$" || nextCharacter === "`" || nextCharacter === "\\") {
51+ return true;
52+ }
53+ index += 1;
54+ continue;
55+ }
56+ if (character === "`") return true;
57+ if (character === "$") {
58+ if (command.slice(index + 1, index + 5) !== "HOME" || variableCharacter(command[index + 5])) return true;
59+ index += 4;
60+ continue;
61+ }
62+ if (character === "~") return true;
63+ continue;
64+ }
65+
66+ if (/\s/.test(character)) {
67+ quote = undefined;
68+ wordStarted = false;
69+ quotedWord = false;
70+ continue;
71+ }
72+ if (quotedWord) return true;
73+ if (character === "'" || character === '"') {
74+ if (wordStarted) return true;
75+ quote = character;
76+ wordStarted = true;
77+ quotedWord = true;
78+ continue;
79+ }
80+ if (character === "\\") {
81+ if (nextCharacter !== "(" && nextCharacter !== ")") return true;
82+ index += 1;
83+ wordStarted = true;
84+ continue;
85+ }
86+ if (character === "`" || SHELL_CONTROL_RE.test(character) || ["{", "}", "(", ")"].includes(character)) return true;
87+ if (character === "$") {
88+ if (command.slice(index + 1, index + 5) !== "HOME" || variableCharacter(command[index + 5])) return true;
89+ index += 4;
90+ wordStarted = true;
91+ continue;
92+ }
93+ if (character === "*") return true;
94+ if (character === "?") return true;
95+ if (character === "[") return true;
96+ if (character === "~") {
97+ if (wordStarted || (nextCharacter !== undefined && nextCharacter !== "/" && !/\s/.test(nextCharacter))) {
98+ return true;
99+ }
100+ }
101+ wordStarted = true;
102+ }
103+
104+ return quote !== undefined;
105+}
106+
107 function localPath(word: string): string {
108 const path = word.replace(/^@/, "").replace(/^file:\/\//, "");
109 return (path === "/" ? path : path.replace(/\/+$/, "")).replace(/[;,)]+$/, "");
110 }
111
112-function isSecretPath(word: string): boolean {
113diff --git a/src/core/normalize.ts b/src/core/normalize.ts
114index adb10b497067578f98038613766115bcf0081be6..d2488d68d5c290a70f47a58d9c9ecd18feb4d8b8 100644
115--- a/src/core/normalize.ts
116+++ b/src/core/normalize.ts
117@@ -8,6 +8,8 @@ const SENSITIVE_ARGUMENT_RE =
118 /((?:^|\s)(?:-u|-H)(?:=|\s*)|(?:^|\s)--(?:api[-_]?key|authorization|cookie|password|secret|token|user|proxy-user|header)(?:=|\s+))(?:(?:"(?:[^"\\]|\\.)*")|(?:'(?:[^'\\]|\\.)*')|\S+)/gi;
119 const URL_USERINFO_RE = /([a-z][a-z\d+.-]*:\/\/)[^/\s@]+@/gi;
120 const SENSITIVE_QUERY_RE = /([?&](?:api[-_]?key|authorization|credential|cookie|password|secret|token)=)[^&\s]*/gi;
121+const SENSITIVE_ENVIRONMENT_RE =
122diff --git a/src/core/rules.ts b/src/core/rules.ts
123index 9d1594461d63255105b7885b777a4bb235ee8018..eeb2be5615b1327d16ee50330a57a391120ef989 100644
124--- a/src/core/rules.ts
125+++ b/src/core/rules.ts
126@@ -112,6 +112,7 @@ export const ASK_PATTERNS: RegExp[] = [
127 /^herdr\s+server\s+stop\s*$/,
128 /^git(?:\s+-C\s+\S+)*\s+reset(?:\s+\S+)*\s+--hard(?:\s|$)/,
129 /^git(?:\s+-C\s+\S+)*\s+checkout\s+--(?:\s|$)/,
130+ /^git(?:\s+-C\s+\S+)*\s+checkout(?:\s+\S+)+\s+--(?:\s|$)/,
131 /^git(?:\s+-C\s+\S+)*\s+(?:clean|restore)(?:\s|$)/,
132 /^git(?:\s+-C\s+\S+)*\s+config\s+--system(?:\s|$)/,
133 ];
134@@ -169,11 +170,12 @@ Respond with ONLY this JSON, WITHOUT markdown formatting, otherwise this program
135 }`;
136
137 export function llmPolicyPrompt(externalDirectories: readonly string[] = []): string {
138+ const nonEmptyDirectories = externalDirectories.filter((pattern) => pattern.length > 0);
139 const directories =
140- externalDirectories.length === 0
141+ nonEmptyDirectories.length === 0
142 ? " - No additional directories."
143- : `${externalDirectories.map((pattern) => ` - ${JSON.stringify(pattern)}`).join("\n")}
144+ : `${nonEmptyDirectories.map((pattern) => ` - ${JSON.stringify(pattern)}`).join("\n")}
145
146-Treat these as trusted policy rules. Allow access when a tool-call path starts with a listed path. This overrides the general directory guidance above.`;
147+Treat these as trusted policy rules. Allow access when a tool-call path is the listed path or is nested below it by path components. Do not match sibling paths that only share a string prefix. This overrides the general directory guidance above.`;
148 return LLM_POLICY_PROMPT.replace(EXTERNAL_DIRECTORIES_PLACEHOLDER, directories);
149 }
150diff --git a/src/pi/bash-split.ts b/src/pi/bash-split.ts
151index c5ecaa153fc3d6d9bb337b5776630424aca66dc1..de0e73f73c298b275b21856767c11ce3ddbb94b0 100644
152--- a/src/pi/bash-split.ts
153+++ b/src/pi/bash-split.ts
154@@ -30,14 +30,37 @@ function redirectNodes(command: Node): Node[] {
155 return redirects;
156 }
157
158+function safeHomeExpansion(value: string): boolean {
159+ for (let index = 0; index < value.length; index += 1) {
160+ if (value[index] !== "$") continue;
161+ if (value.slice(index + 1, index + 5) !== "HOME" || /[A-Za-z0-9_]/.test(value[index + 5] ?? "")) return false;
162+ index += 4;
163+ }
164+ return true;
165+}
166+
167 function redirectPath(node: Node): string | undefined {
168 const destination = node.childForFieldName("destination")?.text.trim();
169- if (!destination || /[$`]/.test(destination)) return undefined;
170+ if (!destination) return undefined;
171+
172+ const quote = destination[0];
173+ if (quote === "'" || quote === '"') {
174+ if (destination.at(-1) !== quote) return undefined;
175+ const path = destination.slice(1, -1);
176+ if (path.includes("\\") || path.includes("`") || path.includes("~")) return undefined;
177+ if (quote === '"' && !safeHomeExpansion(path)) return undefined;
178+ if (quote === "'" && path.includes("$HOME")) return undefined;
179+ return path;
180+ }
181+
182 if (
183- (destination.startsWith('"') && destination.endsWith('"')) ||
184- (destination.startsWith("'") && destination.endsWith("'"))
185+ [...destination].some(
186+ (character) => /\s/.test(character) || ["'", '"', "\\", "`", "*", "?", "["].includes(character),
187+ ) ||
188+ !safeHomeExpansion(destination) ||
189+ (destination.includes("~") && !(destination === "~" || destination.startsWith("~/")))
190 ) {
191- return destination.slice(1, -1);
192+ return undefined;
193 }
194 return destination;
195 }
196@@ -82,16 +105,12 @@ export async function splitBashCommand(
197 if (!tree || tree.rootNode.hasError) return raw(command);
198 try {
199 const commands: BashSplitCommand[] = [];
200- const seen = new Set<string>();
201 for (const node of tree.rootNode.descendantsOfType("command")) {
202 if (!node) continue;
203 const source = node.text.trim();
204- if (source && !seen.has(source)) {
205- seen.add(source);
206- commands.push({ source, redirects: redirects(node) });
207- }
208+ if (source) commands.push({ source, redirects: redirects(node) });
209 }
210- return { commands, parsed: true };
211+ return commands.length === 0 && command.trim() ? raw(command) : { commands, parsed: true };
212 } finally {
213 tree.delete();
214 }
215diff --git a/src/pi/config.ts b/src/pi/config.ts
216index ada564c0862437dc4378276d0517941ee3d2aa43..0915976cc2abd5f5f7a52bda9a7997092a13ae43 100644
217--- a/src/pi/config.ts
218+++ b/src/pi/config.ts
219@@ -47,8 +47,8 @@ function toolPermissions(value: unknown): Record<string, PermissionAction> {
220 }
221
222 function externalDirectories(value: unknown): ExternalDirectories {
223- if (!Array.isArray(value) || !value.every((path) => typeof path === "string")) {
224- throw new Error("externalDirectories must be a JSON array of strings");
225+ if (!Array.isArray(value) || !value.every((path) => typeof path === "string" && path.length > 0)) {
226+ throw new Error("externalDirectories must be a JSON array of non-empty strings");
227 }
228 return value;
229 }
230@@ -66,6 +66,20 @@ export function parsePiPolicyConfig(value: unknown): PiPolicyEngineConfig {
231 };
232 }
233
234+function validatePiPolicyFragment(value: unknown): Record<string, unknown> {
235+ const data = objectValue(value);
236+ if (!data) throw new Error("policy engine configuration must be a JSON object");
237+ if (data.permission !== undefined) {
238+ throw new Error("permission has been renamed to tools; external_directory has been renamed to externalDirectories");
239+ }
240+ if (data.reviewer !== undefined && data.reviewer !== null && !objectValue(data.reviewer)) {
241+ throw new Error("reviewer must be a JSON object");
242+ }
243+ if (data.tools !== undefined) toolPermissions(data.tools);
244+ if (data.externalDirectories !== undefined) externalDirectories(data.externalDirectories);
245+ return data;
246+}
247+
248 function mergeObjects(
249 base: Record<string, unknown> | undefined,
250 override: Record<string, unknown> | undefined,
251@@ -99,11 +113,17 @@ function mergeConfig(base: Record<string, unknown>, override: Record<string, unk
252
253 export function loadPiPolicyConfig(cwd = process.cwd()): PiPolicyEngineConfig {
254 let config: Record<string, unknown> = {};
255- for (const path of configPaths(cwd)) {
256+ for (const [index, path] of configPaths(cwd).entries()) {
257 if (!existsSync(path)) continue;
258 const source = JSON.parse(readFileSync(path, "utf8"));
259- parsePiPolicyConfig(source);
260- config = mergeConfig(config, source as Record<string, unknown>);
261+ let data: Record<string, unknown>;
262+ if (index === 0) {
263+ parsePiPolicyConfig(source);
264+ data = source as Record<string, unknown>;
265+ } else {
266+ data = validatePiPolicyFragment(source);
267+ }
268+ config = mergeConfig(config, data);
269 }
270 return parsePiPolicyConfig(config);
271 }
272diff --git a/src/pi/static-permissions.ts b/src/pi/static-permissions.ts
273index 6c60b91cc0a31dc83ab39cd641b89c371390dcff..99a94b5fe993b3fb16746f13e490af9e2042c738 100644
274--- a/src/pi/static-permissions.ts
275+++ b/src/pi/static-permissions.ts
276@@ -17,7 +17,17 @@ function expandedPattern(pattern: string): string {
277 }
278
279 export function matchesExternalDirectory(directories: ExternalDirectories | undefined, value: string): boolean {
280- return directories?.some((directory) => value.startsWith(expandedPattern(directory))) ?? false;
281+ return (
282+ directories?.some((directory) => {
283+ const expanded = expandedPattern(directory);
284+ if (!expanded) return false;
285+ const pathFromDirectory = relative(expanded, value);
286+ return (
287+ pathFromDirectory === "" ||
288+ (!pathFromDirectory.startsWith(`..${sep}`) && pathFromDirectory !== ".." && !isAbsolute(pathFromDirectory))
289+ );
290+ }) ?? false
291+ );
292 }
293
294 async function canonicalPath(path: string, cwd: string): Promise<string> {
295@@ -61,6 +71,7 @@ export async function checkStaticPermission(
296 config: PiPolicyEngineConfig,
297 ): Promise<Decision | undefined> {
298 const action = config.tools[toolType] ?? "check";
299+ if (action === "deny") return staticDecision(action, `Static ${toolType} permission`, "config_allow");
300
301 let externalPath: string | undefined;
302 if (PATH_TOOLS.has(toolType)) {
303diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
304index 942730e0d09d1330697c5568eeebb3594af07669..1a9179c31fd983f4506641a47c7e3bc339698a59 100644
305--- a/test/core/deterministic.test.ts
306+++ b/test/core/deterministic.test.ts
307@@ -24,6 +24,7 @@ test("allows recognized deterministic command grammars", () => {
308 'grep -rn "namespace\\|Namespace" ~/go/pkg/mod/github.com/jessevdk/go-flags@v1.6.1/option.go',
309 "echo 'a|b'",
310 "git add src/core/rules.ts",
311+ "git checkout main",
312 "git commit -m 'Allow deterministic Git changes'",
313 "git commit -m 'sudo rm -rf /'",
314 "cp README.md README.copy",
315@@ -89,6 +90,45 @@ test("checks parsed Bash redirects", () => {
316 }
317 });
318
319+test("does not allow unresolved Bash word syntax", () => {
320+ for (const command of ['cat .e"nv"', 'cat "$TARGET"', 'rm -rf "$(echo -n /)"', 'echo x > /e"tc"/x']) {
321+ expect(checkDeterministic("bash", { command })?.decision).not.toBe("allow");
322+ }
323+
324+ expect(checkDeterministic("bash", { command: "echo $HOME" })).toMatchObject({ decision: "allow" });
325+});
326+
327+test("sends inline interpreter code to review while allowing ordinary toolchain commands", () => {
328+ for (const command of [
329+ 'python -c "print(1)"',
330+ 'python3 -c "print(1)"',
331+ 'node -e "console.log(1)"',
332+ 'node --eval "console.log(1)"',
333+ 'php -r "echo 1;"',
334+ 'ruby -e "puts 1"',
335+ 'perl -e "print 1"',
336+ 'deno eval "console.log(1)"',
337+ 'bun -e "console.log(1)"',
338+ 'bun --eval "console.log(1)"',
339+ 'elixir -e "IO.puts(1)"',
340+ 'scala -e "println(1)"',
341+ 'swift -e "print(1)"',
342+ 'mix run -e "IO.puts(1)"',
343+ ]) {
344+ expect(checkDeterministic("bash", { command })?.decision).not.toBe("allow");
345+ }
346+
347+ for (const command of [
348+ "python script.py",
349+ "node script.js",
350+ "php script.php",
351+ "ruby script.rb",
352+ "deno run script.ts",
353+ ]) {
354+ expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "allow" });
355+ }
356+});
357+
358 test("allows local Docker operations except volume deletion", () => {
359 for (const command of [
360 "docker compose up -d",
361@@ -104,8 +144,11 @@ test("allows local Docker operations except volume deletion", () => {
362 "docker volume rm app-data",
363 "docker volume prune -f",
364 "docker rm --volumes app",
365+ "docker rm -fv app",
366 "docker compose down -v",
367+ "docker compose down --volumes=true",
368 "docker system prune --volumes",
369+ "docker system prune --volumes=true",
370 ]) {
371 expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "ask" });
372 }
373@@ -139,6 +182,13 @@ test("asks before allowing reads from recognized secret paths", () => {
374 }
375 });
376
377+test("normalizes static absolute paths before checking home secrets", () => {
378+ const home = process.env.HOME ?? "/home/user";
379+ for (const secretPath of [`${home}/.docker/config.json`, `${home}/.netrc`, `${home}/.aws/credentials`]) {
380+ expect(checkDeterministic("bash", { command: `cat /tmp/..${secretPath}` })).toMatchObject({ decision: "ask" });
381+ }
382+});
383+
384 test("does not treat search terms as secret paths", () => {
385 expect(checkDeterministic("bash", { command: "rg '.env' README.md" })).toMatchObject({ decision: "allow" });
386 });
387@@ -165,7 +215,10 @@ test("asks before destructive operations", () => {
388 for (const command of [
389 "git reset --hard",
390 "git checkout -- src/core/rules.ts",
391+ "git checkout HEAD -- src/core/rules.ts",
392 "git clean -fd",
393+ "touch /tmp/../etc/policy-engine",
394+ "touch ~/../../etc/policy-engine",
395 "rm -rf /",
396 "rm -rf ~",
397 "rm -rf $HOME",
398@@ -176,6 +229,10 @@ test("asks before destructive operations", () => {
399 }
400 });
401
402+test("does not normalize dynamic paths into system paths", () => {
403+ expect(checkDeterministic("bash", { command: "touch /tmp/$TARGET/../etc/policy-engine" })?.decision).not.toBe("ask");
404+});
405+
406 test("does not deterministically allow non-toolchain mutation or shell-control variants", () => {
407diff --git a/test/pi/bash-split.test.ts b/test/pi/bash-split.test.ts
408new file mode 100644
409index 0000000000000000000000000000000000000000..03743a417cfc34b46313265a23a5959b9f064853
410--- /dev/null
411+++ b/test/pi/bash-split.test.ts
412@@ -0,0 +1,47 @@
413+import { expect, test } from "bun:test";
414+import { checkParsedBash } from "../../src/core/deterministic";
415+import { splitBashCommand } from "../../src/pi/bash-split";
416+
417+test("falls back to the raw command when valid Bash has no command nodes", async () => {
418+ await expect(splitBashCommand("> /etc/policy-engine")).resolves.toEqual({
419+ commands: [{ source: "> /etc/policy-engine", redirects: [] }],
420+ parsed: false,
421+ });
422+});
423+
424+test("keeps duplicate command nodes and their enclosing redirects", async () => {
425+ const result = await splitBashCommand("(echo x) > /tmp/out; (echo x) > /etc/policy-engine");
426+
427+ expect(result.parsed).toBe(true);
428+ expect(result.commands).toHaveLength(2);
429+ expect(result.commands.map((command) => command.source)).toEqual(["echo x", "echo x"]);
430+ expect(result.commands.map((command) => command.redirects.map((redirect) => redirect.path))).toEqual([
431+ ["/tmp/out"],
432+ ["/etc/policy-engine"],
433+ ]);
434+ expect(checkParsedBash(result.commands[0]!.source, result.commands[0]!.redirects)).toMatchObject({
435+ decision: "allow",
436+ });
437+ expect(checkParsedBash(result.commands[1]!.source, result.commands[1]!.redirects)).toMatchObject({ decision: "ask" });
438+});
439+
440+test("falls back for unresolved words and redirect paths in parsed Bash", async () => {
441+ for (const command of ['cat .e"nv"', 'rm -rf "$(echo -n /)"']) {
442+ const result = await splitBashCommand(command);
443+ expect(result.parsed).toBe(true);
444+ expect(checkParsedBash(result.commands[0]!.source, result.commands[0]!.redirects)?.decision).not.toBe("allow");
445+ }
446+
447+ const redirectResult = await splitBashCommand('echo x > /e"tc"/x');
448+ expect(redirectResult.parsed).toBe(true);
449+ expect(redirectResult.commands[0]!.redirects[0]).toMatchObject({ dynamic: true });
450+ expect(checkParsedBash(redirectResult.commands[0]!.source, redirectResult.commands[0]!.redirects)?.decision).not.toBe(
451+ "allow",
452+ );
453+
454+ const homeRedirect = await splitBashCommand('echo x > "$HOME/output.txt"');
455+ expect(homeRedirect.commands[0]!.redirects[0]).toMatchObject({ path: "$HOME/output.txt", dynamic: false });
456+ expect(checkParsedBash(homeRedirect.commands[0]!.source, homeRedirect.commands[0]!.redirects)).toMatchObject({
457+ decision: "allow",
458+ });
459+});
460diff --git a/test/pi/static-permissions.test.ts b/test/pi/static-permissions.test.ts
461new file mode 100644
462index 0000000000000000000000000000000000000000..3b8b9373d9fb46e295bc74a9313dc227f8fde745
463--- /dev/null
464+++ b/test/pi/static-permissions.test.ts
465@@ -0,0 +1,40 @@
466+import { expect, test } from "bun:test";
467+import { mkdtempSync, rmSync } from "node:fs";
468+import { tmpdir } from "node:os";
469+import { join } from "node:path";
470+import { checkStaticPermission, matchesExternalDirectory } from "../../src/pi/static-permissions";
471+
472+test("explicit deny wins before external path handling", async () => {
473+ const cwd = mkdtempSync(join(tmpdir(), "policy-engine-cwd-"));
474+ try {
475+ const decision = await checkStaticPermission(
476+ "write",
477+ { path: join(tmpdir(), "policy-engine-external-file") },
478+ cwd,
479+ { reviewer: { kind: "none" }, tools: { write: "deny" }, externalDirectories: [] },
480+ );
481+ expect(decision).toMatchObject({ decision: "deny" });
482+ } finally {
483+ rmSync(cwd, { recursive: true, force: true });
484+ }
485+});
486+
487+test("matches external directories by path components", async () => {
488+ const root = mkdtempSync(join(tmpdir(), "policy-engine-external-"));
489+ const cwd = join(root, "cwd");
490+ const external = join(root, "shared");
491+ const nested = join(external, "nested", "file.txt");
492+ const sibling = join(root, "shared-sibling", "file.txt");
493+ try {
494+ const config = { reviewer: { kind: "none" } as const, tools: {}, externalDirectories: [external] };
495+ await expect(checkStaticPermission("read", { path: nested }, cwd, config)).resolves.toMatchObject({
496+ decision: "allow",
497+ });
498+ await expect(checkStaticPermission("read", { path: sibling }, cwd, config)).resolves.toBeUndefined();
499+ expect(matchesExternalDirectory([external], `${external}-sibling`)).toBe(false);
500+ expect(matchesExternalDirectory([external], nested)).toBe(true);
501+ expect(matchesExternalDirectory([""], nested)).toBe(false);
502+ } finally {
503+ rmSync(root, { recursive: true, force: true });
504+ }
505+});