bc22f3dbc8d1f48083c9b67f071851c619d02ec9
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
2index 257422432e44234e867e5d8ebc4c5ddd8083cac2..20a72afd5e0218752d668d4fa0f278e04a4d53f7 100644
3--- a/src/core/deterministic.ts
4+++ b/src/core/deterministic.ts
5@@ -2,6 +2,12 @@ import type { Decision } from "./types";
6 import { ALLOW_COMMANDS, ASK_PATTERNS, SHELL_CONTROL_RE, STDERR_REDIRECT_RE, DEVNULL_REDIRECT_RE } from "./rules";
7 import { stripRtkPrefix, expandHome } from "./normalize";
8
9+export type BashRedirect = {
10+ path: string;
11+ writes: boolean;
12+ dynamic: boolean;
13+};
14+
15 const DESTRUCTIVE_RM_TARGETS = new Set(["/", "/*", "~", "~/*", "/etc", "/usr", "/var", "/home", "/root"]);
16 const SYSTEM_DIRECTORIES = [
17 "/",
18@@ -21,9 +27,55 @@ const SYSTEM_DIRECTORIES = [
19 "/var",
20 ];
21 const LOCAL_MUTATION_COMMANDS = new Set(["chmod", "chown", "cp", "ln", "mkdir", "mv", "rm", "rmdir", "tee", "touch"]);
22+const FIND_OPERATORS = new Set(["!", "-not", "-a", "-and", "-o", "-or", "(", ")"]);
23+const FIND_UNARY_EXPRESSIONS = new Set([
24+ "-depth",
25+ "-empty",
26+ "-ls",
27+ "-print",
28+ "-print0",
29+ "-prune",
30+ "-readable",
31+ "-writable",
32+ "-executable",
33+]);
34+
35+function hasShellControl(command: string): boolean {
36+ let quote: "'" | '"' | undefined;
37+ for (let index = 0; index < command.length; index += 1) {
38+ const character = command[index];
39+ const nextCharacter = command[index + 1];
40+
41+ if (quote === "'") {
42+ if (character === "'") quote = undefined;
43+ continue;
44+ }
45+ if (quote === '"') {
46+ if (character === '"') {
47+ quote = undefined;
48+ continue;
49+ }
50+ if (character === "`" || (character === "$" && ["(", "{"].includes(nextCharacter ?? ""))) return true;
51+ if (character === "\\") index += 1;
52+ continue;
53+ }
54
55-function hasShellControl(cmd: string): boolean {
56- return SHELL_CONTROL_RE.test(cmd);
57+ if (character === "'") {
58+ quote = "'";
59+ continue;
60+ }
61+ if (character === '"') {
62+ quote = '"';
63+ continue;
64+ }
65+ if (character === "\\") {
66+ index += 1;
67+ continue;
68+ }
69+ if (character === "$" && ["(", "{"].includes(nextCharacter ?? "")) return true;
70+ if (SHELL_CONTROL_RE.test(character)) return true;
71+ }
72+ return quote !== undefined;
73 }
74
75 function shellWords(command: string): string[] {
76@@ -93,7 +145,7 @@ function hasOption(arguments_: string[], optionNames: ReadonlySet<string>): bool
77
78 function hasSecretPathOperand(command: string): boolean {
79 const [program, ...arguments_] = shellWords(command);
80- if (!program) return false;
81+ if (!program || program === "cd") return false;
82
83 if (program === "grep" || program === "rg") {
84 const fileOptions =
85@@ -153,6 +205,63 @@ function isDestructiveRmTarget(word: string): boolean {
86 return DESTRUCTIVE_RM_TARGETS.has(path) || path === process.env.HOME;
87 }
88
89+function findToken(argument: string): string {
90+ return argument === "\\(" ? "(" : argument === "\\)" ? ")" : argument;
91+}
92+
93+function findRootPaths(arguments_: string[]): string[] {
94+ const roots: string[] = [];
95+ for (const argument of arguments_) {
96+ const token = findToken(argument);
97+ if (token.startsWith("-") || FIND_OPERATORS.has(token)) break;
98+ roots.push(argument);
99+ }
100+ return roots;
101+}
102+
103+function isSafeFind(arguments_: string[]): boolean {
104+ let index = findRootPaths(arguments_).length;
105diff --git a/src/core/rules.ts b/src/core/rules.ts
106index aad9bd762c4f227568189658a9881452595c7921..9d1594461d63255105b7885b777a4bb235ee8018 100644
107--- a/src/core/rules.ts
108+++ b/src/core/rules.ts
109@@ -1,7 +1,7 @@
110 import { DECISION_CATEGORIES } from "./types";
111
112 // Bump to invalidate all cached decisions when rules change.
113-export const POLICY_VERSION = 35;
114+export const POLICY_VERSION = 36;
115
116 // Trailing stderr redirections that are safe to strip before pattern matching.
117 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
118@@ -22,6 +22,7 @@ export const ALLOW_COMMANDS: ReadonlySet<string> = new Set([
119 "bunx",
120 "cargo",
121 "cat",
122+ "cd",
123 "chmod",
124 "chown",
125 "composer",
126@@ -115,8 +116,7 @@ export const ASK_PATTERNS: RegExp[] = [
127 /^git(?:\s+-C\s+\S+)*\s+config\s+--system(?:\s|$)/,
128 ];
129
130-// Reject shell syntax from deterministic allow patterns so compound commands
131-// cannot receive a deterministic allow before their command nodes are evaluated.
132+// Raw core calls reject shell syntax. Parsed Pi commands provide structured redirects instead.
133 export const SHELL_CONTROL_RE = /[|;&`<>\r\n]|\$\(|\$\{/;
134
135 const EXTERNAL_DIRECTORIES_PLACEHOLDER = "<<EXTERNAL_DIRECTORIES>>";
136diff --git a/src/pi/bash-split.ts b/src/pi/bash-split.ts
137index dff2c9e7733dfbfb9207f8a9e2d884014dc0f78d..c5ecaa153fc3d6d9bb337b5776630424aca66dc1 100644
138--- a/src/pi/bash-split.ts
139+++ b/src/pi/bash-split.ts
140@@ -1,8 +1,14 @@
141 import { createRequire } from "node:module";
142 import { Language, Parser, type Node } from "web-tree-sitter";
143+import type { BashRedirect } from "../core/deterministic";
144+
145+export type BashSplitCommand = {
146+ source: string;
147+ redirects: BashRedirect[];
148+};
149
150 export type BashSplitResult = {
151- commands: string[];
152+ commands: BashSplitCommand[];
153 parsed: boolean;
154 };
155
156@@ -15,8 +21,36 @@ type BashParserLoader = () => Promise<BashParser>;
157 const require = createRequire(import.meta.url);
158 let parserPromise: Promise<BashParser> | undefined;
159
160-function source(node: Node): string {
161- return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim();
162+function redirectNodes(command: Node): Node[] {
163+ const redirects = [...command.childrenForFieldName("redirect")];
164+ for (let node = command.parent; node; node = node.parent) {
165+ if (node.type === "command_substitution" || node.type === "process_substitution") break;
166+ if (node.type === "redirected_statement") redirects.push(...node.childrenForFieldName("redirect"));
167+ }
168+ return redirects;
169+}
170+
171+function redirectPath(node: Node): string | undefined {
172+ const destination = node.childForFieldName("destination")?.text.trim();
173+ if (!destination || /[$`]/.test(destination)) return undefined;
174+ if (
175+ (destination.startsWith('"') && destination.endsWith('"')) ||
176+ (destination.startsWith("'") && destination.endsWith("'"))
177+ ) {
178+ return destination.slice(1, -1);
179+ }
180+ return destination;
181+}
182+
183+function redirects(command: Node): BashRedirect[] {
184+ return redirectNodes(command).map((node) => {
185+ const path = redirectPath(node);
186+ return {
187+ path: path ?? "",
188+ writes: node.text.includes(">"),
189+ dynamic: path === undefined,
190+ };
191+ });
192 }
193
194 async function loadBashParser(): Promise<BashParser> {
195@@ -35,7 +69,7 @@ async function loadBashParser(): Promise<BashParser> {
196 }
197
198 function raw(command: string): BashSplitResult {
199- return { commands: [command], parsed: false };
200+ return { commands: [{ source: command, redirects: [] }], parsed: false };
201 }
202
203 export async function splitBashCommand(
204@@ -47,14 +81,14 @@ export async function splitBashCommand(
205 const tree = parser.parse(command);
206 if (!tree || tree.rootNode.hasError) return raw(command);
207 try {
208- const commands: string[] = [];
209+ const commands: BashSplitCommand[] = [];
210 const seen = new Set<string>();
211 for (const node of tree.rootNode.descendantsOfType("command")) {
212 if (!node) continue;
213- const text = source(node);
214- if (text && !seen.has(text)) {
215- seen.add(text);
216- commands.push(text);
217+ const source = node.text.trim();
218+ if (source && !seen.has(source)) {
219+ seen.add(source);
220+ commands.push({ source, redirects: redirects(node) });
221 }
222 }
223 return { commands, parsed: true };
224diff --git a/src/pi/index.ts b/src/pi/index.ts
225index a1c3450e103cecfd0db861637adf7162e71f789a..f5d91cb42317e712c33c1128f132aa3ff7ef7165 100644
226--- a/src/pi/index.ts
227+++ b/src/pi/index.ts
228@@ -2,7 +2,7 @@ import type { UserMessage } from "@earendil-works/pi-ai";
229 import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
230 import { createJsonlDecisionAudit } from "../core/audit";
231 import { createJsonlDecisionCache } from "../core/cache";
232-import { checkDeterministic } from "../core/deterministic";
233+import { checkDeterministic, checkParsedBash, type BashRedirect } from "../core/deterministic";
234 import { auditInputSummary, cacheKey, normalizeRequest } from "../core/normalize";
235 import { createPolicyPipeline } from "../core/pipeline";
236 import { createPolicyReviewer, type PiReviewerCaller } from "../core/review";
237@@ -91,6 +91,7 @@ function createPiPipeline(
238 sessionBashAllowOverride: SessionBashAllowOverride | undefined,
239 cwd: string,
240 ctx: ExtensionContext,
241+ bashRedirects: WeakMap<object, readonly BashRedirect[]>,
242 ) {
243 const config = loadPiPolicyConfig(cwd);
244 const paths = piPolicyPaths();
245@@ -117,7 +118,15 @@ function createPiPipeline(
246 decide: async (request) => checkStaticPermission(request.toolName, request.input, cwd, config),
247 },
248 ],
249- deterministic: (request) => checkDeterministic(request.toolName, request.input),
250+ deterministic: (request) => {
251+ if (request.toolName !== "bash" || typeof request.input.command !== "string") {
252+ return checkDeterministic(request.toolName, request.input);
253+ }
254+ const redirects = bashRedirects.get(request.input);
255+ return redirects
256+ ? checkParsedBash(request.input.command, redirects)
257+ : checkDeterministic(request.toolName, request.input);
258+ },
259 cache: createJsonlDecisionCache(paths.cacheFile),
260 audit: createJsonlDecisionAudit(paths.auditFile),
261 reviewer: createPolicyReviewer(config.reviewer, piReviewerCaller(ctx), config.externalDirectories),
262@@ -137,7 +146,8 @@ export async function evaluatePiToolCall(
263 ctx?: ExtensionContext,
264 ): Promise<PolicyEvaluation> {
265 if (!ctx) throw new Error("Pi extension context is required");
266- const pipeline = createPiPipeline(sessionBashAllowOverride, context.cwd ?? process.cwd(), ctx);
267+ const bashRedirects = new WeakMap<object, readonly BashRedirect[]>();
268+ const pipeline = createPiPipeline(sessionBashAllowOverride, context.cwd ?? process.cwd(), ctx, bashRedirects);
269 const evaluationOptions = {
270 skipPrechecks: true,
271 skipLLMReview: skipPermissions,
272@@ -151,10 +161,11 @@ export async function evaluatePiToolCall(
273 }
274
275 const split = await splitBash(input.command);
276- const requests = split.commands.map((command) => ({
277- toolName,
278- input: { ...input, command },
279- }));
280+ const requests = split.commands.map((splitCommand) => {
281+ const commandInput = { ...input, command: splitCommand.source };
282+ bashRedirects.set(commandInput, splitCommand.redirects);
283+ return { toolName, input: commandInput };
284+ });
285 return pipeline.evaluateMany(requests, context, {
286 ...evaluationOptions,
287 skipDeterministic: !split.parsed,
288diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
289index a15f51e0ccb548840eb0d4dfa0c613139f3ddcc3..942730e0d09d1330697c5568eeebb3594af07669 100644
290--- a/test/core/deterministic.test.ts
291+++ b/test/core/deterministic.test.ts
292@@ -1,5 +1,5 @@
293 import { expect, test } from "bun:test";
294-import { checkDeterministic } from "../../src/core/deterministic";
295+import { checkDeterministic, checkParsedBash } from "../../src/core/deterministic";
296
297 test("allows sh syntax checks without restricting the source path", () => {
298 for (const command of [
299@@ -20,6 +20,9 @@ test("keeps shell execution subject to confirmation", () => {
300 test("allows recognized deterministic command grammars", () => {
301 for (const command of [
302 "command -v bun node",
303+ "cd ~/.ssh",
304+ 'grep -rn "namespace\\|Namespace" ~/go/pkg/mod/github.com/jessevdk/go-flags@v1.6.1/option.go',
305+ "echo 'a|b'",
306 "git add src/core/rules.ts",
307 "git commit -m 'Allow deterministic Git changes'",
308 "git commit -m 'sudo rm -rf /'",
309@@ -70,6 +73,22 @@ test("allows recognized deterministic command grammars", () => {
310 }
311 });
312
313+test("checks parsed Bash redirects", () => {
314+ expect(checkParsedBash("echo value", [{ path: "output.txt", writes: true, dynamic: false }])).toMatchObject({
315+ decision: "allow",
316+ });
317+ expect(checkParsedBash("cat .golangci.yml", [{ path: "/dev/null", writes: true, dynamic: false }])).toMatchObject({
318+ decision: "allow",
319+ });
320+ for (const redirect of [
321+ { path: ".env", writes: true, dynamic: false },
322+ { path: "/etc/policy-engine", writes: true, dynamic: false },
323+ { path: "", writes: true, dynamic: true },
324+ ]) {
325+ expect(checkParsedBash("echo value", [redirect])?.decision).not.toBe("allow");
326+ }
327+});
328+
329 test("allows local Docker operations except volume deletion", () => {
330 for (const command of [
331 "docker compose up -d",
332@@ -124,6 +143,24 @@ test("does not treat search terms as secret paths", () => {
333 expect(checkDeterministic("bash", { command: "rg '.env' README.md" })).toMatchObject({ decision: "allow" });
334 });
335
336+test("allows read-only find expressions", () => {
337+ for (const command of [
338+ "find .",
339+ "find src -type f -name '*.ts'",
340+ "find . -maxdepth 2 -type f -print",
341+ "find . -name '*.ts' -o -name '*.md'",
342+ 'find . -type f \\( -name "*.go" -o -name "*.md" -o -name "*.json" \\) -not -path "./.git/*"',
343+ ]) {
344+ expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "allow" });
345+ }
346+
347+ for (const command of ["find . -delete", "find . -exec cat {} \\;", "find . -fprint results.txt"]) {
348+ expect(checkDeterministic("bash", { command })?.decision).not.toBe("allow");
349+ }
350+
351+ expect(checkDeterministic("bash", { command: "find ~ -name '*.ts'" })).toMatchObject({ decision: "ask" });
352+});
353+
354 test("asks before destructive operations", () => {
355 for (const command of [
356 "git reset --hard",
357@@ -148,6 +185,7 @@ test("does not deterministically allow non-toolchain mutation or shell-control v
358 "kustomize build deploy",
359 "kubectl kustomize deploy",
360 "command -v node; rm -rf /",
361+ 'echo "$(pwd)"',
362 ]) {
363 expect(checkDeterministic("bash", { command })?.decision).not.toBe("allow");
364 }