mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
96d77fd2c8
commit
2ab88932b3
11 changed files with 648 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
15
src/integrations/terminal/sandbox/NoOpSandbox.ts
Normal file
15
src/integrations/terminal/sandbox/NoOpSandbox.ts
Normal 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
|
||||
}
|
||||
}
|
||||
128
src/integrations/terminal/sandbox/SandboxManager.ts
Normal file
128
src/integrations/terminal/sandbox/SandboxManager.ts
Normal 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
|
||||
}
|
||||
99
src/integrations/terminal/sandbox/SrtSandbox.ts
Normal file
99
src/integrations/terminal/sandbox/SrtSandbox.ts
Normal 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, "'\\''") + "'"
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
159
src/integrations/terminal/sandbox/__tests__/SrtSandbox.spec.ts
Normal file
159
src/integrations/terminal/sandbox/__tests__/SrtSandbox.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
5
src/integrations/terminal/sandbox/index.ts
Normal file
5
src/integrations/terminal/sandbox/index.ts
Normal 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"
|
||||
42
src/integrations/terminal/sandbox/types.ts
Normal file
42
src/integrations/terminal/sandbox/types.ts
Normal 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: [],
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue