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:
parent
a35e844ba0
commit
d0f84d9a38
6 changed files with 155 additions and 1 deletions
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
99
packages/pi-coding-agent/src/core/tools/bash-interceptor.ts
Normal file
99
packages/pi-coding-agent/src/core/tools/bash-interceptor.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue