feat: add configurable command sandboxing via sandbox-runtime (srt)

Adds support for running agent commands in an isolated environment
using Anthropic's sandbox-runtime `srt` CLI tool, addressing #11771.

Changes:
- Add sandbox configuration settings to package.json (commandSandboxEnabled,
  commandSandboxNetworkPolicy, commandSandboxWritePolicy,
  commandSandboxAllowedPaths, commandSandboxDeniedPaths)
- Create SandboxManager abstraction with SrtSandbox and NoOpSandbox
  implementations in src/integrations/terminal/sandbox/
- Integrate sandbox command wrapping into executeCommandInTerminal
- Add comprehensive tests (23 tests across 3 test files)
This commit is contained in:
Roo Code 2026-02-26 23:57:05 +00:00
parent 96d77fd2c8
commit 2ab88932b3
11 changed files with 648 additions and 1 deletions

View file

@ -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:

View file

@ -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<boolean> {
return true
}
wrapCommand(command: string, _cwd: string): string {
return command
}
}

View file

@ -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<boolean>("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<string[]>("commandSandboxAllowedPaths", DEFAULT_SANDBOX_CONFIG.allowedPaths),
deniedPaths: config.get<string[]>("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<boolean> {
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
}

View file

@ -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<boolean> {
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, "'\\''") + "'"
}

View file

@ -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)
})
})

View file

@ -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<string, any> = {}) {
const defaults: Record<string, any> = {
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")
})
})
})

View file

@ -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)
})
})
})

View file

@ -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"

View file

@ -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<boolean>
/**
* 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: [],
}

View file

@ -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,

View file

@ -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)",