diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index cb6fc6ff023..f4532609086 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -17,6 +17,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" import { Package } from "../../shared/package" +import { SandboxManager } from "../../integrations/terminal/sandbox" import { t } from "../../i18n" import { getTaskDirectoryPath } from "../../utils/storage" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -312,7 +313,12 @@ export async function executeCommandInTerminal( workingDir = terminal.getCurrentWorkingDirectory() } - const process = terminal.runCommand(command, callbacks) + // Wrap the command through the sandbox manager if sandboxing is enabled. + // This uses the `srt` CLI tool to provide network/filesystem isolation. + const sandboxManager = SandboxManager.getInstance() + const sandboxedCommand = sandboxManager.wrapCommand(command, workingDir) + + const process = terminal.runCommand(sandboxedCommand, callbacks) task.terminalProcess = process // Dual-timeout logic: diff --git a/src/integrations/terminal/sandbox/NoOpSandbox.ts b/src/integrations/terminal/sandbox/NoOpSandbox.ts new file mode 100644 index 00000000000..e34080e0ed4 --- /dev/null +++ b/src/integrations/terminal/sandbox/NoOpSandbox.ts @@ -0,0 +1,15 @@ +import type { CommandSandbox } from "./types" + +/** + * No-op sandbox that passes commands through unchanged. + * Used when sandboxing is disabled. + */ +export class NoOpSandbox implements CommandSandbox { + async isAvailable(): Promise { + return true + } + + wrapCommand(command: string, _cwd: string): string { + return command + } +} diff --git a/src/integrations/terminal/sandbox/SandboxManager.ts b/src/integrations/terminal/sandbox/SandboxManager.ts new file mode 100644 index 00000000000..e053351e297 --- /dev/null +++ b/src/integrations/terminal/sandbox/SandboxManager.ts @@ -0,0 +1,128 @@ +import * as vscode from "vscode" + +import { Package } from "../../../shared/package" + +import type { CommandSandbox, SandboxConfig } from "./types" +import { DEFAULT_SANDBOX_CONFIG } from "./types" +import { SrtSandbox } from "./SrtSandbox" +import { NoOpSandbox } from "./NoOpSandbox" + +/** + * Manages command sandbox configuration and provides the appropriate + * sandbox implementation based on user settings. + * + * The SandboxManager reads VS Code configuration to determine whether + * sandboxing is enabled and which policies to apply, then returns either + * an SrtSandbox (for real isolation) or a NoOpSandbox (passthrough). + */ +export class SandboxManager { + private static instance: SandboxManager | undefined + private sandbox: CommandSandbox | undefined + private lastConfig: SandboxConfig | undefined + + /** + * Get the singleton SandboxManager instance. + */ + static getInstance(): SandboxManager { + if (!SandboxManager.instance) { + SandboxManager.instance = new SandboxManager() + } + return SandboxManager.instance + } + + /** + * Read sandbox configuration from VS Code settings. + */ + getConfig(): SandboxConfig { + const config = vscode.workspace.getConfiguration(Package.name) + + return { + enabled: config.get("commandSandboxEnabled", DEFAULT_SANDBOX_CONFIG.enabled), + networkPolicy: config.get<"allow" | "deny">( + "commandSandboxNetworkPolicy", + DEFAULT_SANDBOX_CONFIG.networkPolicy, + ), + writePolicy: config.get<"allow" | "deny">("commandSandboxWritePolicy", DEFAULT_SANDBOX_CONFIG.writePolicy), + allowedPaths: config.get("commandSandboxAllowedPaths", DEFAULT_SANDBOX_CONFIG.allowedPaths), + deniedPaths: config.get("commandSandboxDeniedPaths", DEFAULT_SANDBOX_CONFIG.deniedPaths), + } + } + + /** + * Get the appropriate sandbox implementation based on current configuration. + * Returns a NoOpSandbox if sandboxing is disabled, or an SrtSandbox if enabled. + * + * The sandbox instance is cached and reused as long as the configuration + * hasn't changed. + */ + getSandbox(): CommandSandbox { + const config = this.getConfig() + + // Return cached sandbox if config hasn't changed + if (this.sandbox && this.lastConfig && configsEqual(this.lastConfig, config)) { + return this.sandbox + } + + this.lastConfig = config + + if (!config.enabled) { + this.sandbox = new NoOpSandbox() + } else { + this.sandbox = new SrtSandbox(config) + } + + return this.sandbox + } + + /** + * Wrap a command using the current sandbox configuration. + * + * @param command The command to potentially wrap + * @param cwd The working directory for the command + * @returns The (possibly wrapped) command string + */ + wrapCommand(command: string, cwd: string): string { + return this.getSandbox().wrapCommand(command, cwd) + } + + /** + * Check if the current sandbox provider is available. + */ + async isAvailable(): Promise { + return this.getSandbox().isAvailable() + } + + /** + * Reset the singleton instance (for testing). + */ + static resetInstance(): void { + SandboxManager.instance = undefined + } +} + +/** + * Deep-compare two SandboxConfig objects for equality. + */ +function configsEqual(a: SandboxConfig, b: SandboxConfig): boolean { + return ( + a.enabled === b.enabled && + a.networkPolicy === b.networkPolicy && + a.writePolicy === b.writePolicy && + arraysEqual(a.allowedPaths, b.allowedPaths) && + arraysEqual(a.deniedPaths, b.deniedPaths) + ) +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false + } + } + + return true +} diff --git a/src/integrations/terminal/sandbox/SrtSandbox.ts b/src/integrations/terminal/sandbox/SrtSandbox.ts new file mode 100644 index 00000000000..0072ceea511 --- /dev/null +++ b/src/integrations/terminal/sandbox/SrtSandbox.ts @@ -0,0 +1,99 @@ +import { execa } from "execa" + +import type { CommandSandbox, SandboxConfig } from "./types" + +/** + * Sandbox implementation using the `srt` CLI tool from Anthropic's sandbox-runtime. + * + * The `srt` tool wraps commands to run them in a sandboxed environment with + * configurable network and filesystem isolation. This approach works with both + * VSCode shell integration terminals and execa terminals since it wraps + * commands at the string level. + * + * @see https://github.com/anthropic-experimental/sandbox-runtime + */ +export class SrtSandbox implements CommandSandbox { + private config: SandboxConfig + private availabilityChecked = false + private available = false + + constructor(config: SandboxConfig) { + this.config = config + } + + /** + * Check if the `srt` CLI tool is available on the system. + * Caches the result after the first check. + */ + async isAvailable(): Promise { + if (this.availabilityChecked) { + return this.available + } + + try { + await execa("srt", ["--version"]) + this.available = true + } catch { + this.available = false + } + + this.availabilityChecked = true + return this.available + } + + /** + * Wrap a command with `srt exec` to run it in a sandboxed environment. + * + * The srt tool uses Linux namespaces (via bubblewrap) to provide: + * - Network isolation (--net=none) + * - Filesystem isolation (read-only bind mounts, allowed paths) + * - Process isolation + * + * @param command The command to sandbox + * @param cwd The working directory for the command + * @returns The wrapped command string ready for terminal execution + */ + wrapCommand(command: string, cwd: string): string { + const args: string[] = ["srt", "exec"] + + // Network policy + if (this.config.networkPolicy === "deny") { + args.push("--net=none") + } + + // Filesystem write policy + if (this.config.writePolicy === "deny") { + args.push("--readonly") + } + + // Allowed paths: bind-mount them read-write + const allowedPaths = this.config.allowedPaths.length > 0 ? this.config.allowedPaths : [cwd] + + for (const allowedPath of allowedPaths) { + args.push(`--bind=${allowedPath}`) + } + + // Denied paths + for (const deniedPath of this.config.deniedPaths) { + args.push(`--deny=${deniedPath}`) + } + + // Set the working directory + args.push(`--chdir=${cwd}`) + + // Add the separator and the actual command + args.push("--") + args.push("sh", "-c", escapeShellArg(command)) + + return args.join(" ") + } +} + +/** + * Escape a string for safe use as a single-quoted shell argument. + * Wraps the value in single quotes, escaping any embedded single quotes. + */ +function escapeShellArg(arg: string): string { + // Replace each single quote with: end quote, escaped quote, start quote + return "'" + arg.replace(/'/g, "'\\''") + "'" +} diff --git a/src/integrations/terminal/sandbox/__tests__/NoOpSandbox.spec.ts b/src/integrations/terminal/sandbox/__tests__/NoOpSandbox.spec.ts new file mode 100644 index 00000000000..0feeae378a3 --- /dev/null +++ b/src/integrations/terminal/sandbox/__tests__/NoOpSandbox.spec.ts @@ -0,0 +1,14 @@ +import { NoOpSandbox } from "../NoOpSandbox" + +describe("NoOpSandbox", () => { + it("should always be available", async () => { + const sandbox = new NoOpSandbox() + expect(await sandbox.isAvailable()).toBe(true) + }) + + it("should pass commands through unchanged", () => { + const sandbox = new NoOpSandbox() + const command = "npm test --verbose" + expect(sandbox.wrapCommand(command, "/some/dir")).toBe(command) + }) +}) diff --git a/src/integrations/terminal/sandbox/__tests__/SandboxManager.spec.ts b/src/integrations/terminal/sandbox/__tests__/SandboxManager.spec.ts new file mode 100644 index 00000000000..ca5f6601b14 --- /dev/null +++ b/src/integrations/terminal/sandbox/__tests__/SandboxManager.spec.ts @@ -0,0 +1,135 @@ +import { SandboxManager } from "../SandboxManager" +import { NoOpSandbox } from "../NoOpSandbox" +import { SrtSandbox } from "../SrtSandbox" + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + }, +})) + +// Mock the package module +vi.mock("../../../../shared/package", () => ({ + Package: { + name: "roo-cline", + }, +})) + +import * as vscode from "vscode" + +describe("SandboxManager", () => { + beforeEach(() => { + SandboxManager.resetInstance() + }) + + function mockConfig(overrides: Record = {}) { + const defaults: Record = { + commandSandboxEnabled: false, + commandSandboxNetworkPolicy: "deny", + commandSandboxWritePolicy: "allow", + commandSandboxAllowedPaths: [], + commandSandboxDeniedPaths: [], + } + + const merged = { ...defaults, ...overrides } + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: any) => { + return key in merged ? merged[key] : defaultValue + }), + } as any) + } + + describe("getInstance", () => { + it("should return a singleton instance", () => { + const a = SandboxManager.getInstance() + const b = SandboxManager.getInstance() + expect(a).toBe(b) + }) + + it("should return a new instance after reset", () => { + const a = SandboxManager.getInstance() + SandboxManager.resetInstance() + const b = SandboxManager.getInstance() + expect(a).not.toBe(b) + }) + }) + + describe("getConfig", () => { + it("should read configuration from vscode workspace", () => { + mockConfig({ commandSandboxEnabled: true, commandSandboxNetworkPolicy: "allow" }) + + const manager = SandboxManager.getInstance() + const config = manager.getConfig() + + expect(config.enabled).toBe(true) + expect(config.networkPolicy).toBe("allow") + expect(config.writePolicy).toBe("allow") + expect(config.allowedPaths).toEqual([]) + expect(config.deniedPaths).toEqual([]) + }) + }) + + describe("getSandbox", () => { + it("should return NoOpSandbox when disabled", () => { + mockConfig({ commandSandboxEnabled: false }) + + const manager = SandboxManager.getInstance() + const sandbox = manager.getSandbox() + + expect(sandbox).toBeInstanceOf(NoOpSandbox) + }) + + it("should return SrtSandbox when enabled", () => { + mockConfig({ commandSandboxEnabled: true }) + + const manager = SandboxManager.getInstance() + const sandbox = manager.getSandbox() + + expect(sandbox).toBeInstanceOf(SrtSandbox) + }) + + it("should cache the sandbox instance when config unchanged", () => { + mockConfig({ commandSandboxEnabled: true }) + + const manager = SandboxManager.getInstance() + const first = manager.getSandbox() + const second = manager.getSandbox() + + expect(first).toBe(second) + }) + + it("should create new sandbox when config changes", () => { + mockConfig({ commandSandboxEnabled: true }) + const manager = SandboxManager.getInstance() + const first = manager.getSandbox() + + mockConfig({ commandSandboxEnabled: true, commandSandboxNetworkPolicy: "allow" }) + const second = manager.getSandbox() + + expect(first).not.toBe(second) + }) + }) + + describe("wrapCommand", () => { + it("should pass through when sandbox is disabled", () => { + mockConfig({ commandSandboxEnabled: false }) + + const manager = SandboxManager.getInstance() + const result = manager.wrapCommand("echo hello", "/tmp") + + expect(result).toBe("echo hello") + }) + + it("should wrap command when sandbox is enabled", () => { + mockConfig({ commandSandboxEnabled: true }) + + const manager = SandboxManager.getInstance() + const result = manager.wrapCommand("echo hello", "/tmp") + + expect(result).toContain("srt exec") + expect(result).toContain("echo hello") + }) + }) +}) diff --git a/src/integrations/terminal/sandbox/__tests__/SrtSandbox.spec.ts b/src/integrations/terminal/sandbox/__tests__/SrtSandbox.spec.ts new file mode 100644 index 00000000000..75f4b11357b --- /dev/null +++ b/src/integrations/terminal/sandbox/__tests__/SrtSandbox.spec.ts @@ -0,0 +1,159 @@ +import { SrtSandbox } from "../SrtSandbox" +import type { SandboxConfig } from "../types" + +// Mock execa to control availability check +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +describe("SrtSandbox", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultConfig: SandboxConfig = { + enabled: true, + networkPolicy: "deny", + writePolicy: "allow", + allowedPaths: [], + deniedPaths: [], + } + + describe("wrapCommand", () => { + it("should wrap command with srt exec and default network deny", () => { + const sandbox = new SrtSandbox(defaultConfig) + const result = sandbox.wrapCommand("npm test", "/home/user/project") + + expect(result).toContain("srt exec") + expect(result).toContain("--net=none") + expect(result).toContain("--bind=/home/user/project") + expect(result).toContain("--chdir=/home/user/project") + expect(result).toContain("-- sh -c") + expect(result).toContain("npm test") + }) + + it("should not include --net=none when network policy is allow", () => { + const config: SandboxConfig = { ...defaultConfig, networkPolicy: "allow" } + const sandbox = new SrtSandbox(config) + const result = sandbox.wrapCommand("curl example.com", "/tmp") + + expect(result).not.toContain("--net=none") + expect(result).toContain("srt exec") + }) + + it("should include --readonly when write policy is deny", () => { + const config: SandboxConfig = { ...defaultConfig, writePolicy: "deny" } + const sandbox = new SrtSandbox(config) + const result = sandbox.wrapCommand("ls -la", "/tmp") + + expect(result).toContain("--readonly") + }) + + it("should not include --readonly when write policy is allow", () => { + const sandbox = new SrtSandbox(defaultConfig) + const result = sandbox.wrapCommand("ls -la", "/tmp") + + expect(result).not.toContain("--readonly") + }) + + it("should use cwd as default allowed path when allowedPaths is empty", () => { + const sandbox = new SrtSandbox(defaultConfig) + const result = sandbox.wrapCommand("ls", "/home/user/project") + + expect(result).toContain("--bind=/home/user/project") + }) + + it("should use specified allowed paths instead of cwd", () => { + const config: SandboxConfig = { + ...defaultConfig, + allowedPaths: ["/opt/data", "/var/cache"], + } + const sandbox = new SrtSandbox(config) + const result = sandbox.wrapCommand("ls", "/home/user/project") + + expect(result).toContain("--bind=/opt/data") + expect(result).toContain("--bind=/var/cache") + // When allowedPaths is specified, cwd is NOT automatically added + expect(result).not.toContain("--bind=/home/user/project") + }) + + it("should include denied paths", () => { + const config: SandboxConfig = { + ...defaultConfig, + deniedPaths: ["/etc/passwd", "/root"], + } + const sandbox = new SrtSandbox(config) + const result = sandbox.wrapCommand("cat /etc/hosts", "/tmp") + + expect(result).toContain("--deny=/etc/passwd") + expect(result).toContain("--deny=/root") + }) + + it("should properly escape single quotes in commands", () => { + const sandbox = new SrtSandbox(defaultConfig) + const result = sandbox.wrapCommand("echo 'hello world'", "/tmp") + + // The command should be wrapped in single quotes with proper escaping + expect(result).toContain("-- sh -c") + expect(result).toContain("hello world") + }) + + it("should combine all options correctly", () => { + const config: SandboxConfig = { + enabled: true, + networkPolicy: "deny", + writePolicy: "deny", + allowedPaths: ["/workspace"], + deniedPaths: ["/secret"], + } + const sandbox = new SrtSandbox(config) + const result = sandbox.wrapCommand("make build", "/workspace") + + expect(result).toContain("srt exec") + expect(result).toContain("--net=none") + expect(result).toContain("--readonly") + expect(result).toContain("--bind=/workspace") + expect(result).toContain("--deny=/secret") + expect(result).toContain("--chdir=/workspace") + expect(result).toContain("-- sh -c") + }) + }) + + describe("isAvailable", () => { + it("should return true when srt is available", async () => { + const { execa } = await import("execa") + const mockExeca = vi.mocked(execa) + mockExeca.mockResolvedValueOnce(undefined as any) + + const sandbox = new SrtSandbox(defaultConfig) + const available = await sandbox.isAvailable() + + expect(available).toBe(true) + expect(mockExeca).toHaveBeenCalledWith("srt", ["--version"]) + }) + + it("should return false when srt is not available", async () => { + const { execa } = await import("execa") + const mockExeca = vi.mocked(execa) + mockExeca.mockRejectedValueOnce(new Error("command not found")) + + const sandbox = new SrtSandbox(defaultConfig) + const available = await sandbox.isAvailable() + + expect(available).toBe(false) + }) + + it("should cache the availability result", async () => { + const { execa } = await import("execa") + const mockExeca = vi.mocked(execa) + mockExeca.mockResolvedValueOnce(undefined as any) + + const sandbox = new SrtSandbox(defaultConfig) + await sandbox.isAvailable() + await sandbox.isAvailable() + + // Should only be called once due to caching + expect(mockExeca).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/integrations/terminal/sandbox/index.ts b/src/integrations/terminal/sandbox/index.ts new file mode 100644 index 00000000000..4de3a3af8f2 --- /dev/null +++ b/src/integrations/terminal/sandbox/index.ts @@ -0,0 +1,5 @@ +export { SandboxManager } from "./SandboxManager" +export { SrtSandbox } from "./SrtSandbox" +export { NoOpSandbox } from "./NoOpSandbox" +export type { CommandSandbox, SandboxConfig } from "./types" +export { DEFAULT_SANDBOX_CONFIG } from "./types" diff --git a/src/integrations/terminal/sandbox/types.ts b/src/integrations/terminal/sandbox/types.ts new file mode 100644 index 00000000000..7c24efb9464 --- /dev/null +++ b/src/integrations/terminal/sandbox/types.ts @@ -0,0 +1,42 @@ +/** + * Configuration for command sandboxing. + */ +export interface SandboxConfig { + /** Whether sandboxing is enabled */ + enabled: boolean + /** Network access policy */ + networkPolicy: "allow" | "deny" + /** Filesystem write policy */ + writePolicy: "allow" | "deny" + /** Paths that sandboxed commands can access */ + allowedPaths: string[] + /** Paths that sandboxed commands are denied access to */ + deniedPaths: string[] +} + +/** + * Interface for command sandbox implementations. + * A sandbox wraps commands to execute them in an isolated environment. + */ +export interface CommandSandbox { + /** + * Check if the sandbox provider is available on the system. + */ + isAvailable(): Promise + + /** + * Wrap a command string with sandbox isolation. + * @param command The original command to wrap + * @param cwd The working directory for the command + * @returns The wrapped command string + */ + wrapCommand(command: string, cwd: string): string +} + +export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { + enabled: false, + networkPolicy: "deny", + writePolicy: "allow", + allowedPaths: [], + deniedPaths: [], +} diff --git a/src/package.json b/src/package.json index 241312ac8c6..c5d5f176f10 100644 --- a/src/package.json +++ b/src/package.json @@ -342,6 +342,45 @@ "default": [], "description": "%commands.commandTimeoutAllowlist.description%" }, + "roo-cline.commandSandboxEnabled": { + "type": "boolean", + "default": false, + "description": "%commands.commandSandboxEnabled.description%" + }, + "roo-cline.commandSandboxNetworkPolicy": { + "type": "string", + "enum": [ + "allow", + "deny" + ], + "default": "deny", + "description": "%commands.commandSandboxNetworkPolicy.description%" + }, + "roo-cline.commandSandboxWritePolicy": { + "type": "string", + "enum": [ + "allow", + "deny" + ], + "default": "allow", + "description": "%commands.commandSandboxWritePolicy.description%" + }, + "roo-cline.commandSandboxAllowedPaths": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "%commands.commandSandboxAllowedPaths.description%" + }, + "roo-cline.commandSandboxDeniedPaths": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "%commands.commandSandboxDeniedPaths.description%" + }, "roo-cline.preventCompletionWithOpenTodos": { "type": "boolean", "default": false, diff --git a/src/package.nls.json b/src/package.nls.json index 177b392f775..0f425191cba 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -30,6 +30,11 @@ "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", "commands.commandExecutionTimeout.description": "Maximum time in seconds to wait for command execution to complete before timing out (0 = no timeout, 1-600s, default: 0s)", "commands.commandTimeoutAllowlist.description": "Command prefixes that are excluded from the command execution timeout. Commands matching these prefixes will run without timeout restrictions.", + "commands.commandSandboxEnabled.description": "Enable command sandboxing to run agent commands in an isolated environment using sandbox-runtime (srt). Requires the srt CLI tool to be installed.", + "commands.commandSandboxNetworkPolicy.description": "Network policy for sandboxed commands. 'allow' permits network access, 'deny' blocks all network access (default: deny).", + "commands.commandSandboxWritePolicy.description": "Filesystem write policy for sandboxed commands. 'allow' permits writes, 'deny' makes the filesystem read-only (default: allow).", + "commands.commandSandboxAllowedPaths.description": "Filesystem paths that sandboxed commands are allowed to access (read and write). If empty, the workspace directory is used.", + "commands.commandSandboxDeniedPaths.description": "Filesystem paths that sandboxed commands are explicitly denied access to.", "commands.preventCompletionWithOpenTodos.description": "Prevent task completion when there are incomplete todos in the todo list", "settings.vsCodeLmModelSelector.description": "Settings for VSCode Language Model API", "settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)",