Repository Public
1/** `shell`: run a command in the Seatbelt sandbox (see sandbox.ts). Never runs unsandboxed. */2import { z } from 'zod';3import { runSandboxed, SandboxUnavailableError, SHELL_OUTPUT_CAP_BYTES } from './sandbox.js';4import { opt, toStrictJsonSchema } from './schema.js';5import { fail, type Tool, type ToolRunResult } from './types.js';67export const SHELL_DEFAULT_TIMEOUT_SEC = 120;8export const SHELL_MAX_TIMEOUT_SEC = 600;910const ShellInput = z.object({11 command: z.string().min(1).max(32_000).describe('Command line for zsh, run from the workspace root. May contain pipes, &&, here-documents and several lines.'),12 timeoutSec: opt(z.number().int().min(1).max(SHELL_MAX_TIMEOUT_SEC)).describe(`Timeout in seconds (1–${SHELL_MAX_TIMEOUT_SEC}). null = ${SHELL_DEFAULT_TIMEOUT_SEC}. On timeout the whole process tree is killed.`),13});14type ShellInput = z.infer<typeof ShellInput>;1516export const shellTool: Tool<ShellInput> = {17 def: {18 name: 'shell',19 description:20 'Run a shell command (zsh) from the workspace root inside a sandbox and return its exit code and combined stdout+stderr. Use it to build, run tests, inspect things, run scripts and use git read commands. ' +21 'Sandbox facts: files can be written ONLY inside the workspace and in $TMPDIR (use $TMPDIR, never /tmp); .git/hooks, .git/config, .aos/, root AGENTS.md/CLAUDE.md and shell startup files are read-only; ' +22 'credentials (~/.ssh, ~/.aws, ~/.config, keychains, env files outside the workspace) are unreadable; API keys are not in the environment. ' +23 'Network: when the mission network is "none" every connection fails, including localhost — do not retry, and do not start servers; when it is "open", outbound Internet works but localhost and listening sockets do not. ' +24 'Each call is a fresh shell: no state (cd, variables, background jobs) survives between calls, and background processes are killed when the command returns. ' +25 `Commands are non-interactive (no TTY, no prompts). Timeout ${SHELL_DEFAULT_TIMEOUT_SEC} s by default, ${SHELL_MAX_TIMEOUT_SEC} s maximum; output above 1 MiB kills the command, and long output is truncated with the full text stored as an artifact — prefer quiet flags and filters (| tail, | grep). ` +26 'Use the dedicated tools instead for reading, searching and editing files, and for git commit / git push.',27 inputSchema: toStrictJsonSchema(ShellInput),28 },29 input: ShellInput,30 // Confined to the workspace when offline (re-running after a crash is acceptable); with network access a31 // command may have changed the outside world, so a crashed step must be reconciled by the owner.32 effect: (_input, ctx) => (ctx?.network === 'open' ? 'non_idempotent' : 'idempotent_write'),33 risk: (_input, ctx) => (ctx?.network === 'open' ? 'network' : 'workspace_write'),34 parallelSafe: false,35 describeForApproval(input) {36 return { title: `Run: ${input.command.split('\n')[0]?.slice(0, 120) ?? ''}`, detail: `${input.command}\n\n(timeout ${input.timeoutSec ?? SHELL_DEFAULT_TIMEOUT_SEC} s, sandboxed, cwd = workspace)` };37 },38 async run(input, ctx): Promise<ToolRunResult> {39 const timeoutSec = Math.min(input.timeoutSec ?? SHELL_DEFAULT_TIMEOUT_SEC, SHELL_MAX_TIMEOUT_SEC);40 let r;41 try {42 r = await runSandboxed({43 command: input.command,44 workspaceAbs: ctx.workspaceAbs,45 network: ctx.network,46 dataDir: ctx.dataDir,47 runId: ctx.runId,48 timeoutMs: timeoutSec * 1000,49 signal: ctx.signal,50 sandboxExecPath: ctx.sandbox?.sandboxExecPath,51 extraDenyRead: ctx.sandbox?.extraDenyRead,52 });53 } catch (e) {54 if (e instanceof SandboxUnavailableError) return fail(`Shell unavailable — the command was NOT run: ${e.message}`);55 throw e;56 }57 const status: string[] = [];58 if (r.timedOut) status.push(`timed out after ${timeoutSec} s; the process tree was killed`);59 if (r.outputTruncated) status.push(`output exceeded ${SHELL_OUTPUT_CAP_BYTES} bytes; the command was killed and the output cut`);60 if (r.aborted) status.push('cancelled');61 status.push(r.exitCode !== null ? `exit code ${r.exitCode}` : `killed by ${r.signal ?? 'signal'}`);62 const body = r.output.length > 0 ? r.output.replace(/\n+$/, '') : '(no output)';63 return { ok: r.exitCode === 0 && !r.timedOut && !r.outputTruncated && !r.aborted, output: `[${status.join('; ')}]\n${body}` };64 },65};66