ce3464e78bfe2c25cb9ecd19316fa53cbd5ef67b
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/AGENTS.md b/AGENTS.md
2index 00381bc161a3b240b309d1a9847ae1fbf2c64a55..f9f2fd9f34c35d6f4cb29256f659ce8c3f44b798 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -6,8 +6,8 @@ This package provides a policy plugin for Pi. It combines host-specific precheck
6
7 ## Layout
8
9-- `src/core/` — shared rules, deterministic checks, pipeline, reviewer, llama.cpp provider, cache, audit, normalization, and configuration.
10-- `src/pi/` — Pi extension, static permissions, Bash parsing, UI, and session Bash overrides.
11+- `src/core/` — shared Bash parsing, glob resolution, rules, deterministic checks, pipeline, reviewer, llama.cpp provider, cache, audit, normalization, and configuration.
12+- `src/pi/` — Pi extension, static permissions, UI, and session Bash overrides.
13 - `test/core/`, `test/pi/` — unit and extension tests.
14
15 The package root and `./pi` export the Pi extension. `src/index.ts` re-exports `./pi/index` for Pi extension loading.
16@@ -16,7 +16,7 @@ Your general goal is to keep this code simple. Don't add things you're not asked
17
18 ## Policy flow
19
20-Pi evaluates session Bash overrides and static permissions before deterministic rules. It parses valid Bash into command nodes before evaluation. Unparsed Bash does not receive a deterministic allow and falls back to LLM review.
21diff --git a/README.md b/README.md
22index 285a47ffe078cc76a04a706409f37aa958398063..6a75e02dd865b305a14ac3836e7fddfb8e45b2cf 100644
23--- a/README.md
24+++ b/README.md
25@@ -93,7 +93,9 @@ Pi uses a root-level `tools` map and `externalDirectories` list. Any tool name c
26
27diff --git a/src/core/bash-glob.ts b/src/core/bash-glob.ts
28new file mode 100644
29index 0000000000000000000000000000000000000000..6b1100255016a620a3ad316e7a723459a7168c3f
30--- /dev/null
31+++ b/src/core/bash-glob.ts
32@@ -0,0 +1,93 @@
33+import { lstatSync, opendirSync, statSync } from "node:fs";
34+import { isAbsolute } from "node:path";
35+import type { BashWord } from "./bash";
36+
37+const MAX_ENTRIES = 2048;
38+const MAX_MATCHES = 256;
39+const MAX_COMPONENTS = 32;
40+
41+function componentPattern(pattern: string): { matcher: RegExp; active: boolean } | undefined {
42+ let source = "";
43+ let active = false;
44+ for (let index = 0; index < pattern.length; index += 1) {
45+ const character = pattern[index];
46+ if (character === "\\") {
47+ const literal = pattern[++index];
48+ if (literal === undefined) return undefined;
49+ source += literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50+ } else if (character === "*") {
51+ if (pattern[index + 1] === "*") return undefined;
52+ source += ".*";
53+ active = true;
54+ } else if (character === "?") {
55+ source += ".";
56+ active = true;
57+ } else if (character === "[") {
58+ active = true;
59+ const end = pattern.indexOf("]", index + 1);
60+ if (end === -1) return undefined;
61+ const content = pattern.slice(index + 1, end);
62+ if (!/^!?[A-Za-z0-9_.-]+$/.test(content)) return undefined;
63+ source += `[${content.replace(/^!/, "^")}]`;
64+ index = end;
65+ } else source += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66+ }
67+ try {
68+ return { matcher: new RegExp(`^${source}$`, "su"), active };
69+ } catch {
70+ return undefined;
71+ }
72+}
73+
74+export function resolveBashGlob(word: BashWord, cwd: string): string[] | undefined {
75+ if (word.unresolved) return undefined;
76+ if (word.glob === undefined) return [word.text];
77+ const components = word.glob.split("/");
78+ if (components.length > MAX_COMPONENTS) return undefined;
79+ let paths = [isAbsolute(word.glob) ? "/" : ""];
80+ const absolutePath = (path: string) => (isAbsolute(path) ? path : `${cwd}/${path}`);
81+ let entries = 0;
82+ try {
83+ for (const component of components) {
84+ if (!component) continue;
85+ const compiled = componentPattern(component);
86+ if (!compiled) return undefined;
87+ const { matcher, active } = compiled;
88+ const next: string[] = [];
89+ for (const path of paths) {
90+ if (!active) {
91+ const literal = component.replace(/\\(.)/g, "$1");
92+ next.push(`${path}${path && !path.endsWith("/") ? "/" : ""}${literal}`);
93+ continue;
94+ }
95+ const directory = opendirSync(absolutePath(path));
96+ try {
97+ for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
98+ if (++entries > MAX_ENTRIES) return undefined;
99+ if (component.includes("[") && [...entry.name].some((character) => character.charCodeAt(0) > 127))
100+ return undefined;
101+ if (entry.name.startsWith(".") && !component.startsWith(".")) continue;
102+ if (!matcher.test(entry.name)) continue;
103+ next.push(`${path}${path && !path.endsWith("/") ? "/" : ""}${entry.name}`);
104+ if (next.length > MAX_MATCHES) return undefined;
105+ }
106+ } finally {
107+ directory.closeSync();
108+ }
109+ }
110+ paths = next;
111+ }
112+ const matches = paths.filter((path) => {
113+ try {
114+ const metadata = word.text.endsWith("/") ? statSync(absolutePath(path)) : lstatSync(absolutePath(path));
115+ return !word.text.endsWith("/") || metadata.isDirectory();
116+ } catch (error) {
117+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
118+ throw error;
119+ }
120+ });
121+ return matches.length ? matches.sort().map((path) => path + (word.text.endsWith("/") ? "/" : "")) : [word.text];
122+ } catch {
123+ return undefined;
124+ }
125+}
126diff --git a/src/core/bash.ts b/src/core/bash.ts
127new file mode 100644
128index 0000000000000000000000000000000000000000..a2520e47387152ef4fc4f80b5c1a05330fca736f
129--- /dev/null
130+++ b/src/core/bash.ts
131@@ -0,0 +1,482 @@
132+import { createRequire } from "node:module";
133+import { Language, Parser, type Node } from "web-tree-sitter";
134+
135+export type BashWord = {
136+ text: string;
137+ glob: string | undefined;
138+ unresolved: boolean;
139+};
140+
141+export type BashRedirect = {
142+ operator: string;
143+ destination?: BashWord;
144+};
145+
146+export type BashCommand = {
147+ source: string;
148+ words: BashWord[];
149+ assignments: BashWord[];
150+ redirects: BashRedirect[];
151+};
152+
153+export type BashNode =
154+ | { kind: "command"; command: BashCommand }
155+ | { kind: "sequence"; operator: ";" | "&&" | "||" | "|"; left: BashNode; right: BashNode }
156+ | { kind: "scope"; body: BashNode }
157+ | { kind: "unsupported"; source: string };
158+
159+export type BashParseResult = {
160+ parsed: boolean;
161+ parserUnavailable?: boolean;
162+ root?: BashNode;
163+};
164+
165+type BashTree = {
166+ rootNode: Node;
167+ delete(): void;
168+};
169+
170+export type BashParser = {
171+ parse(command: string): BashTree | null;
172+};
173+
174+export type BashParserLoader = () => Promise<BashParser>;
175+
176+const require = createRequire(import.meta.url);
177+let parserPromise: Promise<BashParser> | undefined;
178+
179+const STATEMENT_TYPES = new Set([
180+ "case_statement",
181+ "c_style_for_statement",
182+ "command",
183+ "compound_statement",
184+ "declaration_command",
185+ "for_statement",
186+ "function_definition",
187+ "if_statement",
188+ "list",
189+ "negated_command",
190+ "pipeline",
191+ "redirected_statement",
192+ "subshell",
193+ "test_command",
194+ "unset_command",
195+ "variable_assignment",
196+ "while_statement",
197+]);
198+
199+const GLOB_CHARACTERS = new Set(["*", "?", "["]);
200+const GLOB_ESCAPE_CHARACTERS = new Set(["*", "?", "[", "]", "\\"]);
201+
202+type DecodedWord = {
203+ text: string;
204+ pattern: string;
205+ activeGlob: boolean;
206+ unresolved: boolean;
207+};
208+
209+type DecodeContext = {
210+ tildeEligible: boolean;
211+};
212+
213+function emptyWord(): DecodedWord {
214+ return { text: "", pattern: "", activeGlob: false, unresolved: false };
215+}
216+
217+function appendWord(target: DecodedWord, part: DecodedWord): void {
218+ target.text += part.text;
219+ target.pattern += part.pattern;
220+ target.activeGlob ||= part.activeGlob;
221+ target.unresolved ||= part.unresolved;
222+}
223+
224+function literalPattern(text: string): string {
225+ let pattern = "";
226+ for (const character of text) {
227+ pattern += GLOB_ESCAPE_CHARACTERS.has(character) ? `\\${character}` : character;
228+ }
229+ return pattern;
230+}
231diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
232index bff7b0762bf024a44db9115d9859a8e093305e85..2d2d9eb628ff77676b31d8014bebdce6d4b30135 100644
233--- a/src/core/deterministic.ts
234+++ b/src/core/deterministic.ts
235@@ -1,17 +1,11 @@
236-import { normalize as normalizePath } from "node:path";
237+import { lstatSync, readlinkSync, statSync } from "node:fs";
238+import { dirname, isAbsolute, resolve } from "node:path";
239+import { parseBash, type BashCommand, type BashNode, type BashParseResult } from "./bash";
240+import { resolveBashGlob } from "./bash-glob";
241+import { ALLOW_COMMANDS } from "./rules";
242 import type { Decision } from "./types";
243-import { ALLOW_COMMANDS, ASK_PATTERNS, SHELL_CONTROL_RE, STDERR_REDIRECT_RE, DEVNULL_REDIRECT_RE } from "./rules";
244-import { stripRtkPrefix, expandHome } from "./normalize";
245
246-export type BashRedirect = {
247- path: string;
248- writes: boolean;
249- dynamic: boolean;
250-};
251-
252-const DESTRUCTIVE_RM_TARGETS = new Set(["/", "/*", "~", "~/*", "/etc", "/usr", "/var", "/home", "/root"]);
253 const SYSTEM_DIRECTORIES = [
254- "/",
255 "/bin",
256 "/boot",
257 "/dev",
258@@ -27,9 +21,40 @@ const SYSTEM_DIRECTORIES = [
259 "/usr",
260 "/var",
261 ];
262-const LOCAL_MUTATION_COMMANDS = new Set(["chmod", "chown", "cp", "ln", "mkdir", "mv", "rm", "rmdir", "tee", "touch"]);
263+const MUTATIONS = new Set(["chmod", "chown", "cp", "ln", "mkdir", "mv", "rm", "rmdir", "tee", "touch"]);
264+const READ_ONLY = new Set([
265+ "base64",
266+ "cat",
267+ "command",
268+ "cut",
269+ "date",
270+ "dirname",
271+ "echo",
272+ "file",
273+ "find",
274+ "getent",
275+ "grep",
276+ "head",
277+ "id",
278+ "ls",
279+ "nl",
280+ "printf",
281+ "pwd",
282+ "readlink",
283+ "rg",
284+ "sed",
285+ "sleep",
286+ "stat",
287+ "strings",
288+ "tail",
289+ "true",
290+ "type",
291+ "wc",
292+ "whereis",
293+ "which",
294+]);
295 const FIND_OPERATORS = new Set(["!", "-not", "-a", "-and", "-o", "-or", "(", ")"]);
296-const FIND_UNARY_EXPRESSIONS = new Set([
297+const FIND_UNARY = new Set([
298 "-depth",
299 "-empty",
300 "-ls",
301@@ -41,570 +66,515 @@ const FIND_UNARY_EXPRESSIONS = new Set([
302 "-executable",
303 ]);
304
305-function hasShellControl(command: string): boolean {
306- let quote: "'" | '"' | undefined;
307- for (let index = 0; index < command.length; index += 1) {
308- const character = command[index];
309- const nextCharacter = command[index + 1];
310-
311- if (quote === "'") {
312- if (character === "'") quote = undefined;
313- continue;
314- }
315- if (quote === '"') {
316- if (character === '"') {
317- quote = undefined;
318- continue;
319- }
320- if (character === "`" || (character === "$" && ["(", "{"].includes(nextCharacter ?? ""))) return true;
321- if (character === "\\") index += 1;
322- continue;
323- }
324-
325- if (character === "'") {
326- quote = "'";
327- continue;
328- }
329- if (character === '"') {
330- quote = '"';
331- continue;
332- }
333- if (character === "\\") {
334- index += 1;
335diff --git a/src/core/index.ts b/src/core/index.ts
336index 2215d6055fc05215847bc512ef036684e8be4229..ce4318e3b8dee42f787a3c2e8c61b52d5f1174a5 100644
337--- a/src/core/index.ts
338+++ b/src/core/index.ts
339@@ -1,4 +1,5 @@
340 export * from "./audit";
341+export * from "./bash";
342 export * from "./cache";
343 export * from "./config";
344 export * from "./deterministic";
345diff --git a/src/core/normalize.ts b/src/core/normalize.ts
346index d2488d68d5c290a70f47a58d9c9ecd18feb4d8b8..646ad24bda2c3f316d872bfea2912f004f9d2929 100644
347--- a/src/core/normalize.ts
348+++ b/src/core/normalize.ts
349@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
350 import { POLICY_VERSION } from "./rules";
351 import type { PolicyRequest } from "./types";
352
353-const RTK_PREFIX_RE = /^rtk\s+/;
354 const SENSITIVE_KEY_RE = /(?:api[-_]?key|auth(?:entication|orization)?|credential|cookie|password|secret|token)/i;
355 const SENSITIVE_ARGUMENT_RE =
356 /((?:^|\s)(?:-u|-H)(?:=|\s*)|(?:^|\s)--(?:api[-_]?key|authorization|cookie|password|secret|token|user|proxy-user|header)(?:=|\s+))(?:(?:"(?:[^"\\]|\\.)*")|(?:'(?:[^'\\]|\\.)*')|\S+)/gi;
357@@ -11,29 +10,6 @@ const SENSITIVE_QUERY_RE = /([?&](?:api[-_]?key|authorization|credential|cookie|
358 const SENSITIVE_ENVIRONMENT_RE =
359diff --git a/src/core/pipeline.ts b/src/core/pipeline.ts
360index ffc9893f16f4be3168c6f70777aae122bc8d4752..85e8841a215310648921914810e781c4ad77df8b 100644
361--- a/src/core/pipeline.ts
362+++ b/src/core/pipeline.ts
363@@ -12,7 +12,7 @@ import type {
364
365 export type PolicyPipelineOptions = {
366 prechecks?: PolicyPrecheck[];
367- deterministic(request: PolicyRequest, context: PolicyContext): Decision | undefined;
368+ deterministic(request: PolicyRequest, context: PolicyContext): Decision | undefined | Promise<Decision | undefined>;
369 cache: DecisionCache;
370 audit: DecisionAudit;
371 reviewer: PolicyReviewer;
372@@ -85,17 +85,19 @@ export function createPolicyPipeline(options: PolicyPipelineOptions) {
373
374 const startedAt = performance.now();
375 if (!evaluationOptions.skipDeterministic) {
376- const deterministic = options.deterministic(request, context);
377+ const deterministic = await options.deterministic(request, context);
378 if (deterministic) {
379 return complete(request, context, { decision: deterministic, source: "deterministic" }, startedAt);
380 }
381 }
382
383+ const cacheAllows = request.toolName !== "bash";
384 const normalized = options.normalize(request, context);
385 const key = options.cacheKey(normalized);
386 try {
387 const cached = await options.cache.lookup(key);
388- if (cached) return complete(request, context, { decision: cached, source: "cache" }, startedAt);
389+ if (cached && (cached.decision !== "allow" || cacheAllows))
390+ return complete(request, context, { decision: cached, source: "cache" }, startedAt);
391 } catch {}
392
393 if (evaluationOptions.skipLLMReview) {
394@@ -122,7 +124,7 @@ export function createPolicyPipeline(options: PolicyPipelineOptions) {
395 rawResponse: reviewed.rawResponse,
396 error: reviewed.error,
397 };
398- if (!reviewed.error && source === "llm") {
399+ if (!reviewed.error && source === "llm" && (reviewed.decision.decision !== "allow" || cacheAllows)) {
400 try {
401 await options.cache.write({
402 key,
403diff --git a/src/core/rules.ts b/src/core/rules.ts
404index 385f3720a69952278e48016d76eebc046ca2fffe..5478a044bd65ab5cf4bd140d4343fba7b21e8eba 100644
405--- a/src/core/rules.ts
406+++ b/src/core/rules.ts
407@@ -1,16 +1,7 @@
408 import { DECISION_CATEGORIES } from "./types";
409
410 // Bump to invalidate all cached decisions when rules change.
411-export const POLICY_VERSION = 38;
412-
413-// Trailing stderr redirections that are safe to strip before pattern matching.
414-// `2>&1` and `2>/dev/null` have no security implication but would otherwise
415-// trip SHELL_CONTROL_RE's `>` check.
416-export const STDERR_REDIRECT_RE = /\s+2>(?:&1|\/dev\/null)\s*$/;
417-
418-// Trailing `> /dev/null` discards stdout — no security implication, but
419-// would otherwise trip SHELL_CONTROL_RE's `>` check.
420-export const DEVNULL_REDIRECT_RE = /\s+>\s*\/dev\/null\s*$/;
421+export const POLICY_VERSION = 39;
422
423 // Local commands that can run without LLM review. Secret paths, destructive
424 // Git operations, system-file changes, and shell control syntax are checked first.
425@@ -105,21 +96,6 @@ export const ALLOW_COMMANDS: ReadonlySet<string> = new Set([
426 "yarnpkg",
427 ]);
428
429-// Exact command forms that require user confirmation.
430-export const ASK_PATTERNS: RegExp[] = [
431- /^(?:sudo|doas|su)(?:\s|$)/,
432- /^(ba)?sh(?!\s+-n(?:\s|$))\b/,
433- /^herdr\s+server\s+stop\s*$/,
434- /^git(?:\s+-C\s+\S+)*\s+reset(?:\s+\S+)*\s+--hard(?:\s|$)/,
435- /^git(?:\s+-C\s+\S+)*\s+checkout\s+--(?:\s|$)/,
436- /^git(?:\s+-C\s+\S+)*\s+checkout(?:\s+\S+)+\s+--(?:\s|$)/,
437- /^git(?:\s+-C\s+\S+)*\s+(?:clean|restore)(?:\s|$)/,
438- /^git(?:\s+-C\s+\S+)*\s+config\s+--system(?:\s|$)/,
439-];
440-
441-// Raw core calls reject shell syntax. Parsed Pi commands provide structured redirects instead.
442-export const SHELL_CONTROL_RE = /[|;&`<>\r\n]|\$\(|\$\{/;
443-
444 const EXTERNAL_DIRECTORIES_PLACEHOLDER = "<<EXTERNAL_DIRECTORIES>>";
445
446 export const LLM_POLICY_PROMPT = `Classify the serialized tool call as allow or ask.
447diff --git a/src/pi/bash-split.ts b/src/pi/bash-split.ts
448deleted file mode 100644
449index 111aa1b9248f4ca9d71cde0d78c3d59919570f6c..0000000000000000000000000000000000000000
450--- a/src/pi/bash-split.ts
451+++ /dev/null
452@@ -1,131 +0,0 @@
453-import { createRequire } from "node:module";
454-import { Language, Parser, type Node } from "web-tree-sitter";
455-import type { BashRedirect } from "../core/deterministic";
456-
457-export type BashSplitCommand = {
458- source: string;
459- redirects: BashRedirect[];
460-};
461-
462-export type BashSplitResult = {
463- commands: BashSplitCommand[];
464- parsed: boolean;
465- parserUnavailable?: boolean;
466-};
467-
468-type BashParser = {
469- parse(command: string): { rootNode: Node; delete(): void } | null;
470-};
471-
472-type BashParserLoader = () => Promise<BashParser>;
473-
474-const require = createRequire(import.meta.url);
475-let parserPromise: Promise<BashParser> | undefined;
476-
477-function redirectNodes(command: Node): Node[] {
478- const redirects = [...command.childrenForFieldName("redirect")];
479- for (let node = command.parent; node; node = node.parent) {
480- if (node.type === "command_substitution" || node.type === "process_substitution") break;
481- if (node.type === "redirected_statement") redirects.push(...node.childrenForFieldName("redirect"));
482- }
483- return redirects;
484-}
485-
486-function safeHomeExpansion(value: string): boolean {
487- for (let index = 0; index < value.length; index += 1) {
488- if (value[index] !== "$") continue;
489- if (value.slice(index + 1, index + 5) !== "HOME" || /[A-Za-z0-9_]/.test(value[index + 5] ?? "")) return false;
490- index += 4;
491- }
492- return true;
493-}
494-
495-function redirectPath(node: Node): string | undefined {
496- const destination = node.childForFieldName("destination")?.text.trim();
497- if (!destination) return undefined;
498-
499- const quote = destination[0];
500- if (quote === "'" || quote === '"') {
501- if (destination.at(-1) !== quote) return undefined;
502- const path = destination.slice(1, -1);
503- if (path.includes("\\") || path.includes("`") || path.includes("~")) return undefined;
504- if (quote === '"' && !safeHomeExpansion(path)) return undefined;
505- if (quote === "'" && path.includes("$HOME")) return undefined;
506- return path;
507- }
508-
509- if (
510- [...destination].some(
511- (character) => /\s/.test(character) || ["'", '"', "\\", "`", "*", "?", "["].includes(character),
512- ) ||
513- !safeHomeExpansion(destination) ||
514- (destination.includes("~") && !(destination === "~" || destination.startsWith("~/")))
515- ) {
516- return undefined;
517- }
518- return destination;
519-}
520-
521-function redirects(command: Node): BashRedirect[] {
522- return redirectNodes(command).map((node) => {
523- const path = redirectPath(node);
524- return {
525- path: path ?? "",
526- writes: node.text.includes(">"),
527- dynamic: path === undefined,
528- };
529- });
530-}
531-
532-async function loadBashParser(): Promise<BashParser> {
533- if (!parserPromise) {
534- parserPromise = (async () => {
535- const parserWasm = require.resolve("web-tree-sitter/web-tree-sitter.wasm");
536- const bashWasm = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
537- await Parser.init({ locateFile: () => parserWasm });
538- const language = await Language.load(bashWasm);
539- const parser = new Parser();
540- parser.setLanguage(language);
541- return parser;
542- })();
543- }
544- return parserPromise;
545-}
546-
547-function raw(command: string): BashSplitResult {
548- return { commands: [{ source: command, redirects: [] }], parsed: false };
549-}
550-
551-function parserUnavailable(command: string): BashSplitResult {
552diff --git a/src/pi/index.ts b/src/pi/index.ts
553index 29333913397c6c74ad65944e4e826b933fbaeecf..deb0da960133d9ef73331dd8eb35d6ae93916805 100644
554--- a/src/pi/index.ts
555+++ b/src/pi/index.ts
556@@ -2,12 +2,12 @@ import type { UserMessage } from "@earendil-works/pi-ai";
557 import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
558 import { createJsonlDecisionAudit } from "../core/audit";
559 import { createJsonlDecisionCache } from "../core/cache";
560-import { checkDeterministic, checkParsedBash, type BashRedirect } from "../core/deterministic";
561+import { parseBash, type BashParseResult } from "../core/bash";
562+import { checkDeterministic, checkParsedBash } from "../core/deterministic";
563 import { auditInputSummary, cacheKey, normalizeRequest } from "../core/normalize";
564 import { createPolicyPipeline } from "../core/pipeline";
565 import { createPolicyReviewer, type PiReviewerCaller } from "../core/review";
566 import type { PolicyContext, PolicyEvaluation, PolicyRequest } from "../core/types";
567-import { splitBashCommand, type BashSplitResult } from "./bash-split";
568 import { loadPiPolicyConfig, piPolicyPaths } from "./config";
569 import { PolicyApprovalDialog } from "./policy-approval";
570 import {
571@@ -91,7 +91,7 @@ function createPiPipeline(
572 sessionBashAllowOverride: SessionBashAllowOverride | undefined,
573 cwd: string,
574 ctx: ExtensionContext,
575- bashRedirects: WeakMap<object, readonly BashRedirect[]>,
576+ parsedBash: WeakMap<object, BashParseResult>,
577 ) {
578 const config = loadPiPolicyConfig(cwd);
579 const paths = piPolicyPaths();
580@@ -118,14 +118,11 @@ function createPiPipeline(
581 decide: async (request) => checkStaticPermission(request.toolName, request.input, cwd, config),
582 },
583 ],
584- deterministic: (request) => {
585- if (request.toolName !== "bash" || typeof request.input.command !== "string") {
586- return checkDeterministic(request.toolName, request.input);
587- }
588- const redirects = bashRedirects.get(request.input);
589- return redirects
590- ? checkParsedBash(request.input.command, redirects)
591- : checkDeterministic(request.toolName, request.input);
592+ deterministic: (request, context) => {
593+ const parsed = parsedBash.get(request.input);
594+ return parsed
595+ ? checkParsedBash(parsed, context.cwd ?? cwd)
596+ : checkDeterministic(request.toolName, request.input, context.cwd ?? cwd);
597 },
598 cache: createJsonlDecisionCache(paths.cacheFile),
599 audit: createJsonlDecisionAudit(paths.auditFile),
600@@ -141,13 +138,13 @@ export async function evaluatePiToolCall(
601 input: ToolInput,
602 context: PolicyContext,
603 sessionBashAllowOverride: SessionBashAllowOverride | undefined,
604- splitBash: (command: string) => Promise<BashSplitResult> = splitBashCommand,
605+ parse: (command: string) => Promise<BashParseResult> = parseBash,
606 skipPermissions = false,
607 ctx?: ExtensionContext,
608 ): Promise<PolicyEvaluation> {
609 if (!ctx) throw new Error("Pi extension context is required");
610- const bashRedirects = new WeakMap<object, readonly BashRedirect[]>();
611- const pipeline = createPiPipeline(sessionBashAllowOverride, context.cwd ?? process.cwd(), ctx, bashRedirects);
612+ const parsedBash = new WeakMap<object, BashParseResult>();
613+ const pipeline = createPiPipeline(sessionBashAllowOverride, context.cwd ?? process.cwd(), ctx, parsedBash);
614 const evaluationOptions = {
615 skipPrechecks: true,
616 skipLLMReview: skipPermissions,
617@@ -160,8 +157,8 @@ export async function evaluatePiToolCall(
618 return pipeline.evaluate(request, context, evaluationOptions);
619 }
620
621- const split = await splitBash(input.command);
622- if (split.parserUnavailable) {
623+ const parsed = await parse(input.command);
624+ if (parsed.parserUnavailable) {
625 return {
626 decision: {
627 decision: "deny",
628@@ -172,15 +169,8 @@ export async function evaluatePiToolCall(
629 };
630 }
631
632- const requests = split.commands.map((splitCommand) => {
633- const commandInput = { ...input, command: splitCommand.source };
634- bashRedirects.set(commandInput, splitCommand.redirects);
635- return { toolName, input: commandInput };
636- });
637- return pipeline.evaluateMany(requests, context, {
638- ...evaluationOptions,
639- skipDeterministic: !split.parsed,
640- });
641+ parsedBash.set(input, parsed);
642+ return pipeline.evaluate(request, context, evaluationOptions);
643 }
644
645 export default function policyEngine(pi: ExtensionAPI) {
646@@ -261,7 +251,7 @@ export default function policyEngine(pi: ExtensionAPI) {
647 signal: ctx.signal,
648 },
649 sessionBashAllowOverride,
650- splitBashCommand,
651+ parseBash,
652 skipPermissions,
653 ctx,
654 );
655diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
656index 22f8c9687d5015ea68ffc629543179baa5f8c106..a90397951cfe75a542c223369e25e657f94180bb 100644
657--- a/test/core/deterministic.test.ts
658+++ b/test/core/deterministic.test.ts
659@@ -1,302 +1,387 @@
660-import { expect, test } from "bun:test";
661+import { afterAll, beforeAll, expect, test } from "bun:test";
662+import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
663+import { tmpdir } from "node:os";
664+import { join } from "node:path";
665 import { checkDeterministic, checkParsedBash } from "../../src/core/deterministic";
666+import { parseBash } from "../../src/core/bash";
667
668-test("allows sh syntax checks without restricting the source path", () => {
669- for (const command of [
670- "sh -n script.sh",
671- "sh -n ./script.sh",
672- "bash -n /tmp/script.sh",
673- "bash -n ../script.sh",
674- "bash -n",
675- ]) {
676- expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "allow" });
677- }
678+let cwd: string;
679+beforeAll(() => {
680+ cwd = mkdtempSync(join(tmpdir(), "policy-bash-"));
681+ for (const directory of [
682+ "safe/Sources",
683+ "mixed",
684+ "options",
685+ "project/.ssh",
686+ "empty",
687+ "many",
688+ "escaped",
689+ "credentials/nested",
690+ "dangling",
691+ "injected",
692+ ])
693+ mkdirSync(join(cwd, directory), { recursive: true });
694+ for (const path of [
695+ "README.md",
696+ "credentials/data.txt",
697+ "dangling/main.swift",
698+ "injected/--include=credentials.go",
699+ "safe/Sources/main.swift",
700+ "safe/Sources/view.swift",
701+ "mixed/main.swift",
702+ "mixed/credentials.swift",
703+ "options/-f.env",
704+ "options/.env",
705+ "project/.ssh/config",
706+ "escaped/a*b.swift",
707+ "escaped/a\\b.swift",
708+ "escaped/.env",
709+ ])
710+ writeFileSync(join(cwd, path), "");
711+ for (let index = 0; index < 260; index += 1) writeFileSync(join(cwd, "many", `${index}.txt`), "");
712+ symlinkSync(join(cwd, "mixed/credentials.swift"), join(cwd, "safe/linked.swift"));
713+ symlinkSync(join(cwd, "credentials/nested"), join(cwd, "shortcut"));
714+ symlinkSync(join(cwd, "missing"), join(cwd, "dangling/credentials.swift"));
715+ symlinkSync(join(cwd, "credentials/missing"), join(cwd, "future.swift"));
716+ symlinkSync("/etc/policy-engine-missing", join(cwd, "system-output"));
717+ symlinkSync("cycle", join(cwd, "cycle"));
718 });
719+afterAll(() => rmSync(cwd, { recursive: true, force: true }));
720
721-test("keeps shell execution subject to confirmation", () => {
722- expect(checkDeterministic("bash", { command: "bash script.sh -n" })).toMatchObject({ decision: "ask" });
723-});
724+const allowed = [
725+ "sh -n script.sh",
726+ "sh -n ./script.sh",
727+ "bash -n /tmp/script.sh",
728+ "bash -n ../script.sh",
729+ "bash -n",
730+ "command -v bun node",
731+ "cd ~/.ssh",
732+ 'grep -rn "namespace\\|Namespace" ~/go/pkg/mod/github.com/jessevdk/go-flags@v1.6.1/option.go',
733+ 'grep -iE "golangci|\\.go$"',
734+ 'grep -E "foo$|bar" README.md',
735+ "echo 'a|b'",
736+ "echo .env",
737+ "echo mixed/*.swift",
738+ "grep credentials README.md",
739+ "printf '%s' .env",
740+ "echo '$HOME'",
741+ "echo '$TARGET'",
742+ 'echo "\\$TARGET"',
743+ "git add src/core/rules.ts",
744+ "git checkout main",
745+ "git diff main~1 --stat",
746+ 'git "reset$" "--hard"',
747+ 'cat ".e$"nv',
748+ "git commit -m 'Allow deterministic Git changes'",
749+ "git commit -m 'sudo rm -rf /'",
750+ "git commit -m .env",
751+ "cp README.md README.copy",
752+ "mv README.copy README.old",
753+ "rm README.old",
754+ "mkdir -p build/output",
755+ "touch build/output/result.txt",
756+ "chmod u+x build/output/result.txt",
757+ "tee build/output/summary.txt",
758+ "bun test",