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) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-13 14:50:10 -06:00
parent a35e844ba0
commit d0f84d9a38
6 changed files with 155 additions and 1 deletions

View file

@ -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]));

View file

@ -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;
}
}

View file

@ -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 };
}

View file

@ -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<typeof bashSchema> {
@ -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);

View file

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

View file

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