Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/core/tools/ExecuteCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions src/integrations/terminal/sandbox/NoOpSandbox.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
128 changes: 128 additions & 0 deletions src/integrations/terminal/sandbox/SandboxManager.ts
Original file line number Diff line number Diff line change
@@ -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
}
99 changes: 99 additions & 0 deletions src/integrations/terminal/sandbox/SrtSandbox.ts
Original file line number Diff line number Diff line change
@@ -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, "'\\''") + "'"
}
14 changes: 14 additions & 0 deletions src/integrations/terminal/sandbox/__tests__/NoOpSandbox.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading