SPB Git

Repository Public

shell.ts 4.3 KB 66 lines typescript

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
Files main
Menu

Theme