From 2058cdfd095b4a18844024aa07cf76a2bb7eda40 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Wed, 25 Mar 2026 14:02:53 +0100 Subject: [PATCH] feat: add Shuru sandbox mode for agent shell execution (#215) * feat: add Shuru sandbox mode for agent shell execution Adds a user-selectable sandbox mode that runs all agent-driven bash commands inside a Shuru microVM. Includes persistent user/project settings, CLI flags (--sandbox / --no-sandbox), a TUI picker via /sandbox, runtime toggling for live agents, delegation propagation, model prompt updates, and Phase 1 guardrails that block shell commands which would silently lose workspace mutations inside the guest overlay. * fix: use word boundary in git guardrail to catch compound commands The git mutation guard used ^ (start-of-string anchor), so compound expressions like `echo foo && git commit` bypassed it. Switch to \b to match the pattern used by the other guardrails. * fix: remove branch from git allowlist and stabilize bash tests - Remove `branch` from the read-only git allowlist since git branch can create/delete/rename branches, which are mutating operations. - Export wrapCommandForShuru and getSandboxMutationBlockReason for direct unit testing without child_process mocking. - Rewrite bash.test.ts to test pure functions directly, eliminating flaky vi.doMock/resetModules ESM interactions. --- src/agent/agent.ts | 43 ++++++-- src/agent/delegations.test.ts | 94 ++++++++++++++++ src/agent/delegations.ts | 4 + src/agent/sandbox.test.ts | 78 +++++++++++++ src/grok/tools.test.ts | 8 ++ src/grok/tools.ts | 3 +- src/index.ts | 65 ++++++++--- src/telegram/headless-bridge.ts | 7 +- src/tools/bash.test.ts | 128 ++++++++++++++++++++++ src/tools/bash.ts | 122 ++++++++++++++++++++- src/ui/app.tsx | 187 +++++++++++++++++++++++++++++++- src/utils/settings.ts | 17 +++ 12 files changed, 721 insertions(+), 35 deletions(-) create mode 100644 src/agent/delegations.test.ts create mode 100644 src/agent/sandbox.test.ts create mode 100644 src/tools/bash.test.ts diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 6381131..8161bc0 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -32,7 +32,7 @@ import type { WorkspaceInfo, } from "../types/index"; import { loadCustomInstructions } from "../utils/instructions"; -import { type CustomSubagentConfig, loadMcpServers, loadValidSubAgents } from "../utils/settings"; +import { type CustomSubagentConfig, loadMcpServers, loadValidSubAgents, type SandboxMode } from "../utils/settings"; import { discoverSkills, formatSkillsForPrompt } from "../utils/skills"; import { type CompactionSettings, @@ -55,6 +55,7 @@ const VISION_MODEL = "grok-4-1-fast-reasoning"; interface AgentOptions { persistSession?: boolean; session?: string; + sandboxMode?: SandboxMode; } type ProcessMessageFinishReason = "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other"; @@ -243,6 +244,7 @@ function formatCustomSubagentsPromptSection(subagents: CustomSubagentConfig[]): function buildSystemPrompt( cwd: string, mode: AgentMode, + sandboxMode: SandboxMode, planContext?: string | null, subagents?: CustomSubagentConfig[], ): string { @@ -254,12 +256,13 @@ function buildSystemPrompt( const skillsText = formatSkillsForPrompt(discoverSkills(cwd)); const skillsSection = skillsText ? `\n\n${skillsText}\n` : ""; const subagentsSection = formatCustomSubagentsPromptSection(subagents ?? loadValidSubAgents()); + const sandboxSection = formatSandboxPromptSection(sandboxMode); const planSection = planContext ? `\n\nAPPROVED PLAN:\nThe following plan has been approved by the user. Execute it now.\n${planContext}\n` : ""; - return `${MODE_PROMPTS[mode]}${customSection}${skillsSection}${subagentsSection}${planSection} + return `${MODE_PROMPTS[mode]}${sandboxSection}${customSection}${skillsSection}${subagentsSection}${planSection} Current working directory: ${cwd}`; } @@ -268,6 +271,7 @@ function buildSubagentPrompt( request: TaskRequest, cwd: string, custom: CustomSubagentConfig | null, + sandboxMode: SandboxMode, subagents?: CustomSubagentConfig[], ): string { const isExplore = request.agent === "explore"; @@ -308,7 +312,21 @@ function buildSubagentPrompt( "", `Delegated task: ${request.description}`, "", - buildSystemPrompt(cwd, mode, undefined, subagents), + buildSystemPrompt(cwd, mode, sandboxMode, undefined, subagents), + ].join("\n"); +} + +function formatSandboxPromptSection(sandboxMode: SandboxMode): string { + if (sandboxMode === "off") return ""; + return [ + "", + "SANDBOX MODE:", + "- Bash commands run inside a Shuru sandbox.", + "- Network is disabled by default unless the current workspace config allows it.", + "- The current workspace is mounted inside the sandbox at `/workspace`.", + "- Shell-side workspace file changes do not persist back to the host in this version.", + "- Use `read_file`, `edit_file`, and `write_file` for durable source edits.", + "- If a task needs a host-persistent shell mutation, explain that sandbox mode blocks that workflow and ask whether to disable sandbox mode.", ].join("\n"); } @@ -360,7 +378,7 @@ export class Agent { if (apiKey) { this.setApiKey(apiKey, baseURL); } - this.bash = new BashTool(); + this.bash = new BashTool(process.cwd(), { sandboxMode: options.sandboxMode ?? "off" }); this.delegations = new DelegationManager(() => this.bash.getCwd()); this.modelId = normalizeModelId(model || DEFAULT_MODEL); this.schedules = new ScheduleManager( @@ -399,6 +417,14 @@ export class Agent { return this.mode; } + getSandboxMode(): SandboxMode { + return this.bash.getSandboxMode(); + } + + setSandboxMode(mode: SandboxMode): void { + this.bash.setSandboxMode(mode); + } + setMode(mode: AgentMode): void { if (mode !== this.mode) { this.mode = mode; @@ -454,7 +480,7 @@ export class Agent { ratioUsed: number; ratioRemaining: number; } { - const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext); + const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.bash.getSandboxMode(), this.planContext); const usedTokens = Math.min(contextWindow, estimateConversationTokens(system, this.messages, inFlightText)); const remainingTokens = Math.max(0, contextWindow - usedTokens); @@ -611,7 +637,7 @@ export class Agent { } const childMode: AgentMode = isExplore ? "ask" : "agent"; - const childBash = new BashTool(this.bash.getCwd()); + const childBash = new BashTool(this.bash.getCwd(), { sandboxMode: this.bash.getSandboxMode() }); const childBaseTools = createTools(childBash, provider, childMode); const initialDetail = isExplore ? "Scanning the codebase" : "Planning delegated work"; let assistantText = ""; @@ -625,7 +651,7 @@ export class Agent { ? { ...resolveModelRuntime(provider, childModelId), model: provider.responses(childModelId) } : resolveModelRuntime(provider, childModelId); const childSystem = applyModelConstraints( - buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, subagents), + buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, childBash.getSandboxMode(), subagents), childRuntime.modelId, ); @@ -743,6 +769,7 @@ export class Agent { return await this.delegations.start(request, { model: this.modelId, + sandboxMode: this.bash.getSandboxMode(), maxToolRounds: this.maxToolRounds, maxTokens: this.maxTokens, }); @@ -871,7 +898,7 @@ export class Agent { const provider = this.requireProvider(); const subagents = loadValidSubAgents(); const system = applyModelConstraints( - buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext, subagents), + buildSystemPrompt(this.bash.getCwd(), this.mode, this.bash.getSandboxMode(), this.planContext, subagents), this.modelId, ); const runtime = resolveModelRuntime(provider, this.modelId); diff --git a/src/agent/delegations.test.ts b/src/agent/delegations.test.ts new file mode 100644 index 0000000..f1c54a3 --- /dev/null +++ b/src/agent/delegations.test.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function importDelegationsModule(options: { home?: string; spawnMock?: ReturnType } = {}) { + vi.resetModules(); + vi.doUnmock("os"); + vi.doUnmock("child_process"); + + if (options.home) { + vi.doMock("os", async () => { + const actual = await vi.importActual("os"); + return { + ...actual, + homedir: () => options.home!, + }; + }); + } + + if (options.spawnMock) { + vi.doMock("child_process", async () => { + const actual = await vi.importActual("child_process"); + return { + ...actual, + spawn: options.spawnMock, + }; + }); + } + + return import("./delegations"); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("os"); + vi.doUnmock("child_process"); +}); + +describe("DelegationManager sandbox propagation", () => { + it("persists sandbox mode in background delegation records", async () => { + const home = makeTempDir("grok-delegation-home-"); + const cwd = makeTempDir("grok-delegation-cwd-"); + const spawnMock = vi.fn(() => ({ + pid: 2468, + unref: vi.fn(), + })); + const mod = await importDelegationsModule({ home, spawnMock }); + const manager = new mod.DelegationManager(() => cwd); + + const result = await manager.start( + { + agent: "explore", + description: "Inspect the repo", + prompt: "Find the execution path.", + }, + { + model: "grok-test-model", + sandboxMode: "shuru", + maxToolRounds: 25, + maxTokens: 2048, + }, + ); + + expect(result.success).toBe(true); + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(["--directory", cwd]), + expect.objectContaining({ cwd }), + ); + + const cliArgs = (spawnMock.mock.calls[0] as unknown[])?.[1] as string[]; + const jobPath = cliArgs[cliArgs.indexOf("--background-task-file") + 1] as string; + const record = JSON.parse(fs.readFileSync(jobPath, "utf8")) as { + sandboxMode: string; + pid: number; + }; + + expect(record.sandboxMode).toBe("shuru"); + expect(record.pid).toBe(2468); + }); +}); diff --git a/src/agent/delegations.ts b/src/agent/delegations.ts index de57404..7aa3b35 100644 --- a/src/agent/delegations.ts +++ b/src/agent/delegations.ts @@ -4,6 +4,7 @@ import { promises as fs } from "fs"; import os from "os"; import path from "path"; import type { DelegationRun, DelegationStatus, TaskRequest, ToolResult } from "../types/index"; +import type { SandboxMode } from "../utils/settings"; const ID_ADJECTIVES = ["brisk", "calm", "clever", "eager", "gentle", "keen", "lively", "nimble", "quiet", "steady"]; @@ -18,6 +19,7 @@ export interface StoredDelegation { prompt: string; cwd: string; model: string; + sandboxMode: SandboxMode; maxToolRounds: number; maxTokens: number; status: DelegationStatus; @@ -38,6 +40,7 @@ export interface DelegationNotification { interface StartDelegationOptions { model: string; + sandboxMode: SandboxMode; maxToolRounds: number; maxTokens: number; } @@ -74,6 +77,7 @@ export class DelegationManager { prompt: request.prompt, cwd, model: options.model, + sandboxMode: options.sandboxMode, maxToolRounds: options.maxToolRounds, maxTokens: options.maxTokens, status: "running", diff --git a/src/agent/sandbox.test.ts b/src/agent/sandbox.test.ts new file mode 100644 index 0000000..5f41e9c --- /dev/null +++ b/src/agent/sandbox.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function importAgentModule() { + vi.resetModules(); + vi.doMock("../storage/index", () => ({ + appendCompaction: vi.fn(), + appendMessages: vi.fn(() => []), + appendSystemMessage: vi.fn(() => 0), + buildChatEntries: vi.fn(() => []), + getNextMessageSequence: vi.fn(() => 0), + getSessionTotalTokens: vi.fn(() => 0), + loadTranscript: vi.fn(() => []), + loadTranscriptState: vi.fn(() => ({ messages: [], seqs: [] })), + recordUsageEvent: vi.fn(), + SessionStore: class { + getWorkspace() { + return null; + } + openSession() { + return null; + } + createSession() { + return null; + } + setModel() {} + getRequiredSession() { + return null; + } + setMode() {} + touchSession() {} + }, + })); + + return import("./agent"); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("../storage/index"); +}); + +describe("Agent sandbox mode", () => { + it("can switch sandbox mode at runtime", async () => { + const { Agent } = await importAgentModule(); + const agent = new Agent(undefined, undefined, undefined, undefined, { + persistSession: false, + sandboxMode: "off", + }); + + expect(agent.getSandboxMode()).toBe("off"); + + agent.setSandboxMode("shuru"); + + expect(agent.getSandboxMode()).toBe("shuru"); + }); + + it("passes sandbox mode into background delegations", async () => { + const { Agent } = await importAgentModule(); + const agent = new Agent(undefined, undefined, undefined, undefined, { + persistSession: false, + sandboxMode: "shuru", + }); + const startMock = vi.fn(async () => ({ success: true, output: "ok" })); + (agent as unknown as { delegations: { start: typeof startMock } }).delegations.start = startMock; + + await (agent as unknown as { runDelegation: (request: unknown) => Promise }).runDelegation({ + agent: "explore", + description: "Inspect", + prompt: "Look around", + }); + + expect(startMock).toHaveBeenCalledWith( + expect.objectContaining({ agent: "explore" }), + expect.objectContaining({ sandboxMode: "shuru" }), + ); + }); +}); diff --git a/src/grok/tools.test.ts b/src/grok/tools.test.ts index 2a89f0e..3fe330d 100644 --- a/src/grok/tools.test.ts +++ b/src/grok/tools.test.ts @@ -36,6 +36,14 @@ function createScheduleToolSet(overrides?: { } describe("schedule daemon tools", () => { + it("describes bash sandbox constraints when shuru mode is enabled", () => { + const tools = createTools(new BashTool("/tmp", { sandboxMode: "shuru" }), {} as never, "agent"); + const bashTool = tools.bash as { description?: string }; + + expect(bashTool.description).toContain("Shuru sandbox"); + expect(bashTool.description).toContain("do not persist back to the host"); + }); + it("reports daemon status", async () => { const { tools } = createScheduleToolSet({ getDaemonStatus: async () => ({ running: true, pid: 4321 }), diff --git a/src/grok/tools.ts b/src/grok/tools.ts index 49d1770..cf78661 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -67,8 +67,7 @@ export function createTools( const base = { bash: tool({ - description: - "Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. Set background=true for long-running processes like dev servers, watchers, or anything that should keep running while you continue working. For file read/write/edit, prefer the dedicated file tools instead.", + description: bash.getToolDescription(), inputSchema: z.object({ command: z.string().describe("The bash command to execute"), timeout: z diff --git a/src/index.ts b/src/index.ts index d68dfd3..9cabed0 100755 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,14 @@ import { } from "./headless/output"; import { runTelegramHeadlessBridge } from "./telegram/headless-bridge"; import { startScheduleDaemon } from "./tools/schedule"; -import { getApiKey, getBaseURL, getCurrentModel, saveUserSettings } from "./utils/settings"; +import { + getApiKey, + getBaseURL, + getCurrentModel, + getCurrentSandboxMode, + type SandboxMode, + saveUserSettings, +} from "./utils/settings"; dotenv.config(); @@ -39,10 +46,11 @@ async function startInteractive( baseURL: string, model: string, maxToolRounds: number, + sandboxMode: SandboxMode, session?: string, initialMessage?: string, ) { - const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session }); + const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode }); const { createCliRenderer } = await import("@opentui/core"); const { createRoot } = await import("@opentui/react"); const { createElement } = await import("react"); @@ -70,6 +78,7 @@ async function startInteractive( baseURL, model, maxToolRounds, + sandboxMode, }, initialMessage, onExit, @@ -83,10 +92,11 @@ async function runHeadless( baseURL: string, model: string, maxToolRounds: number, + sandboxMode: SandboxMode, format: HeadlessOutputFormat, session?: string, ) { - const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session }); + const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode }); const prelude = renderHeadlessPrelude(format, agent.getSessionId() || undefined); if (prelude.stdout) process.stdout.write(prelude.stdout); if (prelude.stderr) process.stderr.write(prelude.stderr); @@ -125,21 +135,34 @@ function changeDirectoryOrExit(directory: string | undefined) { } } -async function runBackgroundDelegation(jobPath: string, options: Record) { +type CliOptions = Record; + +function stringOption(value: string | boolean | undefined): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function resolveCliSandboxMode(value: string | boolean | undefined): SandboxMode | undefined { + if (value === true) return "shuru"; + if (value === false) return "off"; + return undefined; +} + +async function runBackgroundDelegation(jobPath: string, options: CliOptions) { let output = ""; try { const delegation = await loadDelegation(jobPath); - const apiKey = options.apiKey || getApiKey(); + const apiKey = stringOption(options.apiKey) || getApiKey(); if (!apiKey) { throw new Error("API key required. Set GROK_API_KEY, use --api-key, or save it to ~/.grok/user-settings.json."); } - const baseURL = options.baseUrl || getBaseURL(); - const model = normalizeModelId(options.model || delegation.model || getCurrentModel()); + const baseURL = stringOption(options.baseUrl) || getBaseURL(); + const model = normalizeModelId(stringOption(options.model) || delegation.model || getCurrentModel()); const maxToolRounds = - parseInt(options.maxToolRounds || String(delegation.maxToolRounds), 10) || delegation.maxToolRounds; - const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { persistSession: false }); + parseInt(stringOption(options.maxToolRounds) || String(delegation.maxToolRounds), 10) || delegation.maxToolRounds; + const sandboxMode = resolveCliSandboxMode(options.sandbox) || delegation.sandboxMode || getCurrentSandboxMode(); + const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { persistSession: false, sandboxMode }); const result = await agent.runTaskRequest({ agent: delegation.agent, description: delegation.description, @@ -165,16 +188,17 @@ async function runBackgroundDelegation(jobPath: string, options: Record) { - const apiKey = options.apiKey || getApiKey(); - const baseURL = options.baseUrl || getBaseURL(); - const model = normalizeModelId(options.model || getCurrentModel()); - const maxToolRounds = parseInt(options.maxToolRounds || "400", 10) || 400; +function resolveConfig(options: CliOptions) { + const apiKey = stringOption(options.apiKey) || getApiKey(); + const baseURL = stringOption(options.baseUrl) || getBaseURL(); + const model = normalizeModelId(stringOption(options.model) || getCurrentModel()); + const maxToolRounds = parseInt(stringOption(options.maxToolRounds) || "400", 10) || 400; + const sandboxMode = resolveCliSandboxMode(options.sandbox) || getCurrentSandboxMode(); - if (options.apiKey) saveUserSettings({ apiKey: options.apiKey }); - if (options.model) saveUserSettings({ defaultModel: normalizeModelId(options.model) }); + if (typeof options.apiKey === "string") saveUserSettings({ apiKey: options.apiKey }); + if (typeof options.model === "string") saveUserSettings({ defaultModel: normalizeModelId(options.model) }); - return { apiKey, baseURL, model, maxToolRounds }; + return { apiKey, baseURL, model, maxToolRounds, sandboxMode }; } function requireApiKey(apiKey: string | undefined): string { @@ -207,6 +231,8 @@ program .option("-d, --directory ", "Working directory", process.cwd()) .option("-p, --prompt ", "Run a single prompt headlessly") .option("--format ", "Headless output format: text or json", parseHeadlessOutputFormat, "text") + .option("--sandbox", "Run agent shell commands inside a Shuru sandbox") + .option("--no-sandbox", "Run agent shell commands directly on the host") .option("-s, --session ", "Continue a saved session by id, or use 'latest'") .option("--background-task-file ", "Run a persisted background delegation") .option("--max-tool-rounds ", "Max tool execution rounds", "400") @@ -227,6 +253,7 @@ program config.baseURL, config.model, config.maxToolRounds, + config.sandboxMode, options.format, options.session, ); @@ -239,6 +266,7 @@ program config.baseURL, config.model, config.maxToolRounds, + config.sandboxMode, options.session, initialMessage, ); @@ -251,6 +279,8 @@ program .option("-u, --base-url ", "API base URL") .option("-m, --model ", "Model to use") .option("-d, --directory ", "Working directory", process.cwd()) + .option("--sandbox", "Run agent shell commands inside a Shuru sandbox") + .option("--no-sandbox", "Run agent shell commands directly on the host") .option("--max-tool-rounds ", "Max tool execution rounds", "400") .option("--log-file ", "Bridge log file", "telegram-remote-bridge.log") .option("--pair-code-file ", "Pairing code file", "telegram-pair-code.txt") @@ -265,6 +295,7 @@ program baseURL: config.baseURL, model: config.model, maxToolRounds: config.maxToolRounds, + sandboxMode: config.sandboxMode, logFile: options.logFile, pairCodeFile: options.pairCodeFile, }); diff --git a/src/telegram/headless-bridge.ts b/src/telegram/headless-bridge.ts index 7e6732f..588e7cf 100644 --- a/src/telegram/headless-bridge.ts +++ b/src/telegram/headless-bridge.ts @@ -6,8 +6,10 @@ import { getApiKey, getBaseURL, getCurrentModel, + getCurrentSandboxMode, getTelegramBotToken, loadUserSettings, + type SandboxMode, saveApprovedTelegramUserId, saveUserSettings, } from "../utils/settings"; @@ -24,6 +26,7 @@ export interface TelegramHeadlessBridgeOptions { apiKey?: string; baseURL?: string; model?: string; + sandboxMode?: SandboxMode; maxToolRounds?: number; logFile?: string; pairCodeFile?: string; @@ -33,6 +36,7 @@ interface TelegramHeadlessStartupConfig { apiKey: string; baseURL: string; model: string; + sandboxMode: SandboxMode; maxToolRounds: number; } @@ -72,7 +76,7 @@ function buildTelegramAgentFactory(startupConfig: TelegramHeadlessStartupConfig) startupConfig.baseURL, startupConfig.model, startupConfig.maxToolRounds, - { session: sessionId }, + { session: sessionId, sandboxMode: startupConfig.sandboxMode }, ); const nextSessionId = agent.getSessionId(); @@ -121,6 +125,7 @@ export async function runTelegramHeadlessBridge(options: TelegramHeadlessBridgeO apiKey, baseURL: options.baseURL ?? getBaseURL(), model: options.model ?? getCurrentModel(), + sandboxMode: options.sandboxMode ?? getCurrentSandboxMode(), maxToolRounds: options.maxToolRounds ?? 400, }; const pathOptions: TelegramHeadlessBridgePathOptions = { diff --git a/src/tools/bash.test.ts b/src/tools/bash.test.ts new file mode 100644 index 0000000..6c3ba8e --- /dev/null +++ b/src/tools/bash.test.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { BashTool, getSandboxMutationBlockReason, wrapCommandForShuru } from "./bash"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("wrapCommandForShuru", () => { + it("wraps a simple command with mount and workspace cd", () => { + const result = wrapCommandForShuru("/repo", "echo hi"); + expect(result).toBe("shuru run --mount '/repo:/workspace' -- sh -lc 'cd /workspace && echo hi'"); + }); + + it("handles paths with spaces", () => { + const result = wrapCommandForShuru("/my repo", "ls"); + expect(result).toBe("shuru run --mount '/my repo:/workspace' -- sh -lc 'cd /workspace && ls'"); + }); + + it("escapes single quotes in the command", () => { + const result = wrapCommandForShuru("/repo", "echo 'hello world'"); + expect(result).toContain("'\\''hello world'\\''"); + }); +}); + +describe("getSandboxMutationBlockReason", () => { + it("returns null for read-only git commands", () => { + expect(getSandboxMutationBlockReason("git status")).toBeNull(); + expect(getSandboxMutationBlockReason("git diff")).toBeNull(); + expect(getSandboxMutationBlockReason("git log --oneline")).toBeNull(); + expect(getSandboxMutationBlockReason("git show HEAD")).toBeNull(); + expect(getSandboxMutationBlockReason("git rev-parse HEAD")).toBeNull(); + expect(getSandboxMutationBlockReason("git grep foo")).toBeNull(); + expect(getSandboxMutationBlockReason("git ls-files")).toBeNull(); + }); + + it("blocks mutating git commands", () => { + expect(getSandboxMutationBlockReason("git add .")).toContain("Sandbox mode blocks git commands"); + expect(getSandboxMutationBlockReason("git commit -m 'test'")).toContain("Sandbox mode blocks git commands"); + expect(getSandboxMutationBlockReason("git push")).toContain("Sandbox mode blocks git commands"); + expect(getSandboxMutationBlockReason("git checkout -b new")).toContain("Sandbox mode blocks git commands"); + }); + + it("blocks git inside compound shell expressions", () => { + expect(getSandboxMutationBlockReason('echo foo && git commit -m "test"')).toContain( + "Sandbox mode blocks git commands", + ); + expect(getSandboxMutationBlockReason('bash -c "git push"')).toContain("Sandbox mode blocks git commands"); + }); + + it("blocks git branch since it can mutate", () => { + expect(getSandboxMutationBlockReason("git branch -D feature")).toContain("Sandbox mode blocks git commands"); + expect(getSandboxMutationBlockReason("git branch new-name")).toContain("Sandbox mode blocks git commands"); + }); + + it("blocks package-manager installs", () => { + expect(getSandboxMutationBlockReason("npm install express")).toContain("Package-manager installs"); + expect(getSandboxMutationBlockReason("yarn add lodash")).toContain("Package-manager installs"); + expect(getSandboxMutationBlockReason("pnpm install")).toContain("Package-manager installs"); + expect(getSandboxMutationBlockReason("bun add zod")).toContain("Package-manager installs"); + }); + + it("blocks package-manager installs in compound commands", () => { + expect(getSandboxMutationBlockReason("echo foo && npm install express")).toContain("Package-manager installs"); + }); + + it("blocks formatters that rewrite files", () => { + expect(getSandboxMutationBlockReason("prettier --write src/")).toContain("formatters"); + expect(getSandboxMutationBlockReason("biome check --write src/")).toContain("formatters"); + }); + + it("allows safe non-mutating commands", () => { + expect(getSandboxMutationBlockReason("ls -la")).toBeNull(); + expect(getSandboxMutationBlockReason("cat README.md")).toBeNull(); + expect(getSandboxMutationBlockReason("grep -r foo src/")).toBeNull(); + expect(getSandboxMutationBlockReason("uname -a")).toBeNull(); + expect(getSandboxMutationBlockReason("npm run test")).toBeNull(); + expect(getSandboxMutationBlockReason("node -e 'console.log(1)'")).toBeNull(); + }); + + it("returns null for empty commands", () => { + expect(getSandboxMutationBlockReason("")).toBeNull(); + expect(getSandboxMutationBlockReason(" ")).toBeNull(); + }); +}); + +describe("BashTool sandbox state", () => { + it("tracks cwd changes independently of sandbox mode", () => { + const root = makeTempDir("grok-bash-test-"); + const nested = path.join(root, "nested"); + fs.mkdirSync(nested); + const bash = new BashTool(root, { sandboxMode: "shuru" }); + + expect(bash.getCwd()).toBe(root); + expect(bash.getSandboxMode()).toBe("shuru"); + }); + + it("can switch sandbox mode at runtime", () => { + const bash = new BashTool("/repo", { sandboxMode: "off" }); + + expect(bash.getSandboxMode()).toBe("off"); + + bash.setSandboxMode("shuru"); + + expect(bash.getSandboxMode()).toBe("shuru"); + }); + + it("returns sandbox-aware tool description", () => { + const off = new BashTool("/repo", { sandboxMode: "off" }); + const on = new BashTool("/repo", { sandboxMode: "shuru" }); + + expect(off.getToolDescription()).not.toContain("Shuru"); + expect(on.getToolDescription()).toContain("Shuru sandbox"); + expect(on.getToolDescription()).toContain("do not persist back to the host"); + }); +}); diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 04d47ee..8165eec 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -4,6 +4,7 @@ import { mkdtemp, rm, stat, unlink } from "fs/promises"; import os from "os"; import path from "path"; import type { ToolResult } from "../types/index"; +import type { SandboxMode } from "../utils/settings"; const MAX_TAIL_BYTES = 8_192; const MAX_BACKGROUND_PROCESSES = 8; @@ -20,15 +21,21 @@ export interface BackgroundProcess { exitCode: number | null; } +interface BashToolOptions { + sandboxMode?: SandboxMode; +} + let nextBgId = 1; export class BashTool { private cwd: string; private bgProcesses = new Map(); private tmpDir: string | null = null; + private sandboxMode: SandboxMode; - constructor(initialCwd = process.cwd()) { + constructor(initialCwd = process.cwd(), options: BashToolOptions = {}) { this.cwd = initialCwd; + this.sandboxMode = options.sandboxMode ?? "off"; } private async ensureTmpDir(): Promise { @@ -63,6 +70,11 @@ export class BashTool { return { success: false, error: "[Cancelled]" }; } + const prepared = this.prepareCommand(command); + if (!prepared.ok) { + return { success: false, error: prepared.error }; + } + return await new Promise((resolve) => { let settled = false; let aborted = false; @@ -77,7 +89,7 @@ export class BashTool { }; const child = exec( - command, + prepared.command, { cwd: this.cwd, timeout, @@ -92,6 +104,11 @@ export class BashTool { const output = stdout + (stderr ? `\nSTDERR: ${stderr}` : ""); if (err) { + const sandboxError = this.formatSandboxRuntimeError(output, err.message); + if (sandboxError) { + finish({ success: false, error: sandboxError }); + return; + } if (output.trim()) { finish({ success: false, error: output.trim() }); return; @@ -150,12 +167,16 @@ export class BashTool { } try { + const prepared = this.prepareCommand(command); + if (!prepared.ok) { + return { success: false, output: prepared.error }; + } const tmpDir = await this.ensureTmpDir(); const id = nextBgId++; const logPath = path.join(tmpDir, `bg-${id}.log`); const logStream = createWriteStream(logPath, { flags: "a" }); - const child = spawn("sh", ["-c", command], { + const child = spawn("sh", ["-c", prepared.command], { cwd: this.cwd, detached: false, stdio: ["ignore", "pipe", "pipe"], @@ -332,6 +353,49 @@ export class BashTool { getCwd(): string { return this.cwd; } + + getSandboxMode(): SandboxMode { + return this.sandboxMode; + } + + setSandboxMode(mode: SandboxMode): void { + this.sandboxMode = mode; + } + + getToolDescription(): string { + if (this.sandboxMode === "shuru") { + return "Execute a bash command inside a Shuru sandbox. Use for searching (grep, rg, find), git inspection, build tools, test runners, and other shell commands that should stay isolated. The current workspace is mounted inside the sandbox at /workspace, network is disabled by default unless configured, and shell-side workspace file changes do not persist back to the host in this version, so prefer the dedicated file tools for durable edits. Set background=true for long-running processes like dev servers or watchers."; + } + return "Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. Set background=true for long-running processes like dev servers, watchers, or anything that should keep running while you continue working. For file read/write/edit, prefer the dedicated file tools instead."; + } + + private prepareCommand(command: string): { ok: true; command: string } | { ok: false; error: string } { + if (this.sandboxMode !== "shuru") { + return { ok: true, command }; + } + const unsupportedReason = getSandboxUnsupportedReason(); + if (unsupportedReason) { + return { ok: false, error: unsupportedReason }; + } + const blockedReason = getSandboxMutationBlockReason(command); + if (blockedReason) { + return { ok: false, error: blockedReason }; + } + return { ok: true, command: wrapCommandForShuru(this.cwd, command) }; + } + + private formatSandboxRuntimeError(output: string, fallbackMessage: string): string | null { + if (this.sandboxMode !== "shuru") { + return null; + } + if (output.includes("shuru: command not found") || output.includes("sh: shuru: not found")) { + return "Shuru sandbox mode is enabled, but the `shuru` CLI is not installed or not on PATH. Install Shuru or disable sandbox mode."; + } + if (output.includes("Apple Silicon") || fallbackMessage.includes("Apple Silicon")) { + return "Shuru sandbox mode requires macOS on Apple Silicon."; + } + return null; + } } function truncCmd(cmd: string, max: number): string { @@ -347,3 +411,55 @@ function formatAge(start: Date): string { const hr = Math.floor(min / 60); return `${hr}h${min % 60}m`; } + +export function wrapCommandForShuru(cwd: string, command: string): string { + const mountArg = `${cwd}:/workspace`; + const innerCommand = `cd /workspace && ${command}`; + return `shuru run --mount ${shellQuote(mountArg)} -- sh -lc ${shellQuote(innerCommand)}`; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function getSandboxUnsupportedReason(): string | null { + if (process.platform !== "darwin" || process.arch !== "arm64") { + return "Shuru sandbox mode currently requires macOS on Apple Silicon."; + } + return null; +} + +export function getSandboxMutationBlockReason(command: string): string | null { + const trimmed = command.trim(); + if (!trimmed) return null; + + if (/\bgit\s+/.test(trimmed) && !/\bgit\s+(status|diff|log|show|rev-parse|grep|ls-files)\b/.test(trimmed)) { + return [ + "Sandbox mode blocks git commands that mutate repository state because Shuru guest-side workspace changes do not persist back to the host.", + "Disable sandbox mode to run persistent git mutations on the real workspace.", + ].join(" "); + } + + const blockedPatterns: Array<{ pattern: RegExp; reason: string }> = [ + { + pattern: /\b(?:npm|pnpm|yarn|bun)\s+(?:add|install|remove|unlink|update|upgrade)\b/, + reason: + "Package-manager installs are blocked in sandbox mode because workspace changes like lockfile updates would stay inside the Shuru guest overlay.", + }, + { + pattern: /\b(?:pip|pip3)\s+install\b|\bpoetry\s+add\b|\buv\s+add\b|\bcargo\s+add\b/, + reason: + "Dependency install commands are blocked in sandbox mode because resulting workspace changes would not persist back to the host.", + }, + { + pattern: + /\b(?:prettier\s+--write|eslint\b.*--fix|biome\s+check\b.*--write|ruff\s+check\b.*--fix|gofmt\s+-w|rustfmt\b|clang-format\b.*-i)\b/, + reason: + "Shell-driven formatters that rewrite files are blocked in sandbox mode because those file changes would not persist back to the host workspace.", + }, + ]; + + const matched = blockedPatterns.find(({ pattern }) => pattern.test(trimmed)); + if (!matched) return null; + return `${matched.reason} Use read_file/edit_file/write_file for durable edits, or disable sandbox mode for host-persistent shell changes.`; +} diff --git a/src/ui/app.tsx b/src/ui/app.tsx index ff0cdcd..3d88238 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -44,6 +44,7 @@ import { loadValidSubAgents, type McpRemoteTransport, type McpServerConfig, + type SandboxMode, saveApprovedTelegramUserId, saveMcpServers, saveProjectSettings, @@ -282,6 +283,7 @@ const SLASH_MENU_ITEMS: SlashMenuItem[] = [ { id: "agents", label: "agents", description: "Manage custom sub-agents" }, { id: "schedule", label: "schedule", description: "View scheduled runs" }, { id: "mcp", label: "mcp", description: "Manage MCP servers" }, + { id: "sandbox", label: "sandbox", description: "Select shell sandbox mode" }, { id: "models", label: "models", description: "Select a model" }, { id: "new", label: "new session", description: "Start a new session" }, { id: "commit-push", label: "commit & push", description: "Commit and push" }, @@ -330,6 +332,7 @@ const BUILTIN_TYPED_SLASH_COMMANDS = new Set([ "/clear", "/model", "/models", + "/sandbox", "/remote-control", "/mcp", "/mcps", @@ -345,6 +348,11 @@ const BUILTIN_TYPED_SLASH_COMMANDS = new Set([ "/commit-pr", ]); +const SANDBOX_OPTIONS: Array<{ id: SandboxMode; label: string; description: string }> = [ + { id: "off", label: "Off", description: "Run shell commands directly on the host" }, + { id: "shuru", label: "Shuru", description: "Run shell commands inside a Shuru sandbox" }, +]; + function parseCustomSubagentSlashCommand( cmd: string, subagents: CustomSubagentConfig[], @@ -397,6 +405,7 @@ export interface AppStartupConfig { apiKey: string | undefined; baseURL: string; model: string; + sandboxMode: SandboxMode; maxToolRounds: number; } @@ -429,10 +438,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const [isProcessing, setIsProcessing] = useState(false); const [liveTurnSourceLabel, setLiveTurnSourceLabel] = useState(null); const [model, setModel] = useState(agent.getModel()); + const [sandboxMode, setSandboxModeState] = useState(agent.getSandboxMode()); const [mode, setModeState] = useState(agent.getMode()); const [showModelPicker, setShowModelPicker] = useState(false); const [modelPickerIndex, setModelPickerIndex] = useState(0); const [modelSearchQuery, setModelSearchQuery] = useState(""); + const [showSandboxPicker, setShowSandboxPicker] = useState(false); + const [sandboxPickerIndex, setSandboxPickerIndex] = useState(0); const [activeToolCalls, setActiveToolCalls] = useState([]); const [sessionTitle, setSessionTitle] = useState(() => agent.getSessionTitle()); const [sessionId, setSessionId] = useState(() => agent.getSessionId()); @@ -572,6 +584,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) ) : MODELS; const filteredModelIds = filteredModels.map((m) => m.id); + const currentSandboxIndex = SANDBOX_OPTIONS.findIndex((option) => option.id === sandboxMode); const filteredSlashItems = slashSearchQuery ? SLASH_MENU_ITEMS.filter( (item) => @@ -595,6 +608,24 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) saveMcpServers(servers); }, []); + const applySandboxMode = useCallback( + (next: SandboxMode) => { + agent.setSandboxMode(next); + for (const telegramAgent of telegramAgentsRef.current.values()) { + telegramAgent.setSandboxMode(next); + } + setSandboxModeState(next); + saveProjectSettings({ sandboxMode: next }); + saveUserSettings({ sandboxMode: next }); + }, + [agent], + ); + + const openSandboxPicker = useCallback(() => { + setSandboxPickerIndex(Math.max(0, currentSandboxIndex)); + setShowSandboxPicker(true); + }, [currentSandboxIndex]); + const setReasoningEfforts = useCallback((next: Record) => { setReasoningEffortByModel(next); saveUserSettings({ reasoningEffortByModel: next }); @@ -1275,6 +1306,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const sid = u.telegram?.sessionsByUserId?.[String(userId)]; const a = new Agent(apiKey, startupConfig.baseURL, startupConfig.model, startupConfig.maxToolRounds, { session: sid, + sandboxMode, }); if (!sid && a.getSessionId()) { saveUserSettings({ @@ -1291,7 +1323,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) map.set(userId, a); return a; }, - [startupConfig, wireTelegramAgentUi], + [sandboxMode, startupConfig, wireTelegramAgentUi], ); const appendTelegramUserMessage = useCallback( @@ -1830,6 +1862,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setModelSearchQuery(""); return true; } + if (c === "/sandbox") { + openSandboxPicker(); + return true; + } if (c === "/remote-control") { setConnectModalIndex(0); setShowConnectModal(true); @@ -1880,7 +1916,16 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) } return false; }, - [handleExit, openAgentsModal, openMcpModal, openScheduleModal, processMessage, resetToNewSession, subAgents], + [ + handleExit, + openAgentsModal, + openMcpModal, + openSandboxPicker, + openScheduleModal, + processMessage, + resetToNewSession, + subAgents, + ], ); const handleSlashMenuSelect = useCallback( @@ -1896,6 +1941,9 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setModelPickerIndex(0); setModelSearchQuery(""); break; + case "sandbox": + openSandboxPicker(); + break; case "remote-control": setConnectModalIndex(0); setShowConnectModal(true); @@ -1943,7 +1991,16 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) break; } }, - [agent, handleExit, openAgentsModal, openMcpModal, openScheduleModal, processMessage, resetToNewSession], + [ + agent, + handleExit, + openAgentsModal, + openMcpModal, + openSandboxPicker, + openScheduleModal, + processMessage, + resetToNewSession, + ], ); const blockPrompt = @@ -1951,6 +2008,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) showTelegramTokenModal || showTelegramPairModal || showMcpModal || + showSandboxPicker || showScheduleModal || showAgentsModal || showAgentsEditor; @@ -2467,6 +2525,29 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) } return; } + if (showSandboxPicker) { + if (isEscapeKey(key)) { + setShowSandboxPicker(false); + return; + } + if (key.name === "up") { + setSandboxPickerIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.name === "down") { + setSandboxPickerIndex((i) => Math.min(SANDBOX_OPTIONS.length - 1, i + 1)); + return; + } + if (key.name === "return") { + const selected = SANDBOX_OPTIONS[sandboxPickerIndex]; + if (selected) { + applySandboxMode(selected.id); + } + setShowSandboxPicker(false); + return; + } + return; + } if (isEscapeKey(key) && interruptActiveRun(key)) { return; @@ -2572,8 +2653,11 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) planTabCount, pqs, removeEditingSubagent, + applySandboxMode, + sandboxPickerIndex, showModelPicker, showPlanPanel, + showSandboxPicker, showSlashMenu, slashMenuIndex, submitApiKey, @@ -2721,6 +2805,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} + showSandboxPicker={showSandboxPicker} showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel} showApiKeyModal={showApiKeyModal} @@ -2737,6 +2822,11 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) /> + + {agent.getCwd().replace(os.homedir(), "~")} + {sandboxMode === "shuru" ? {" · sandbox"} : null} + + ) : ( /* ── Home ───────────────────────────────────────── */ @@ -2753,6 +2843,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} + showSandboxPicker={showSandboxPicker} showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel} showApiKeyModal={showApiKeyModal} @@ -2772,6 +2863,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) {agent.getCwd().replace(os.homedir(), "~")} + {sandboxMode === "shuru" ? {" · sandbox"} : null} {"v1.0.0"} @@ -2876,6 +2968,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) reasoningEffortByModel={reasoningEffortByModel} /> )} + {showSandboxPicker && ( + + )} {showConnectModal && ( ; isProcessing: boolean; showModelPicker: boolean; + showSandboxPicker: boolean; showSlashMenu: boolean; showPlanQuestions: boolean; showApiKeyModal: boolean; @@ -3055,7 +3158,14 @@ function PromptBox({