Why this matters
BashTool.tsx is one of the clearest places to watch the shared tool contract
turn into a real boundary with the operating system. The model does not get a
magic “run whatever you want” button. It has to satisfy a schema, pass
permission checks, pick a sandbox posture, and then live with UI rules about
progress, backgrounding, and output formatting.
Big picture first
Read the Bash tool in this order:
toolName.tsgives the stable name:BASH_TOOL_NAME.BashTool.tsxdefines timing constants such asPROGRESS_THRESHOLD_MSandASSISTANT_BLOCKING_BUDGET_MS.fullInputSchemadescribes the richest request shape the runtime can accept.isSearchOrReadBashCommand()classifies read/search/list commands so the UI can present them differently.buildTool({ ... })plugs all of that into the sharedTool.tscontract.bashPermissions.tscarries the heavy permission logic.
Start with identity and timing
// Here to break circular dependency from prompt.ts
export const BASH_TOOL_NAME = 'Bash'
const PROGRESS_THRESHOLD_MS = 2000; // Show progress after 2 seconds
const ASSISTANT_BLOCKING_BUDGET_MS = 15_000;
These two constants do different jobs. PROGRESS_THRESHOLD_MS is about when
the interface should stop pretending a command is “instant” and start showing
progress. ASSISTANT_BLOCKING_BUDGET_MS is about agent coordination: in
assistant mode, a long blocking command gets moved to the background after 15
seconds so the main agent can keep responding.
The schema layers
Two related schema layers
`fullInputSchema` is the richest internal request shape. `inputSchema` is the model-facing version, and it always omits `_simulatedSedEdit`.
The runtime understands much more than a bare command string, but not every
field is exposed to the model. The internal shape includes human-readable
description metadata and an internal _simulatedSedEdit field. The
model-facing schema is derived from that fuller shape and hides
_simulatedSedEdit.
Code walk
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string().describe('The command to execute'),
timeout: semanticNumber(z.number().optional()).describe(`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`),
description: z.string().optional().describe(`Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.
For simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):
- ls → "List files in current directory"
- git status → "Show working tree status"
- npm install → "Install package dependencies"`),
run_in_background: semanticBoolean(z.boolean().optional()).describe(`Set to true to run this command in the background. Use Read to read the output later.`),
dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()).describe('Set this to true to dangerously override sandbox mode and run commands without sandboxing.'),
_simulatedSedEdit: z.object({
filePath: z.string(),
newContent: z.string()
}).optional().describe('Internal: pre-computed sed edit result from preview')
}));
This richer internal shape is used so the runtime can carry both normal
user-visible fields like description and special internal state when needed.
dangerouslyDisableSandbox is part of the real request contract, which shows
that sandbox choice is first-class policy, not an afterthought.
const inputSchema = lazySchema(() => isBackgroundTasksDisabled ? fullInputSchema().omit({
run_in_background: true,
_simulatedSedEdit: true
}) : fullInputSchema().omit({
_simulatedSedEdit: true
}));
That omission step is the key distinction. fullInputSchema is the richest
internal request shape. inputSchema is the model-facing version, and it
always omits _simulatedSedEdit. When background tasks are disabled, the
model-facing schema also omits run_in_background.
How Bash decides whether a command “feels like reading”
const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', 'ag', 'ack', 'locate', 'which', 'whereis']);
export function isSearchOrReadBashCommand(command: string): {
isSearch: boolean;
isRead: boolean;
isList: boolean;
} {
let partsWithOperators: string[];
try {
partsWithOperators = splitCommandWithOperators(command);
} catch {
return {
isSearch: false,
isRead: false,
isList: false
};
}
Claude Code does not treat every Bash command the same in the interface. A
search like rg todo src is different from rm -rf build. This helper tries
to recognize search, read, and list commands so the UI can collapse or label
them more gently when that is safe to do.
The full file also defines BASH_READ_COMMANDS, BASH_LIST_COMMANDS, and
BASH_SEMANTIC_NEUTRAL_COMMANDS, which is a hint that even “just classify the
command” becomes subtle once pipes, &&, redirects, and neutral commands like
echo show up.
Where the shared contract becomes a real tool
export const BashTool = buildTool({
name: BASH_TOOL_NAME,
searchHint: 'execute shell commands',
maxResultSizeChars: 30_000,
strict: true,
async description({ description }) {
return description || 'Run shell command';
},
async checkPermissions(input, context): Promise<PermissionResult> {
return bashToolHasPermission(input, context);
},
isSearchOrReadCommand(input) {
const parsed = inputSchema().safeParse(input);
if (!parsed.success) return {
isSearch: false,
isRead: false,
isList: false
};
return isSearchOrReadBashCommand(parsed.data.command);
},
This is the important handoff point. buildTool({ ... }) is the shared factory
from Tool.ts, but the Bash tool fills in Bash-specific policy:
- how to describe the command to the user
- how to ask
bashPermissions.tswhether the command is allowed - how to classify the command for the UI
- which schemas describe valid input and output
The execution hook is equally concrete:
async call(input: BashToolInput, toolUseContext, _canUseTool?: CanUseToolFn, parentMessage?: AssistantMessage, onProgress?: ToolCallProgress<BashProgress>) {
// Handle simulated sed edit - apply directly instead of running sed
// This ensures what the user previewed is exactly what gets written
if (input._simulatedSedEdit) {
return applySedEdit(input._simulatedSedEdit, toolUseContext, parentMessage);
}
That branch is one of the easiest places to see that this file is not a toy. Some “Bash” actions become direct file writes because the permission system has already produced a reviewed sed preview.
Real execution behavior
if (feature('KAIROS') && getKairosActive() && isMainThread && !isBackgroundTasksDisabled && run_in_background !== true) {
setTimeout(() => {
if (shellCommand.status === 'running' && backgroundShellId === undefined) {
assistantAutoBackgrounded = true;
startBackgrounding('tengu_bash_command_assistant_auto_backgrounded');
}
}, ASSISTANT_BLOCKING_BUDGET_MS).unref();
}
const initialResult = await Promise.race([resultPromise, new Promise<null>(resolve => {
const t = setTimeout((r: (v: null) => void) => r(null), PROGRESS_THRESHOLD_MS, resolve);
t.unref();
})]);
These two snippets show why the earlier constants matter. After 2 seconds, the tool starts acting like a “running job” with progress UI. After 15 seconds in assistant mode, the main agent stops blocking and backgrounds the work. Same command, different user-experience decisions.
bashPermissions.ts stays central throughout this flow. The checkPermissions
hook delegates to bashToolHasPermission(input, context), so schema,
classification, and execution all sit downstream of a real policy layer.
Fun facts
tools/BashTool/toolName.tsexists mostly to break a circular dependency withprompt.ts, so even the tool name lives in its own tiny file._simulatedSedEditis intentionally omitted from the model-facing schema; otherwise the model could pair a harmless-looking command with an arbitrary write.BASH_LIST_COMMANDSis kept separate from read commands so the UI can say “Listed N directories” instead of the misleading “Read N files.”
Takeaways
- BashTool uses the shared contract from Tool.ts, but it fills that contract with much richer policy and execution logic.
- The real schema already exposes key ideas: backgrounding, sandbox overrides, and an internal sed-preview path.
- Progress timing, assistant blocking budgets, and read/search classification are part of the tool design, not just UI decoration.