ce3464e78bfe2c25cb9ecd19316fa53cbd5ef67b

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

add structured bash policy evaluation

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",