feat(sandbox): add workspace access mode

This commit is contained in:
Peter Steinberger
2026-01-07 09:32:49 +00:00
parent 94d3a9742b
commit 0914517ee3
14 changed files with 229 additions and 55 deletions

View File

@@ -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: 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. - 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). - 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: 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. - 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. - Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.

View File

@@ -867,7 +867,10 @@ sessions so they cannot access your host system.
Defaults (if enabled): Defaults (if enabled):
- scope: `"agent"` (one container + workspace per agent) - scope: `"agent"` (one container + workspace per agent)
- Debian bookworm-slim based image - 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 - 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) - 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) - optional sandboxed browser (Chromium + CDP, noVNC observer)
@@ -885,6 +888,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
sandbox: { sandbox: {
mode: "non-main", // off | non-main | all mode: "non-main", // off | non-main | all
scope: "agent", // session | agent | shared (agent is default) scope: "agent", // session | agent | shared (agent is default)
workspaceAccess: "none", // none | ro | rw
workspaceRoot: "~/.clawdbot/sandboxes", workspaceRoot: "~/.clawdbot/sandboxes",
docker: { docker: {
image: "clawdbot-sandbox:bookworm-slim", 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` Note: sandbox containers default to `network: "none"`; set `agent.sandbox.docker.network`
to `"bridge"` (or your custom network) if the agent needs outbound access. 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: Build the optional browser image with:
```bash ```bash
scripts/sandbox-browser-setup.sh scripts/sandbox-browser-setup.sh

View File

@@ -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 or `"session"` for stricter per-session isolation. `scope: "shared"` uses a
single container/workspace. 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 dont enable it for strangers. Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and dont enable it for strangers.
## What to Tell Your AI ## What to Tell Your AI

View File

@@ -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: `"agent"` by default (one container + workspace per agent)
- scope: `"session"` for per-session isolation - scope: `"session"` for per-session isolation
- per-scope workspace folder mounted at `/workspace` - per-scope workspace folder mounted at `/workspace`
- optional agent workspace access (`agent.sandbox.workspaceAccess`)
- allow/deny tool policy (deny wins) - 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 Warning: `scope: "shared"` disables cross-session isolation. All sessions share
one container and one workspace. one container and one workspace.
@@ -89,7 +90,9 @@ one container and one workspace.
- Image: `clawdbot-sandbox:bookworm-slim` - Image: `clawdbot-sandbox:bookworm-slim`
- One container per agent - 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 - Auto-prune: idle > 24h OR age > 7d
- Network: `none` by default (explicitly opt-in if you need egress) - 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` - 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: { sandbox: {
mode: "non-main", // off | non-main | all mode: "non-main", // off | non-main | all
scope: "agent", // session | agent | shared (agent is default) scope: "agent", // session | agent | shared (agent is default)
workspaceAccess: "none", // none | ro | rw
workspaceRoot: "~/.clawdbot/sandboxes", workspaceRoot: "~/.clawdbot/sandboxes",
docker: { docker: {
image: "clawdbot-sandbox:bookworm-slim", image: "clawdbot-sandbox:bookworm-slim",

View File

@@ -132,6 +132,20 @@ describe("bash tool backgrounding", () => {
).rejects.toThrow("elevated is not available right now."); ).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 () => { it("logs line-based slices and defaults to last lines", async () => {
const result = await bashTool.execute("call1", { const result = await bashTool.execute("call1", {
command: command:

View File

@@ -165,10 +165,14 @@ export function createBashTool(
const sessionId = randomUUID(); const sessionId = randomUUID();
const warnings: string[] = []; const warnings: string[] = [];
const elevatedDefaults = defaults?.elevated; const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn =
elevatedDefaults?.defaultLevel === "on" &&
elevatedDefaults.enabled &&
elevatedDefaults.allowed;
const elevatedRequested = const elevatedRequested =
typeof params.elevated === "boolean" typeof params.elevated === "boolean"
? params.elevated ? params.elevated
: elevatedDefaults?.defaultLevel === "on"; : elevatedDefaultOn;
if (elevatedRequested) { if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
throw new Error("elevated is not available right now."); throw new Error("elevated is not available right now.");

View File

@@ -17,6 +17,8 @@ describe("buildEmbeddedSandboxInfo", () => {
enabled: true, enabled: true,
sessionKey: "session:test", sessionKey: "session:test",
workspaceDir: "/tmp/clawdbot-sandbox", workspaceDir: "/tmp/clawdbot-sandbox",
agentWorkspaceDir: "/tmp/clawdbot-workspace",
workspaceAccess: "none",
containerName: "clawdbot-sbx-test", containerName: "clawdbot-sbx-test",
containerWorkdir: "/workspace", containerWorkdir: "/workspace",
docker: { docker: {
@@ -44,6 +46,8 @@ describe("buildEmbeddedSandboxInfo", () => {
expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({ expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({
enabled: true, enabled: true,
workspaceDir: "/tmp/clawdbot-sandbox", workspaceDir: "/tmp/clawdbot-sandbox",
workspaceAccess: "none",
agentWorkspaceMount: undefined,
browserControlUrl: "http://localhost:9222", browserControlUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080", browserNoVncUrl: "http://localhost:6080",
}); });

View File

@@ -177,6 +177,8 @@ const isAbortError = (err: unknown): boolean => {
type EmbeddedSandboxInfo = { type EmbeddedSandboxInfo = {
enabled: boolean; enabled: boolean;
workspaceDir?: string; workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserControlUrl?: string; browserControlUrl?: string;
browserNoVncUrl?: string; browserNoVncUrl?: string;
}; };
@@ -249,6 +251,9 @@ export function buildEmbeddedSandboxInfo(
return { return {
enabled: true, enabled: true,
workspaceDir: sandbox.workspaceDir, workspaceDir: sandbox.workspaceDir,
workspaceAccess: sandbox.workspaceAccess,
agentWorkspaceMount:
sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
browserControlUrl: sandbox.browser?.controlUrl, browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl, browserNoVncUrl: sandbox.browser?.noVncUrl,
}; };
@@ -466,32 +471,38 @@ export async function compactEmbeddedPiSession(params: {
} }
await fs.mkdir(resolvedWorkspace, { recursive: true }); 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({ await ensureSessionHeader({
sessionFile: params.sessionFile, sessionFile: params.sessionFile,
sessionId: params.sessionId, sessionId: params.sessionId,
cwd: resolvedWorkspace, cwd: effectiveWorkspace,
}); });
let restoreSkillEnv: (() => void) | undefined; let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace); process.chdir(effectiveWorkspace);
try { try {
const shouldLoadSkillEntries = const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace) ? loadWorkspaceSkillEntries(effectiveWorkspace)
: []; : [];
const skillsSnapshot = const skillsSnapshot =
params.skillsSnapshot ?? params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, { buildWorkspaceSkillSnapshot(effectiveWorkspace, {
config: params.config, config: params.config,
entries: skillEntries, entries: skillEntries,
}); });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({ ? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot, snapshot: params.skillsSnapshot,
@@ -503,7 +514,7 @@ export async function compactEmbeddedPiSession(params: {
}); });
const bootstrapFiles = const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace); await loadWorkspaceBootstrapFiles(effectiveWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdbotCodingTools({ const tools = createClawdbotCodingTools({
@@ -533,7 +544,7 @@ export async function compactEmbeddedPiSession(params: {
const userTime = formatUserTime(new Date(), userTimezone); const userTime = formatUserTime(new Date(), userTimezone);
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({ appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace, workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel, defaultThinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt, extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
@@ -550,13 +561,13 @@ export async function compactEmbeddedPiSession(params: {
}), }),
contextFiles, contextFiles,
skills: promptSkills, skills: promptSkills,
cwd: resolvedWorkspace, cwd: effectiveWorkspace,
tools, tools,
}); });
const sessionManager = SessionManager.open(params.sessionFile); const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create( const settingsManager = SettingsManager.create(
resolvedWorkspace, effectiveWorkspace,
agentDir, agentDir,
); );
@@ -760,33 +771,38 @@ export async function runEmbeddedPiAgent(params: {
); );
await fs.mkdir(resolvedWorkspace, { recursive: true }); 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({ await ensureSessionHeader({
sessionFile: params.sessionFile, sessionFile: params.sessionFile,
sessionId: params.sessionId, sessionId: params.sessionId,
cwd: resolvedWorkspace, cwd: effectiveWorkspace,
}); });
let restoreSkillEnv: (() => void) | undefined; let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace); process.chdir(effectiveWorkspace);
try { try {
const shouldLoadSkillEntries = const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace) ? loadWorkspaceSkillEntries(effectiveWorkspace)
: []; : [];
const skillsSnapshot = const skillsSnapshot =
params.skillsSnapshot ?? params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, { buildWorkspaceSkillSnapshot(effectiveWorkspace, {
config: params.config, config: params.config,
entries: skillEntries, entries: skillEntries,
}); });
const sandboxSessionKey =
params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({ ? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot, snapshot: params.skillsSnapshot,
@@ -798,7 +814,7 @@ export async function runEmbeddedPiAgent(params: {
}); });
const bootstrapFiles = const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace); await loadWorkspaceBootstrapFiles(effectiveWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills( const promptSkills = resolvePromptSkills(
skillsSnapshot, skillsSnapshot,
@@ -833,7 +849,7 @@ export async function runEmbeddedPiAgent(params: {
const userTime = formatUserTime(new Date(), userTimezone); const userTime = formatUserTime(new Date(), userTimezone);
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({ appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace, workspaceDir: effectiveWorkspace,
defaultThinkLevel: thinkLevel, defaultThinkLevel: thinkLevel,
extraSystemPrompt: params.extraSystemPrompt, extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
@@ -850,13 +866,13 @@ export async function runEmbeddedPiAgent(params: {
}), }),
contextFiles, contextFiles,
skills: promptSkills, skills: promptSkills,
cwd: resolvedWorkspace, cwd: effectiveWorkspace,
tools, tools,
}); });
const sessionManager = SessionManager.open(params.sessionFile); const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create( const settingsManager = SettingsManager.create(
resolvedWorkspace, effectiveWorkspace,
agentDir, agentDir,
); );

View File

@@ -240,6 +240,8 @@ describe("createClawdbotCodingTools", () => {
enabled: true, enabled: true,
sessionKey: "sandbox:test", sessionKey: "sandbox:test",
workspaceDir: path.join(os.tmpdir(), "clawdbot-sandbox"), workspaceDir: path.join(os.tmpdir(), "clawdbot-sandbox"),
agentWorkspaceDir: path.join(os.tmpdir(), "clawdbot-workspace"),
workspaceAccess: "none",
containerName: "clawdbot-sbx-test", containerName: "clawdbot-sbx-test",
containerWorkdir: "/workspace", containerWorkdir: "/workspace",
docker: { docker: {
@@ -264,6 +266,37 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "browser")).toBe(false); 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", () => { it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({ const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } }, config: { agent: { tools: { deny: ["browser"] } } },

View File

@@ -526,6 +526,7 @@ export function createClawdbotCodingTools(options?: {
const bashToolName = "bash"; const bashToolName = "bash";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const sandboxRoot = sandbox?.workspaceDir; const sandboxRoot = sandbox?.workspaceDir;
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
if (tool.name === readTool.name) { if (tool.name === readTool.name) {
return sandboxRoot return sandboxRoot
@@ -555,10 +556,12 @@ export function createClawdbotCodingTools(options?: {
const tools: AnyAgentTool[] = [ const tools: AnyAgentTool[] = [
...base, ...base,
...(sandboxRoot ...(sandboxRoot
? [ ? allowWorkspaceWrites
createSandboxedEditTool(sandboxRoot), ? [
createSandboxedWriteTool(sandboxRoot), createSandboxedEditTool(sandboxRoot),
] createSandboxedWriteTool(sandboxRoot),
]
: []
: []), : []),
bashTool as unknown as AnyAgentTool, bashTool as unknown as AnyAgentTool,
processTool as unknown as AnyAgentTool, processTool as unknown as AnyAgentTool,

View File

@@ -23,6 +23,7 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME, DEFAULT_IDENTITY_FILENAME,
DEFAULT_SOUL_FILENAME, DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_FILENAME,
@@ -35,6 +36,8 @@ export type SandboxToolPolicy = {
deny?: string[]; deny?: string[];
}; };
export type SandboxWorkspaceAccess = "none" | "ro" | "rw";
export type SandboxBrowserConfig = { export type SandboxBrowserConfig = {
enabled: boolean; enabled: boolean;
image: string; image: string;
@@ -78,6 +81,7 @@ export type SandboxScope = "session" | "agent" | "shared";
export type SandboxConfig = { export type SandboxConfig = {
mode: "off" | "non-main" | "all"; mode: "off" | "non-main" | "all";
scope: SandboxScope; scope: SandboxScope;
workspaceAccess: SandboxWorkspaceAccess;
workspaceRoot: string; workspaceRoot: string;
docker: SandboxDockerConfig; docker: SandboxDockerConfig;
browser: SandboxBrowserConfig; browser: SandboxBrowserConfig;
@@ -95,6 +99,8 @@ export type SandboxContext = {
enabled: boolean; enabled: boolean;
sessionKey: string; sessionKey: string;
workspaceDir: string; workspaceDir: string;
agentWorkspaceDir: string;
workspaceAccess: SandboxWorkspaceAccess;
containerName: string; containerName: string;
containerWorkdir: string; containerWorkdir: string;
docker: SandboxDockerConfig; 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_CDP_PORT = 9222;
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; 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_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
@@ -227,6 +234,7 @@ function defaultSandboxConfig(cfg?: ClawdbotConfig): SandboxConfig {
scope: agent?.scope, scope: agent?.scope,
perSession: agent?.perSession, perSession: agent?.perSession,
}), }),
workspaceAccess: agent?.workspaceAccess ?? "none",
workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
docker: { docker: {
image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE, image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE,
@@ -474,6 +482,7 @@ async function ensureSandboxWorkspace(
DEFAULT_IDENTITY_FILENAME, DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME, DEFAULT_USER_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
]; ];
for (const name of files) { for (const name of files) {
const src = path.join(seed, name); const src = path.join(seed, name);
@@ -582,6 +591,8 @@ async function createSandboxContainer(params: {
name: string; name: string;
cfg: SandboxDockerConfig; cfg: SandboxDockerConfig;
workspaceDir: string; workspaceDir: string;
workspaceAccess: SandboxWorkspaceAccess;
agentWorkspaceDir: string;
scopeKey: string; scopeKey: string;
}) { }) {
const { name, cfg, workspaceDir, scopeKey } = params; const { name, cfg, workspaceDir, scopeKey } = params;
@@ -593,7 +604,21 @@ async function createSandboxContainer(params: {
scopeKey, scopeKey,
}); });
args.push("--workdir", cfg.workdir); 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"); args.push(cfg.image, "sleep", "infinity");
await execDocker(args); await execDocker(args);
@@ -607,6 +632,7 @@ async function createSandboxContainer(params: {
async function ensureSandboxContainer(params: { async function ensureSandboxContainer(params: {
sessionKey: string; sessionKey: string;
workspaceDir: string; workspaceDir: string;
agentWorkspaceDir: string;
cfg: SandboxConfig; cfg: SandboxConfig;
}) { }) {
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
@@ -620,6 +646,8 @@ async function ensureSandboxContainer(params: {
name: containerName, name: containerName,
cfg: params.cfg.docker, cfg: params.cfg.docker,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
workspaceAccess: params.cfg.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
scopeKey, scopeKey,
}); });
} else if (!state.running) { } else if (!state.running) {
@@ -675,6 +703,7 @@ function buildSandboxBrowserResolvedConfig(params: {
async function ensureSandboxBrowser(params: { async function ensureSandboxBrowser(params: {
scopeKey: string; scopeKey: string;
workspaceDir: string; workspaceDir: string;
agentWorkspaceDir: string;
cfg: SandboxConfig; cfg: SandboxConfig;
}): Promise<SandboxBrowserContext | null> { }): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) return null; if (!params.cfg.browser.enabled) return null;
@@ -695,7 +724,25 @@ async function ensureSandboxBrowser(params: {
scopeKey: params.scopeKey, scopeKey: params.scopeKey,
labels: { "clawdbot.sandboxBrowser": "1" }, 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}`); args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) { if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) {
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
@@ -883,29 +930,38 @@ export async function resolveSandboxContext(params: {
await maybePruneSandboxes(cfg); await maybePruneSandboxes(cfg);
const agentWorkspaceDir = resolveUserPath(
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR,
);
const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
const workspaceDir = const sandboxWorkspaceDir =
cfg.scope === "shared" cfg.scope === "shared"
? workspaceRoot ? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace = const workspaceDir =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir;
await ensureSandboxWorkspace( if (workspaceDir === sandboxWorkspaceDir) {
workspaceDir, await ensureSandboxWorkspace(
seedWorkspace, sandboxWorkspaceDir,
params.config?.agent?.skipBootstrap, agentWorkspaceDir,
); params.config?.agent?.skipBootstrap,
);
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
const containerName = await ensureSandboxContainer({ const containerName = await ensureSandboxContainer({
sessionKey: rawSessionKey, sessionKey: rawSessionKey,
workspaceDir, workspaceDir,
agentWorkspaceDir,
cfg, cfg,
}); });
const browser = await ensureSandboxBrowser({ const browser = await ensureSandboxBrowser({
scopeKey, scopeKey,
workspaceDir, workspaceDir,
agentWorkspaceDir,
cfg, cfg,
}); });
@@ -913,6 +969,8 @@ export async function resolveSandboxContext(params: {
enabled: true, enabled: true,
sessionKey: rawSessionKey, sessionKey: rawSessionKey,
workspaceDir, workspaceDir,
agentWorkspaceDir,
workspaceAccess: cfg.workspaceAccess,
containerName, containerName,
containerWorkdir: cfg.docker.workdir, containerWorkdir: cfg.docker.workdir,
docker: cfg.docker, docker: cfg.docker,
@@ -932,19 +990,26 @@ export async function ensureSandboxWorkspaceForSession(params: {
const mainKey = params.config?.session?.mainKey?.trim() || "main"; const mainKey = params.config?.session?.mainKey?.trim() || "main";
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
const agentWorkspaceDir = resolveUserPath(
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR,
);
const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
const workspaceDir = const sandboxWorkspaceDir =
cfg.scope === "shared" cfg.scope === "shared"
? workspaceRoot ? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace = const workspaceDir =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir;
await ensureSandboxWorkspace( if (workspaceDir === sandboxWorkspaceDir) {
workspaceDir, await ensureSandboxWorkspace(
seedWorkspace, sandboxWorkspaceDir,
params.config?.agent?.skipBootstrap, agentWorkspaceDir,
); params.config?.agent?.skipBootstrap,
);
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
return { return {
workspaceDir, workspaceDir,

View File

@@ -21,6 +21,8 @@ export function buildAgentSystemPromptAppend(params: {
sandboxInfo?: { sandboxInfo?: {
enabled: boolean; enabled: boolean;
workspaceDir?: string; workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserControlUrl?: string; browserControlUrl?: string;
browserNoVncUrl?: string; browserNoVncUrl?: string;
}; };
@@ -185,6 +187,13 @@ export function buildAgentSystemPromptAppend(params: {
params.sandboxInfo.workspaceDir params.sandboxInfo.workspaceDir
? `Sandbox workspace: ${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 params.sandboxInfo.browserControlUrl
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}` ? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
: "", : "",

View File

@@ -924,6 +924,13 @@ export type ClawdbotConfig = {
sandbox?: { sandbox?: {
/** Enable sandboxing for sessions. */ /** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all"; 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. * Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default) * - "spawned": only allow session tools to target sessions spawned from this session (default)

View File

@@ -579,6 +579,9 @@ export const ClawdbotSchema = z.object({
mode: z mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")]) .union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(), .optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")]) .union([z.literal("spawned"), z.literal("all")])
.optional(), .optional(),