diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e020e70..2e0fb65a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests. - Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs. - Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first). +- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`. - Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353. - Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts. - Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 42f69c8ff..bfdbe2478 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -867,7 +867,10 @@ sessions so they cannot access your host system. Defaults (if enabled): - scope: `"agent"` (one container + workspace per agent) - Debian bookworm-slim based image -- workspace per agent under `~/.clawdbot/sandboxes` +- agent workspace access: `workspaceAccess: "none"` (default) + - `"none"`: use a per-scope sandbox workspace under `~/.clawdbot/sandboxes` + - `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`) + - `"rw"`: mount the agent workspace read/write at `/workspace` - auto-prune: idle > 24h OR age > 7d - tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins) - optional sandboxed browser (Chromium + CDP, noVNC observer) @@ -885,6 +888,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`, sandbox: { mode: "non-main", // off | non-main | all scope: "agent", // session | agent | shared (agent is default) + workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.clawdbot/sandboxes", docker: { image: "clawdbot-sandbox:bookworm-slim", @@ -941,6 +945,8 @@ scripts/sandbox-setup.sh Note: sandbox containers default to `network: "none"`; set `agent.sandbox.docker.network` to `"bridge"` (or your custom network) if the agent needs outbound access. +Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace. + Build the optional browser image with: ```bash scripts/sandbox-browser-setup.sh diff --git a/docs/gateway/security.md b/docs/gateway/security.md index cbe943d32..d12dde53b 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -146,6 +146,11 @@ Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default) or `"session"` for stricter per-session isolation. `scope: "shared"` uses a single container/workspace. +Also consider agent workspace access inside the sandbox: +- `agent.sandbox.workspaceAccess: "none"` (default) keeps the agent workspace off-limits; tools run against a sandbox workspace under `~/.clawdbot/sandboxes` +- `workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`) +- `workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace` + Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers. ## What to Tell Your AI diff --git a/docs/install/docker.md b/docs/install/docker.md index 3a98b264d..ed06679e9 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -79,8 +79,9 @@ container. The gateway stays on your host, but the tool execution is isolated: - scope: `"agent"` by default (one container + workspace per agent) - scope: `"session"` for per-session isolation - per-scope workspace folder mounted at `/workspace` +- optional agent workspace access (`agent.sandbox.workspaceAccess`) - allow/deny tool policy (deny wins) -- inbound media is copied into the sandbox workspace (`media/inbound/*`) so tools can read it +- inbound media is copied into the active sandbox workspace (`media/inbound/*`) so tools can read it (with `workspaceAccess: "rw"`, this lands in the agent workspace) Warning: `scope: "shared"` disables cross-session isolation. All sessions share one container and one workspace. @@ -89,7 +90,9 @@ one container and one workspace. - Image: `clawdbot-sandbox:bookworm-slim` - One container per agent -- Workspace per agent under `~/.clawdbot/sandboxes` +- Agent workspace access: `workspaceAccess: "none"` (default) uses `~/.clawdbot/sandboxes` + - `"ro"` keeps the sandbox workspace at `/workspace` and mounts the agent workspace read-only at `/agent` (disables `write`/`edit`) + - `"rw"` mounts the agent workspace read/write at `/workspace` - Auto-prune: idle > 24h OR age > 7d - Network: `none` by default (explicitly opt-in if you need egress) - Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` @@ -103,6 +106,7 @@ one container and one workspace. sandbox: { mode: "non-main", // off | non-main | all scope: "agent", // session | agent | shared (agent is default) + workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.clawdbot/sandboxes", docker: { image: "clawdbot-sandbox:bookworm-slim", diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index defeaa5bd..7276803bb 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -132,6 +132,20 @@ describe("bash tool backgrounding", () => { ).rejects.toThrow("elevated is not available right now."); }); + it("does not default to elevated when not allowed", async () => { + const customBash = createBashTool({ + elevated: { enabled: true, allowed: false, defaultLevel: "on" }, + backgroundMs: 1000, + timeoutSec: 5, + }); + + const result = await customBash.execute("call1", { + command: "echo hi", + }); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text).toContain("hi"); + }); + it("logs line-based slices and defaults to last lines", async () => { const result = await bashTool.execute("call1", { command: diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index b8985756d..c380710a3 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -165,10 +165,14 @@ export function createBashTool( const sessionId = randomUUID(); const warnings: string[] = []; const elevatedDefaults = defaults?.elevated; + const elevatedDefaultOn = + elevatedDefaults?.defaultLevel === "on" && + elevatedDefaults.enabled && + elevatedDefaults.allowed; const elevatedRequested = typeof params.elevated === "boolean" ? params.elevated - : elevatedDefaults?.defaultLevel === "on"; + : elevatedDefaultOn; if (elevatedRequested) { if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { throw new Error("elevated is not available right now."); diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index 1ce3bc94d..ac5b75a76 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -17,6 +17,8 @@ describe("buildEmbeddedSandboxInfo", () => { enabled: true, sessionKey: "session:test", workspaceDir: "/tmp/clawdbot-sandbox", + agentWorkspaceDir: "/tmp/clawdbot-workspace", + workspaceAccess: "none", containerName: "clawdbot-sbx-test", containerWorkdir: "/workspace", docker: { @@ -44,6 +46,8 @@ describe("buildEmbeddedSandboxInfo", () => { expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({ enabled: true, workspaceDir: "/tmp/clawdbot-sandbox", + workspaceAccess: "none", + agentWorkspaceMount: undefined, browserControlUrl: "http://localhost:9222", browserNoVncUrl: "http://localhost:6080", }); diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 8720ee265..c0026884c 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -177,6 +177,8 @@ const isAbortError = (err: unknown): boolean => { type EmbeddedSandboxInfo = { enabled: boolean; workspaceDir?: string; + workspaceAccess?: "none" | "ro" | "rw"; + agentWorkspaceMount?: string; browserControlUrl?: string; browserNoVncUrl?: string; }; @@ -249,6 +251,9 @@ export function buildEmbeddedSandboxInfo( return { enabled: true, workspaceDir: sandbox.workspaceDir, + workspaceAccess: sandbox.workspaceAccess, + agentWorkspaceMount: + sandbox.workspaceAccess === "ro" ? "/agent" : undefined, browserControlUrl: sandbox.browser?.controlUrl, browserNoVncUrl: sandbox.browser?.noVncUrl, }; @@ -466,32 +471,38 @@ export async function compactEmbeddedPiSession(params: { } await fs.mkdir(resolvedWorkspace, { recursive: true }); + const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; + const sandbox = await resolveSandboxContext({ + config: params.config, + sessionKey: sandboxSessionKey, + workspaceDir: resolvedWorkspace, + }); + const effectiveWorkspace = sandbox?.enabled + ? sandbox.workspaceAccess === "rw" + ? resolvedWorkspace + : sandbox.workspaceDir + : resolvedWorkspace; + await fs.mkdir(effectiveWorkspace, { recursive: true }); await ensureSessionHeader({ sessionFile: params.sessionFile, sessionId: params.sessionId, - cwd: resolvedWorkspace, + cwd: effectiveWorkspace, }); let restoreSkillEnv: (() => void) | undefined; - process.chdir(resolvedWorkspace); + process.chdir(effectiveWorkspace); try { const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; const skillEntries = shouldLoadSkillEntries - ? loadWorkspaceSkillEntries(resolvedWorkspace) + ? loadWorkspaceSkillEntries(effectiveWorkspace) : []; const skillsSnapshot = params.skillsSnapshot ?? - buildWorkspaceSkillSnapshot(resolvedWorkspace, { + buildWorkspaceSkillSnapshot(effectiveWorkspace, { config: params.config, entries: skillEntries, }); - const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; - const sandbox = await resolveSandboxContext({ - config: params.config, - sessionKey: sandboxSessionKey, - workspaceDir: resolvedWorkspace, - }); restoreSkillEnv = params.skillsSnapshot ? applySkillEnvOverridesFromSnapshot({ snapshot: params.skillsSnapshot, @@ -503,7 +514,7 @@ export async function compactEmbeddedPiSession(params: { }); const bootstrapFiles = - await loadWorkspaceBootstrapFiles(resolvedWorkspace); + await loadWorkspaceBootstrapFiles(effectiveWorkspace); const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); const tools = createClawdbotCodingTools({ @@ -533,7 +544,7 @@ export async function compactEmbeddedPiSession(params: { const userTime = formatUserTime(new Date(), userTimezone); const systemPrompt = buildSystemPrompt({ appendPrompt: buildAgentSystemPromptAppend({ - workspaceDir: resolvedWorkspace, + workspaceDir: effectiveWorkspace, defaultThinkLevel: params.thinkLevel, extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, @@ -550,13 +561,13 @@ export async function compactEmbeddedPiSession(params: { }), contextFiles, skills: promptSkills, - cwd: resolvedWorkspace, + cwd: effectiveWorkspace, tools, }); const sessionManager = SessionManager.open(params.sessionFile); const settingsManager = SettingsManager.create( - resolvedWorkspace, + effectiveWorkspace, agentDir, ); @@ -760,33 +771,38 @@ export async function runEmbeddedPiAgent(params: { ); await fs.mkdir(resolvedWorkspace, { recursive: true }); + const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; + const sandbox = await resolveSandboxContext({ + config: params.config, + sessionKey: sandboxSessionKey, + workspaceDir: resolvedWorkspace, + }); + const effectiveWorkspace = sandbox?.enabled + ? sandbox.workspaceAccess === "rw" + ? resolvedWorkspace + : sandbox.workspaceDir + : resolvedWorkspace; + await fs.mkdir(effectiveWorkspace, { recursive: true }); await ensureSessionHeader({ sessionFile: params.sessionFile, sessionId: params.sessionId, - cwd: resolvedWorkspace, + cwd: effectiveWorkspace, }); let restoreSkillEnv: (() => void) | undefined; - process.chdir(resolvedWorkspace); + process.chdir(effectiveWorkspace); try { const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; const skillEntries = shouldLoadSkillEntries - ? loadWorkspaceSkillEntries(resolvedWorkspace) + ? loadWorkspaceSkillEntries(effectiveWorkspace) : []; const skillsSnapshot = params.skillsSnapshot ?? - buildWorkspaceSkillSnapshot(resolvedWorkspace, { + buildWorkspaceSkillSnapshot(effectiveWorkspace, { config: params.config, entries: skillEntries, }); - const sandboxSessionKey = - params.sessionKey?.trim() || params.sessionId; - const sandbox = await resolveSandboxContext({ - config: params.config, - sessionKey: sandboxSessionKey, - workspaceDir: resolvedWorkspace, - }); restoreSkillEnv = params.skillsSnapshot ? applySkillEnvOverridesFromSnapshot({ snapshot: params.skillsSnapshot, @@ -798,7 +814,7 @@ export async function runEmbeddedPiAgent(params: { }); const bootstrapFiles = - await loadWorkspaceBootstrapFiles(resolvedWorkspace); + await loadWorkspaceBootstrapFiles(effectiveWorkspace); const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const promptSkills = resolvePromptSkills( skillsSnapshot, @@ -833,7 +849,7 @@ export async function runEmbeddedPiAgent(params: { const userTime = formatUserTime(new Date(), userTimezone); const systemPrompt = buildSystemPrompt({ appendPrompt: buildAgentSystemPromptAppend({ - workspaceDir: resolvedWorkspace, + workspaceDir: effectiveWorkspace, defaultThinkLevel: thinkLevel, extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, @@ -850,13 +866,13 @@ export async function runEmbeddedPiAgent(params: { }), contextFiles, skills: promptSkills, - cwd: resolvedWorkspace, + cwd: effectiveWorkspace, tools, }); const sessionManager = SessionManager.open(params.sessionFile); const settingsManager = SettingsManager.create( - resolvedWorkspace, + effectiveWorkspace, agentDir, ); diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 3c14b7519..566e85659 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -240,6 +240,8 @@ describe("createClawdbotCodingTools", () => { enabled: true, sessionKey: "sandbox:test", workspaceDir: path.join(os.tmpdir(), "clawdbot-sandbox"), + agentWorkspaceDir: path.join(os.tmpdir(), "clawdbot-workspace"), + workspaceAccess: "none", containerName: "clawdbot-sbx-test", containerWorkdir: "/workspace", docker: { @@ -264,6 +266,37 @@ describe("createClawdbotCodingTools", () => { expect(tools.some((tool) => tool.name === "browser")).toBe(false); }); + it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { + const sandbox = { + enabled: true, + sessionKey: "sandbox:test", + workspaceDir: path.join(os.tmpdir(), "clawdbot-sandbox"), + agentWorkspaceDir: path.join(os.tmpdir(), "clawdbot-workspace"), + workspaceAccess: "ro", + containerName: "clawdbot-sbx-test", + containerWorkdir: "/workspace", + docker: { + image: "clawdbot-sandbox:bookworm-slim", + containerPrefix: "clawdbot-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: [], + network: "none", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + tools: { + allow: ["read", "write", "edit"], + deny: [], + }, + }; + const tools = createClawdbotCodingTools({ sandbox }); + expect(tools.some((tool) => tool.name === "read")).toBe(true); + expect(tools.some((tool) => tool.name === "write")).toBe(false); + expect(tools.some((tool) => tool.name === "edit")).toBe(false); + }); + it("filters tools by agent tool policy even without sandbox", () => { const tools = createClawdbotCodingTools({ config: { agent: { tools: { deny: ["browser"] } } }, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 4dd201caa..baaadd0b8 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -526,6 +526,7 @@ export function createClawdbotCodingTools(options?: { const bashToolName = "bash"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const sandboxRoot = sandbox?.workspaceDir; + const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro"; const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { if (tool.name === readTool.name) { return sandboxRoot @@ -555,10 +556,12 @@ export function createClawdbotCodingTools(options?: { const tools: AnyAgentTool[] = [ ...base, ...(sandboxRoot - ? [ - createSandboxedEditTool(sandboxRoot), - createSandboxedWriteTool(sandboxRoot), - ] + ? allowWorkspaceWrites + ? [ + createSandboxedEditTool(sandboxRoot), + createSandboxedWriteTool(sandboxRoot), + ] + : [] : []), bashTool as unknown as AnyAgentTool, processTool as unknown as AnyAgentTool, diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index bf2b41056..d3134f04b 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -23,6 +23,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENTS_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_HEARTBEAT_FILENAME, DEFAULT_IDENTITY_FILENAME, DEFAULT_SOUL_FILENAME, DEFAULT_TOOLS_FILENAME, @@ -35,6 +36,8 @@ export type SandboxToolPolicy = { deny?: string[]; }; +export type SandboxWorkspaceAccess = "none" | "ro" | "rw"; + export type SandboxBrowserConfig = { enabled: boolean; image: string; @@ -78,6 +81,7 @@ export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { mode: "off" | "non-main" | "all"; scope: SandboxScope; + workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; docker: SandboxDockerConfig; browser: SandboxBrowserConfig; @@ -95,6 +99,8 @@ export type SandboxContext = { enabled: boolean; sessionKey: string; workspaceDir: string; + agentWorkspaceDir: string; + workspaceAccess: SandboxWorkspaceAccess; containerName: string; containerWorkdir: string; docker: SandboxDockerConfig; @@ -144,6 +150,7 @@ const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdbot-sbx-browser-"; const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; +const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent"; const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox"); const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); @@ -227,6 +234,7 @@ function defaultSandboxConfig(cfg?: ClawdbotConfig): SandboxConfig { scope: agent?.scope, perSession: agent?.perSession, }), + workspaceAccess: agent?.workspaceAccess ?? "none", workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, docker: { image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE, @@ -474,6 +482,7 @@ async function ensureSandboxWorkspace( DEFAULT_IDENTITY_FILENAME, DEFAULT_USER_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_HEARTBEAT_FILENAME, ]; for (const name of files) { const src = path.join(seed, name); @@ -582,6 +591,8 @@ async function createSandboxContainer(params: { name: string; cfg: SandboxDockerConfig; workspaceDir: string; + workspaceAccess: SandboxWorkspaceAccess; + agentWorkspaceDir: string; scopeKey: string; }) { const { name, cfg, workspaceDir, scopeKey } = params; @@ -593,7 +604,21 @@ async function createSandboxContainer(params: { scopeKey, }); args.push("--workdir", cfg.workdir); - args.push("-v", `${workspaceDir}:${cfg.workdir}`); + const mainMountSuffix = + params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir + ? ":ro" + : ""; + args.push("-v", `${workspaceDir}:${cfg.workdir}${mainMountSuffix}`); + if ( + params.workspaceAccess !== "none" && + workspaceDir !== params.agentWorkspaceDir + ) { + const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""; + args.push( + "-v", + `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, + ); + } args.push(cfg.image, "sleep", "infinity"); await execDocker(args); @@ -607,6 +632,7 @@ async function createSandboxContainer(params: { async function ensureSandboxContainer(params: { sessionKey: string; workspaceDir: string; + agentWorkspaceDir: string; cfg: SandboxConfig; }) { const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); @@ -620,6 +646,8 @@ async function ensureSandboxContainer(params: { name: containerName, cfg: params.cfg.docker, workspaceDir: params.workspaceDir, + workspaceAccess: params.cfg.workspaceAccess, + agentWorkspaceDir: params.agentWorkspaceDir, scopeKey, }); } else if (!state.running) { @@ -675,6 +703,7 @@ function buildSandboxBrowserResolvedConfig(params: { async function ensureSandboxBrowser(params: { scopeKey: string; workspaceDir: string; + agentWorkspaceDir: string; cfg: SandboxConfig; }): Promise { if (!params.cfg.browser.enabled) return null; @@ -695,7 +724,25 @@ async function ensureSandboxBrowser(params: { scopeKey: params.scopeKey, labels: { "clawdbot.sandboxBrowser": "1" }, }); - args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`); + const mainMountSuffix = + params.cfg.workspaceAccess === "ro" && + params.workspaceDir === params.agentWorkspaceDir + ? ":ro" + : ""; + args.push( + "-v", + `${params.workspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`, + ); + if ( + params.cfg.workspaceAccess !== "none" && + params.workspaceDir !== params.agentWorkspaceDir + ) { + const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""; + args.push( + "-v", + `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, + ); + } args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`); if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) { args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); @@ -883,29 +930,38 @@ export async function resolveSandboxContext(params: { await maybePruneSandboxes(cfg); + const agentWorkspaceDir = resolveUserPath( + params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR, + ); const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); - const workspaceDir = + const sandboxWorkspaceDir = cfg.scope === "shared" ? workspaceRoot : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); - const seedWorkspace = - params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; - await ensureSandboxWorkspace( - workspaceDir, - seedWorkspace, - params.config?.agent?.skipBootstrap, - ); + const workspaceDir = + cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir; + if (workspaceDir === sandboxWorkspaceDir) { + await ensureSandboxWorkspace( + sandboxWorkspaceDir, + agentWorkspaceDir, + params.config?.agent?.skipBootstrap, + ); + } else { + await fs.mkdir(workspaceDir, { recursive: true }); + } const containerName = await ensureSandboxContainer({ sessionKey: rawSessionKey, workspaceDir, + agentWorkspaceDir, cfg, }); const browser = await ensureSandboxBrowser({ scopeKey, workspaceDir, + agentWorkspaceDir, cfg, }); @@ -913,6 +969,8 @@ export async function resolveSandboxContext(params: { enabled: true, sessionKey: rawSessionKey, workspaceDir, + agentWorkspaceDir, + workspaceAccess: cfg.workspaceAccess, containerName, containerWorkdir: cfg.docker.workdir, docker: cfg.docker, @@ -932,19 +990,26 @@ export async function ensureSandboxWorkspaceForSession(params: { const mainKey = params.config?.session?.mainKey?.trim() || "main"; if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; + const agentWorkspaceDir = resolveUserPath( + params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR, + ); const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); - const workspaceDir = + const sandboxWorkspaceDir = cfg.scope === "shared" ? workspaceRoot : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); - const seedWorkspace = - params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; - await ensureSandboxWorkspace( - workspaceDir, - seedWorkspace, - params.config?.agent?.skipBootstrap, - ); + const workspaceDir = + cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir; + if (workspaceDir === sandboxWorkspaceDir) { + await ensureSandboxWorkspace( + sandboxWorkspaceDir, + agentWorkspaceDir, + params.config?.agent?.skipBootstrap, + ); + } else { + await fs.mkdir(workspaceDir, { recursive: true }); + } return { workspaceDir, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index fc811d880..72dfd9868 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -21,6 +21,8 @@ export function buildAgentSystemPromptAppend(params: { sandboxInfo?: { enabled: boolean; workspaceDir?: string; + workspaceAccess?: "none" | "ro" | "rw"; + agentWorkspaceMount?: string; browserControlUrl?: string; browserNoVncUrl?: string; }; @@ -185,6 +187,13 @@ export function buildAgentSystemPromptAppend(params: { params.sandboxInfo.workspaceDir ? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}` : "", + params.sandboxInfo.workspaceAccess + ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ + params.sandboxInfo.agentWorkspaceMount + ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})` + : "" + }` + : "", params.sandboxInfo.browserControlUrl ? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}` : "", diff --git a/src/config/types.ts b/src/config/types.ts index 75fd1a99d..9f8526420 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -924,6 +924,13 @@ export type ClawdbotConfig = { sandbox?: { /** Enable sandboxing for sessions. */ mode?: "off" | "non-main" | "all"; + /** + * Agent workspace access inside the sandbox. + * - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot + * - "ro": mount the agent workspace read-only; disables write/edit tools + * - "rw": mount the agent workspace read/write; enables write/edit tools + */ + workspaceAccess?: "none" | "ro" | "rw"; /** * Session tools visibility for sandboxed sessions. * - "spawned": only allow session tools to target sessions spawned from this session (default) diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 749d46401..be3a70fe3 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -579,6 +579,9 @@ export const ClawdbotSchema = z.object({ mode: z .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) .optional(), + workspaceAccess: z + .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) + .optional(), sessionToolsVisibility: z .union([z.literal("spawned"), z.literal("all")]) .optional(),