From d0f84d9a38ac22944cac4f634c56467fb5ef75be Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 14:50:10 -0600 Subject: [PATCH 1/2] feat: add bash interceptor to block commands that duplicate dedicated tools Regex-based pre-execution check in the bash tool blocks shell commands (grep, cat, sed -i, etc.) when the dedicated replacement tool is available in the session. Configurable via bashInterceptor settings. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pi-coding-agent/src/core/agent-session.ts | 9 +- .../src/core/settings-manager.ts | 15 +++ .../src/core/tools/bash-interceptor.ts | 99 +++++++++++++++++++ .../pi-coding-agent/src/core/tools/bash.ts | 23 +++++ .../pi-coding-agent/src/core/tools/index.ts | 6 ++ packages/pi-coding-agent/src/index.ts | 4 + 6 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/pi-coding-agent/src/core/tools/bash-interceptor.ts diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 69e7d6680..cf44b036b 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -2188,7 +2188,14 @@ export class AgentSession { ? this._baseToolsOverride : createAllTools(this._cwd, { read: { autoResizeImages }, - bash: { commandPrefix: shellCommandPrefix }, + bash: { + commandPrefix: shellCommandPrefix, + interceptor: { + enabled: this.settingsManager.getBashInterceptorEnabled(), + rules: this.settingsManager.getBashInterceptorRules(), + }, + availableToolNames: () => this.getActiveToolNames(), + }, }); this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool])); diff --git a/packages/pi-coding-agent/src/core/settings-manager.ts b/packages/pi-coding-agent/src/core/settings-manager.ts index ef61acad8..3c1521c2e 100644 --- a/packages/pi-coding-agent/src/core/settings-manager.ts +++ b/packages/pi-coding-agent/src/core/settings-manager.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import type { BashInterceptorRule } from "./tools/bash-interceptor.js"; export interface CompactionSettings { enabled?: boolean; // default: true @@ -39,6 +40,11 @@ export interface ThinkingBudgetsSettings { high?: number; } +export interface BashInterceptorSettings { + enabled?: boolean; // default: true + rules?: BashInterceptorRule[]; // override default rules +} + export interface MarkdownSettings { codeBlockIndent?: string; // default: " " } @@ -93,6 +99,7 @@ export interface Settings { autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME markdown?: MarkdownSettings; + bashInterceptor?: BashInterceptorSettings; } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -939,4 +946,12 @@ export class SettingsManager { getCodeBlockIndent(): string { return this.settings.markdown?.codeBlockIndent ?? " "; } + + getBashInterceptorEnabled(): boolean { + return this.settings.bashInterceptor?.enabled ?? true; + } + + getBashInterceptorRules(): BashInterceptorRule[] | undefined { + return this.settings.bashInterceptor?.rules; + } } diff --git a/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts b/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts new file mode 100644 index 000000000..acf719238 --- /dev/null +++ b/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts @@ -0,0 +1,99 @@ +/** + * Bash command interceptor — blocks shell commands that duplicate dedicated tools. + * + * Each rule defines a regex pattern, a suggested replacement tool, and a message. + * A command is only blocked when the suggested tool exists in the session's active tool list. + */ + +export interface BashInterceptorRule { + pattern: string; + flags?: string; + tool: string; + message: string; +} + +export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [ + { + pattern: "^\\s*(cat|head|tail|less|more)\\s+", + tool: "read", + message: "Use the read tool to view file contents instead of shell commands.", + }, + { + pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+", + tool: "grep", + message: "Use the grep tool for searching file contents instead of shell commands.", + }, + { + pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)", + tool: "find", + message: "Use the find tool for locating files by name/type instead of shell commands.", + }, + { + pattern: "^\\s*sed\\s+(-i|--in-place)", + tool: "edit", + message: "Use the edit tool for in-place file modifications instead of sed.", + }, + { + pattern: "^\\s*perl\\s+.*-[pn]?i", + tool: "edit", + message: "Use the edit tool for in-place file modifications instead of perl.", + }, + { + pattern: "^\\s*awk\\s+.*-i\\s+inplace", + tool: "edit", + message: "Use the edit tool for in-place file modifications instead of awk.", + }, + { + pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S", + tool: "write", + message: "Use the write tool to create/overwrite files instead of shell redirects.", + }, +]; + +export interface InterceptionResult { + block: boolean; + message?: string; + suggestedTool?: string; +} + +/** + * Compile rules into regex objects, silently skipping invalid patterns. + */ +function compileRules(rules: BashInterceptorRule[]): Array<{ regex: RegExp; rule: BashInterceptorRule }> { + return rules.flatMap((rule) => { + try { + return [{ regex: new RegExp(rule.pattern, rule.flags), rule }]; + } catch { + return []; // skip invalid regex + } + }); +} + +/** + * Check whether a bash command should be intercepted. + * + * @param command - The shell command to check + * @param availableTools - Tool names present in the current session + * @param rules - Override the default rule set (optional) + */ +export function checkBashInterception( + command: string, + availableTools: string[], + rules?: BashInterceptorRule[], +): InterceptionResult { + const effectiveRules = rules ?? DEFAULT_BASH_INTERCEPTOR_RULES; + const compiled = compileRules(effectiveRules); + const trimmed = command.trim(); + + for (const { regex, rule } of compiled) { + if (regex.test(trimmed) && availableTools.includes(rule.tool)) { + return { + block: true, + message: `Blocked: ${rule.message}\n\nOriginal command: ${command}`, + suggestedTool: rule.tool, + }; + } + } + + return { block: false }; +} diff --git a/packages/pi-coding-agent/src/core/tools/bash.ts b/packages/pi-coding-agent/src/core/tools/bash.ts index df4619738..82e67844d 100644 --- a/packages/pi-coding-agent/src/core/tools/bash.ts +++ b/packages/pi-coding-agent/src/core/tools/bash.ts @@ -7,6 +7,7 @@ import type { AgentTool } from "@gsd/pi-agent-core"; import { type Static, Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { getShellConfig, getShellEnv, killProcessTree, sanitizeCommand } from "../../utils/shell.js"; +import { type BashInterceptorRule, checkBashInterception } from "./bash-interceptor.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; import type { ArtifactManager } from "../artifact-manager.js"; @@ -191,6 +192,13 @@ export interface BashToolOptions { spawnHook?: BashSpawnHook; /** Session-scoped artifact storage. When provided, spills to artifact files instead of temp files. */ artifactManager?: ArtifactManager; + /** Bash interceptor configuration — blocks commands that duplicate dedicated tools */ + interceptor?: { + enabled: boolean; + rules?: BashInterceptorRule[]; + }; + /** Tool names available in the session, used by the interceptor to check if replacement tools exist */ + availableToolNames?: string[] | (() => string[]); } export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { @@ -210,6 +218,21 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo signal?: AbortSignal, onUpdate?, ) => { + // Check bash interceptor — block commands that duplicate dedicated tools + if (options?.interceptor?.enabled) { + const toolNames = + typeof options.availableToolNames === "function" + ? options.availableToolNames() + : options.availableToolNames ?? []; + const interception = checkBashInterception(command, toolNames, options.interceptor.rules); + if (interception.block) { + return { + content: [{ type: "text" as const, text: interception.message ?? "Command blocked by interceptor" }], + details: undefined, + }; + } + } + // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) const resolvedCommand = sanitizeCommand(commandPrefix ? `${commandPrefix}\n${command}` : command); const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook); diff --git a/packages/pi-coding-agent/src/core/tools/index.ts b/packages/pi-coding-agent/src/core/tools/index.ts index c3c0a2790..cc3e4f891 100644 --- a/packages/pi-coding-agent/src/core/tools/index.ts +++ b/packages/pi-coding-agent/src/core/tools/index.ts @@ -8,6 +8,12 @@ export { bashTool, createBashTool, } from "./bash.js"; +export { + type BashInterceptorRule, + checkBashInterception, + DEFAULT_BASH_INTERCEPTOR_RULES, + type InterceptionResult, +} from "./bash-interceptor.js"; export { createEditTool, type EditOperations, diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index be7adb7f8..54513d12f 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -202,6 +202,7 @@ export { export { BlobStore, isBlobRef, parseBlobRef, externalizeImageData, resolveImageData } from "./core/blob-store.js"; export { ArtifactManager } from "./core/artifact-manager.js"; export { + type BashInterceptorSettings, type CompactionSettings, type ImageSettings, type PackageSource, @@ -220,6 +221,7 @@ export { } from "./core/skills.js"; // Tools export { + type BashInterceptorRule, type BashOperations, type BashSpawnContext, type BashSpawnHook, @@ -227,6 +229,8 @@ export { type BashToolInput, type BashToolOptions, bashTool, + checkBashInterception, + DEFAULT_BASH_INTERCEPTOR_RULES, codingTools, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, From e55b6dd99497b6856c7180e6d5cd36cd29424ab8 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 15:46:08 -0600 Subject: [PATCH 2/2] fix: bash interceptor regex bugs and add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix cat rule to exclude heredoc syntax (cat <> append and digit-prefixed fd redirects (2>) using lookbehind (?\d])>(?!>) - Add compileInterceptor() — pre-compiles rules once at construction time instead of on every bash call; export CompiledInterceptor type - Update createBashTool to use pre-compiled interceptor instance - Add 33 unit tests covering all rules, edge cases, and pass-throughs --- .../src/core/tools/bash-interceptor.test.ts | 198 ++++++++++++++++++ .../src/core/tools/bash-interceptor.ts | 54 +++-- .../pi-coding-agent/src/core/tools/bash.ts | 18 +- .../pi-coding-agent/src/core/tools/index.ts | 2 + packages/pi-coding-agent/src/index.ts | 2 + 5 files changed, 249 insertions(+), 25 deletions(-) create mode 100644 packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts diff --git a/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts b/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts new file mode 100644 index 000000000..315fc4dc0 --- /dev/null +++ b/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts @@ -0,0 +1,198 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + checkBashInterception, + compileInterceptor, + DEFAULT_BASH_INTERCEPTOR_RULES, + type BashInterceptorRule, +} from "./bash-interceptor.js"; + +const ALL_TOOLS = ["read", "grep", "find", "edit", "write"]; +const NO_TOOLS: string[] = []; + +describe("checkBashInterception", () => { + describe("read rule (cat/head/tail/less/more)", () => { + it("blocks cat with a file argument", () => { + const r = checkBashInterception("cat README.md", ALL_TOOLS); + assert.equal(r.block, true); + assert.equal(r.suggestedTool, "read"); + }); + + it("blocks head and tail", () => { + assert.equal(checkBashInterception("head -n 20 file.ts", ALL_TOOLS).block, true); + assert.equal(checkBashInterception("tail -f app.log", ALL_TOOLS).block, true); + }); + + it("does NOT block cat used as heredoc (cat < { + const r = checkBashInterception("cat < file.txt", ALL_TOOLS); + assert.notEqual(r.suggestedTool, "read"); + }); + + it("does NOT block when read tool is absent", () => { + assert.equal(checkBashInterception("cat README.md", NO_TOOLS).block, false); + assert.equal(checkBashInterception("cat README.md", ["grep"]).block, false); + }); + }); + + describe("grep rule", () => { + it("blocks grep and rg", () => { + assert.equal(checkBashInterception("grep foo bar.ts", ALL_TOOLS).block, true); + assert.equal(checkBashInterception("rg -r pattern .", ALL_TOOLS).block, true); + }); + + it("blocks grep with leading whitespace", () => { + assert.equal(checkBashInterception(" grep -r foo .", ALL_TOOLS).block, true); + }); + + it("does NOT block when grep tool is absent", () => { + assert.equal(checkBashInterception("grep foo bar", ["read", "edit"]).block, false); + }); + }); + + describe("find rule", () => { + it("blocks find with -name flag", () => { + assert.equal(checkBashInterception('find . -name "*.ts"', ALL_TOOLS).block, true); + }); + + it("blocks find with -type flag", () => { + assert.equal(checkBashInterception("find /tmp -maxdepth 1 -type f", ALL_TOOLS).block, true); + }); + + it("does NOT block find without name/type flags", () => { + assert.equal(checkBashInterception("find /tmp -maxdepth 1", ALL_TOOLS).block, false); + }); + + it("does NOT block when find tool is absent", () => { + assert.equal(checkBashInterception('find . -name "*.ts"', ["read", "grep"]).block, false); + }); + }); + + describe("edit rule (sed/perl/awk)", () => { + it("blocks sed -i", () => { + assert.equal(checkBashInterception("sed -i 's/foo/bar/' file.ts", ALL_TOOLS).block, true); + assert.equal(checkBashInterception("sed --in-place 's/x/y/' f", ALL_TOOLS).block, true); + }); + + it("does NOT block sed without -i (read-only)", () => { + assert.equal(checkBashInterception("sed 's/foo/bar/' file.ts", ALL_TOOLS).block, false); + }); + + it("blocks perl -pi and perl -p -i", () => { + assert.equal(checkBashInterception("perl -pi -e 's/foo/bar/' file", ALL_TOOLS).block, true); + assert.equal(checkBashInterception("perl -p -i -e 's/x/y/' f", ALL_TOOLS).block, true); + }); + + it("blocks awk -i inplace", () => { + assert.equal(checkBashInterception("awk -i inplace '{print}' file", ALL_TOOLS).block, true); + }); + + it("does NOT block when edit tool is absent", () => { + assert.equal(checkBashInterception("sed -i 's/a/b/' f", ["read", "grep"]).block, false); + }); + }); + + describe("write rule (echo/printf/heredoc redirect)", () => { + it("blocks echo with > redirect", () => { + assert.equal(checkBashInterception("echo hello > file.txt", ALL_TOOLS).block, true); + }); + + it("blocks printf with > redirect", () => { + assert.equal(checkBashInterception('printf "%s" content > out.txt', ALL_TOOLS).block, true); + }); + + it("does NOT block echo without redirect", () => { + assert.equal(checkBashInterception("echo hello", ALL_TOOLS).block, false); + }); + + it("does NOT block >> append redirect (write tool does not support appending)", () => { + assert.equal(checkBashInterception("echo hello >> file.txt", ALL_TOOLS).block, false); + }); + + it("does NOT block stderr redirect (2>)", () => { + assert.equal(checkBashInterception("echo test 2> /dev/null", ALL_TOOLS).block, false); + }); + + it("does NOT block pipe (echo foo | grep bar)", () => { + assert.equal(checkBashInterception("echo foo | grep bar", ALL_TOOLS).block, false); + }); + + it("does NOT block when write tool is absent", () => { + assert.equal(checkBashInterception("echo hello > file.txt", ["read", "grep"]).block, false); + }); + }); + + describe("pass-through commands", () => { + it("passes npm install", () => { + assert.equal(checkBashInterception("npm install", ALL_TOOLS).block, false); + }); + + it("passes ls > output.txt (not an echo/printf/cat)", () => { + assert.equal(checkBashInterception("ls > output.txt", ALL_TOOLS).block, false); + }); + + it("passes tee file.txt", () => { + assert.equal(checkBashInterception("tee file.txt", ALL_TOOLS).block, false); + }); + + it("passes git log", () => { + assert.equal(checkBashInterception("git log --oneline", ALL_TOOLS).block, false); + }); + }); + + describe("block message content", () => { + it("includes the original command in the block message", () => { + const r = checkBashInterception("cat README.md", ALL_TOOLS); + assert.ok(r.message?.includes("cat README.md"), "message should contain original command"); + }); + + it("returns block:false with no message when not blocked", () => { + const r = checkBashInterception("npm install", ALL_TOOLS); + assert.equal(r.block, false); + assert.equal(r.message, undefined); + }); + }); +}); + +describe("compileInterceptor", () => { + it("produces same results as checkBashInterception", () => { + const interceptor = compileInterceptor(DEFAULT_BASH_INTERCEPTOR_RULES); + const cases: [string, string[], boolean][] = [ + ["cat README.md", ALL_TOOLS, true], + ["npm install", ALL_TOOLS, false], + ["grep foo bar", ALL_TOOLS, true], + ["echo hello >> file", ALL_TOOLS, false], + ["echo test 2> /dev/null", ALL_TOOLS, false], + ]; + for (const [cmd, tools, expected] of cases) { + assert.equal( + interceptor.check(cmd, tools).block, + expected, + `pre-compiled: "${cmd}" expected block=${expected}`, + ); + } + }); + + it("silently skips rules with invalid regex patterns", () => { + const rules: BashInterceptorRule[] = [ + { pattern: "[invalid(", tool: "read", message: "broken" }, + { pattern: "^\\s*cat\\s+", tool: "read", message: "valid" }, + ]; + const interceptor = compileInterceptor(rules); + assert.equal(interceptor.check("cat file.txt", ["read"]).block, true); + }); + + it("returns block:false when available tools list is empty", () => { + const interceptor = compileInterceptor(DEFAULT_BASH_INTERCEPTOR_RULES); + assert.equal(interceptor.check("cat README.md", []).block, false); + }); + + it("allows custom rule override", () => { + const customRules: BashInterceptorRule[] = [ + { pattern: "^\\s*curl\\s+", tool: "fetch", message: "Use fetch tool instead." }, + ]; + const interceptor = compileInterceptor(customRules); + assert.equal(interceptor.check("curl https://example.com", ["fetch"]).block, true); + // default rules not active + assert.equal(interceptor.check("cat file.txt", ["read"]).block, false); + }); +}); diff --git a/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts b/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts index acf719238..309c6a10f 100644 --- a/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts +++ b/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts @@ -14,7 +14,8 @@ export interface BashInterceptorRule { export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [ { - pattern: "^\\s*(cat|head|tail|less|more)\\s+", + // cat/head/tail for file viewing — excludes heredoc syntax (cat <<) + pattern: "^\\s*(cat(?!\\s*<<)|head|tail|less|more)\\s+", tool: "read", message: "Use the read tool to view file contents instead of shell commands.", }, @@ -44,7 +45,9 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [ message: "Use the edit tool for in-place file modifications instead of awk.", }, { - pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S", + // echo/printf/heredoc writing to a file via > (not >> append, not 2> stderr redirect) + // Matches a single > not preceded by |, >, or a digit (fd redirect like 2>) + pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*(?\\d])>(?!>)\\s*\\S", tool: "write", message: "Use the write tool to create/overwrite files instead of shell redirects.", }, @@ -56,22 +59,48 @@ export interface InterceptionResult { suggestedTool?: string; } +export interface CompiledInterceptor { + check: (command: string, availableTools: string[]) => InterceptionResult; +} + /** - * Compile rules into regex objects, silently skipping invalid patterns. + * Compile rules into an interceptor with pre-built regex objects. + * Silently skips rules with invalid patterns. + * + * Pre-compiling at construction time avoids repeated `new RegExp()` calls + * on every bash command invocation. */ -function compileRules(rules: BashInterceptorRule[]): Array<{ regex: RegExp; rule: BashInterceptorRule }> { - return rules.flatMap((rule) => { +export function compileInterceptor(rules: BashInterceptorRule[]): CompiledInterceptor { + const compiled = rules.flatMap((rule) => { try { return [{ regex: new RegExp(rule.pattern, rule.flags), rule }]; } catch { return []; // skip invalid regex } }); + + return { + check(command: string, availableTools: string[]): InterceptionResult { + const trimmed = command.trim(); + for (const { regex, rule } of compiled) { + if (regex.test(trimmed) && availableTools.includes(rule.tool)) { + return { + block: true, + message: `Blocked: ${rule.message}\n\nOriginal command: ${command}`, + suggestedTool: rule.tool, + }; + } + } + return { block: false }; + }, + }; } /** * Check whether a bash command should be intercepted. * + * Compiles rules on each call — prefer `compileInterceptor()` for repeated use. + * * @param command - The shell command to check * @param availableTools - Tool names present in the current session * @param rules - Override the default rule set (optional) @@ -82,18 +111,5 @@ export function checkBashInterception( rules?: BashInterceptorRule[], ): InterceptionResult { const effectiveRules = rules ?? DEFAULT_BASH_INTERCEPTOR_RULES; - const compiled = compileRules(effectiveRules); - const trimmed = command.trim(); - - for (const { regex, rule } of compiled) { - if (regex.test(trimmed) && availableTools.includes(rule.tool)) { - return { - block: true, - message: `Blocked: ${rule.message}\n\nOriginal command: ${command}`, - suggestedTool: rule.tool, - }; - } - } - - return { block: false }; + return compileInterceptor(effectiveRules).check(command, availableTools); } diff --git a/packages/pi-coding-agent/src/core/tools/bash.ts b/packages/pi-coding-agent/src/core/tools/bash.ts index 82e67844d..6d84199c3 100644 --- a/packages/pi-coding-agent/src/core/tools/bash.ts +++ b/packages/pi-coding-agent/src/core/tools/bash.ts @@ -7,7 +7,7 @@ import type { AgentTool } from "@gsd/pi-agent-core"; import { type Static, Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { getShellConfig, getShellEnv, killProcessTree, sanitizeCommand } from "../../utils/shell.js"; -import { type BashInterceptorRule, checkBashInterception } from "./bash-interceptor.js"; +import { type BashInterceptorRule, compileInterceptor, DEFAULT_BASH_INTERCEPTOR_RULES } from "./bash-interceptor.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; import type { ArtifactManager } from "../artifact-manager.js"; @@ -207,6 +207,12 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo const spawnHook = options?.spawnHook; const artifactManager = options?.artifactManager; + // Pre-compile interceptor rules once at construction time + const interceptorInstance = + options?.interceptor?.enabled + ? compileInterceptor(options.interceptor.rules ?? DEFAULT_BASH_INTERCEPTOR_RULES) + : null; + return { name: "bash", label: "bash", @@ -219,12 +225,12 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo onUpdate?, ) => { // Check bash interceptor — block commands that duplicate dedicated tools - if (options?.interceptor?.enabled) { + if (interceptorInstance) { const toolNames = - typeof options.availableToolNames === "function" - ? options.availableToolNames() - : options.availableToolNames ?? []; - const interception = checkBashInterception(command, toolNames, options.interceptor.rules); + typeof options!.availableToolNames === "function" + ? options!.availableToolNames() + : options!.availableToolNames ?? []; + const interception = interceptorInstance.check(command, toolNames); if (interception.block) { return { content: [{ type: "text" as const, text: interception.message ?? "Command blocked by interceptor" }], diff --git a/packages/pi-coding-agent/src/core/tools/index.ts b/packages/pi-coding-agent/src/core/tools/index.ts index cc3e4f891..c95fe4b78 100644 --- a/packages/pi-coding-agent/src/core/tools/index.ts +++ b/packages/pi-coding-agent/src/core/tools/index.ts @@ -11,6 +11,8 @@ export { export { type BashInterceptorRule, checkBashInterception, + type CompiledInterceptor, + compileInterceptor, DEFAULT_BASH_INTERCEPTOR_RULES, type InterceptionResult, } from "./bash-interceptor.js"; diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index 54513d12f..4d75e487e 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -230,6 +230,8 @@ export { type BashToolOptions, bashTool, checkBashInterception, + type CompiledInterceptor, + compileInterceptor, DEFAULT_BASH_INTERCEPTOR_RULES, codingTools, DEFAULT_MAX_BYTES,