Merge pull request #258 from gsd-build/feat/bash-interceptor

feat: bash interceptor for tool discipline
This commit is contained in:
TÂCHES 2026-03-13 15:47:53 -06:00 committed by GitHub
commit bdb6bcde35
7 changed files with 379 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,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 <<EOF)", () => {
const r = checkBashInterception("cat <<EOF > 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);
});
});

View file

@ -0,0 +1,115 @@
/**
* 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[] = [
{
// 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.",
},
{
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.",
},
{
// 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.",
},
];
export interface InterceptionResult {
block: boolean;
message?: string;
suggestedTool?: string;
}
export interface CompiledInterceptor {
check: (command: string, availableTools: string[]) => InterceptionResult;
}
/**
* 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.
*/
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)
*/
export function checkBashInterception(
command: string,
availableTools: string[],
rules?: BashInterceptorRule[],
): InterceptionResult {
const effectiveRules = rules ?? DEFAULT_BASH_INTERCEPTOR_RULES;
return compileInterceptor(effectiveRules).check(command, availableTools);
}

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, 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";
@ -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> {
@ -199,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",
@ -210,6 +224,21 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
signal?: AbortSignal,
onUpdate?,
) => {
// Check bash interceptor — block commands that duplicate dedicated tools
if (interceptorInstance) {
const toolNames =
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" }],
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,14 @@ export {
bashTool,
createBashTool,
} from "./bash.js";
export {
type BashInterceptorRule,
checkBashInterception,
type CompiledInterceptor,
compileInterceptor,
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,10 @@ export {
type BashToolInput,
type BashToolOptions,
bashTool,
checkBashInterception,
type CompiledInterceptor,
compileInterceptor,
DEFAULT_BASH_INTERCEPTOR_RULES,
codingTools,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,