diff --git a/CHANGELOG.md b/CHANGELOG.md index 416eeb69b..ac19573fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Config: expose schema + UI hints for generic config forms (Web UI + future clients). - Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. - Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow. +- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. ### Fixes - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. @@ -53,6 +54,7 @@ - Gateway: document config hot reload + reload matrix. - Onboarding/Config: add protocol notes for wizard + schema RPC. - Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. +- Sandbox: document per-session agent sandbox setup, config, and Docker build. ## 2.0.0-beta5 — 2026-01-03 diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox new file mode 100644 index 000000000..dec3f32d1 --- /dev/null +++ b/Dockerfile.sandbox @@ -0,0 +1,16 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + python3 \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +CMD ["sleep", "infinity"] diff --git a/docs/configuration.md b/docs/configuration.md index 475bcf76f..8f1ba315c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,6 +33,11 @@ better forms without hard-coding config knowledge. } ``` +Build the default image once with: +```bash +scripts/sandbox-setup.sh +``` + ## Self-chat mode (recommended for group control) To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers): @@ -323,6 +328,9 @@ Default: `~/clawd`. } ``` +If `agent.sandbox` is enabled, non-main sessions can override this with their +own per-session workspaces under `agent.sandbox.workspaceRoot`. + ### `messages` Controls inbound/outbound prefixes and timestamps. @@ -435,6 +443,50 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require execute in parallel across sessions. Each session is still serialized (one run per session key at a time). Default: 1. +### `agent.sandbox` + +Optional per-session **Docker sandboxing** for the embedded agent. Intended for +non-main sessions so they cannot access your host system. + +Defaults (if enabled): +- one container per session +- Debian bookworm-slim based image +- workspace per session under `~/.clawdis/sandboxes` +- auto-prune: idle > 24h OR age > 7d +- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins) + +```json5 +{ + agent: { + sandbox: { + mode: "non-main", // off | non-main | all + perSession: true, + workspaceRoot: "~/.clawdis/sandboxes", + docker: { + image: "clawdis-sandbox:bookworm-slim", + containerPrefix: "clawdis-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "bridge", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + setupCommand: "apt-get update && apt-get install -y git curl jq" + }, + tools: { + allow: ["bash", "process", "read", "write", "edit"], + deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] + }, + prune: { + idleHours: 24, // 0 disables idle pruning + maxAgeDays: 7 // 0 disables max-age pruning + } + } + } +} +``` + ### `models` (custom providers + base URLs) Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers diff --git a/docs/docker.md b/docs/docker.md index e5d544190..2b48f70ec 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -9,16 +9,27 @@ read_when: Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow. -## Quick start (recommended) +This guide covers: +- Containerized Gateway (full Clawdis in Docker) +- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools) -From the repo root: +## Requirements + +- Docker Desktop (or Docker Engine) + Docker Compose v2 +- Enough disk for images + logs + +## Containerized Gateway (Docker Compose) + +### Quick start (recommended) + +From repo root: ```bash ./docker-setup.sh ``` This script: -- builds the image +- builds the gateway image - runs the onboarding wizard - runs WhatsApp login - starts the gateway via Docker Compose @@ -27,7 +38,7 @@ It writes config/workspace on the host: - `~/.clawdis/` - `~/clawd` -## Manual flow (compose) +### Manual flow (compose) ```bash docker build -t clawdis:local -f Dockerfile . @@ -36,14 +47,126 @@ docker compose run --rm clawdis-cli login docker compose up -d clawdis-gateway ``` -## E2E smoke test (Docker) +### Health check + +```bash +docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN" +``` + +### E2E smoke test (Docker) ```bash scripts/e2e/onboard-docker.sh ``` -## Notes +### Notes - Gateway bind defaults to `lan` for container use. -- Health check: - `docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"` +- The gateway container is the source of truth for sessions (`~/.clawdis/sessions`). + +## Per-session Agent Sandbox (host gateway + Docker tools) + +### What it does + +When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker +container. The gateway stays on your host, but the tool execution is isolated: +- one container per session (hard wall) +- per-session workspace folder mounted at `/workspace` +- allow/deny tool policy (deny wins) + +### Default behavior + +- Image: `clawdis-sandbox:bookworm-slim` +- One container per session +- Workspace per session under `~/.clawdis/sandboxes` +- Auto-prune: idle > 24h OR age > 7d +- Default allow: `bash`, `process`, `read`, `write`, `edit` +- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway` + +### Enable sandboxing + +```json5 +{ + agent: { + sandbox: { + mode: "non-main", // off | non-main | all + perSession: true, + workspaceRoot: "~/.clawdis/sandboxes", + docker: { + image: "clawdis-sandbox:bookworm-slim", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "bridge", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + setupCommand: "apt-get update && apt-get install -y git curl jq" + }, + tools: { + allow: ["bash", "process", "read", "write", "edit"], + deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] + }, + prune: { + idleHours: 24, // 0 disables idle pruning + maxAgeDays: 7 // 0 disables max-age pruning + } + } + } +} +``` + +### Build the default sandbox image + +```bash +scripts/sandbox-setup.sh +``` + +This builds `clawdis-sandbox:bookworm-slim` using `Dockerfile.sandbox`. + +### Custom sandbox image + +Build your own image and point config to it: + +```bash +docker build -t my-clawdis-sbx -f Dockerfile.sandbox . +``` + +```json5 +{ + agent: { + sandbox: { docker: { image: "my-clawdis-sbx" } } + } +} +``` + +### Tool policy (allow/deny) + +- `deny` wins over `allow`. +- If `allow` is empty: all tools (except deny) are available. +- If `allow` is non-empty: only tools in `allow` are available (minus deny). + +### Pruning strategy + +Two knobs: +- `prune.idleHours`: remove containers not used in X hours (0 = disable) +- `prune.maxAgeDays`: remove containers older than X days (0 = disable) + +Example: +- Keep busy sessions but cap lifetime: + `idleHours: 24`, `maxAgeDays: 7` +- Never prune: + `idleHours: 0`, `maxAgeDays: 0` + +### Security notes + +- Hard wall only applies to **tools** (bash/read/write/edit). +- Host-only tools like browser/camera/canvas are blocked by default. +- Allowing `browser` in sandbox **breaks isolation** (browser runs on host). + +## Troubleshooting + +- Image missing: build with `scripts/sandbox-setup.sh` or set `agent.sandbox.docker.image`. +- Container not running: it will auto-create per session on demand. +- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your + mounted workspace ownership (or chown the workspace folder). diff --git a/docs/security.md b/docs/security.md index 81b961666..a4939ba62 100644 --- a/docs/security.md +++ b/docs/security.md @@ -99,6 +99,12 @@ services: network_mode: bridge # Limited network ``` +### Per-session sandbox (Clawdis-native) + +Clawdis can also run **non-main sessions** inside per-session Docker containers +(`agent.sandbox`). This keeps the gateway on your host while isolating agent +tools in a hard wall container. See `docs/configuration.md` for the full config. + Expose only the services your AI needs: - ✅ GoWA API (for WhatsApp) - ✅ Specific HTTP APIs diff --git a/scripts/sandbox-setup.sh b/scripts/sandbox-setup.sh new file mode 100755 index 000000000..4cbe2171b --- /dev/null +++ b/scripts/sandbox-setup.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME="clawdis-sandbox:bookworm-slim" + +docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox . +echo "Built ${IMAGE_NAME}" diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index fba5abfef..d49804731 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -1,6 +1,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, statSync } from "node:fs"; +import fs from "node:fs/promises"; import { homedir } from "node:os"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; @@ -18,6 +19,7 @@ import { markExited, setJobTtlMs, } from "./bash-process-registry.js"; +import { assertSandboxPath } from "./sandbox-paths.js"; import { getShellConfig, killProcessTree, @@ -47,12 +49,20 @@ const stringEnum = ( export type BashToolDefaults = { backgroundMs?: number; timeoutSec?: number; + sandbox?: BashSandboxConfig; }; export type ProcessToolDefaults = { cleanupMs?: number; }; +export type BashSandboxConfig = { + containerName: string; + workspaceDir: string; + containerWorkdir: string; + env?: Record; +}; + const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), workdir: Type.Optional( @@ -136,19 +146,55 @@ export function createBashTool( const startedAt = Date.now(); const sessionId = randomUUID(); const warnings: string[] = []; - const workdir = resolveWorkdir( - params.workdir?.trim() || process.cwd(), - warnings, - ); + const sandbox = defaults?.sandbox; + const rawWorkdir = params.workdir?.trim() || process.cwd(); + let workdir = rawWorkdir; + let containerWorkdir = sandbox?.containerWorkdir; + if (sandbox) { + const resolved = await resolveSandboxWorkdir({ + workdir: rawWorkdir, + sandbox, + warnings, + }); + workdir = resolved.hostWorkdir; + containerWorkdir = resolved.containerWorkdir; + } else { + workdir = resolveWorkdir(rawWorkdir, warnings); + } const { shell, args: shellArgs } = getShellConfig(); - const env = params.env ? { ...process.env, ...params.env } : process.env; - const child = spawn(shell, [...shellArgs, params.command], { - cwd: workdir, - env, - detached: true, - stdio: ["pipe", "pipe", "pipe"], - }); + const baseEnv = coerceEnv(process.env); + const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv; + const env = sandbox + ? buildSandboxEnv({ + paramsEnv: params.env, + sandboxEnv: sandbox.env, + containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, + }) + : mergedEnv; + const child = sandbox + ? spawn( + "docker", + buildDockerExecArgs({ + containerName: sandbox.containerName, + command: params.command, + workdir: containerWorkdir ?? sandbox.containerWorkdir, + env, + tty: false, + }), + { + cwd: workdir, + env: process.env, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }, + ) + : spawn(shell, [...shellArgs, params.command], { + cwd: workdir, + env, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }); const session = { id: sessionId, @@ -776,6 +822,86 @@ export function createProcessTool( export const processTool = createProcessTool(); +function buildSandboxEnv(params: { + paramsEnv?: Record; + sandboxEnv?: Record; + containerWorkdir: string; +}) { + const env: Record = { + PATH: DEFAULT_PATH, + HOME: params.containerWorkdir, + }; + for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) { + env[key] = value; + } + for (const [key, value] of Object.entries(params.paramsEnv ?? {})) { + env[key] = value; + } + return env; +} + +function coerceEnv(env?: NodeJS.ProcessEnv | Record) { + const record: Record = {}; + if (!env) return record; + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") record[key] = value; + } + return record; +} + +function buildDockerExecArgs(params: { + containerName: string; + command: string; + workdir?: string; + env: Record; + tty: boolean; +}) { + const args = ["exec", "-i"]; + if (params.tty) args.push("-t"); + if (params.workdir) { + args.push("-w", params.workdir); + } + for (const [key, value] of Object.entries(params.env)) { + args.push("-e", `${key}=${value}`); + } + args.push(params.containerName, "sh", "-lc", params.command); + return args; +} + +async function resolveSandboxWorkdir(params: { + workdir: string; + sandbox: BashSandboxConfig; + warnings: string[]; +}) { + const fallback = params.sandbox.workspaceDir; + try { + const resolved = await assertSandboxPath({ + filePath: params.workdir, + cwd: process.cwd(), + root: params.sandbox.workspaceDir, + }); + const stats = await fs.stat(resolved.resolved); + if (!stats.isDirectory()) { + throw new Error("workdir is not a directory"); + } + const relative = resolved.relative + ? resolved.relative.split(path.sep).join(path.posix.sep) + : ""; + const containerWorkdir = relative + ? path.posix.join(params.sandbox.containerWorkdir, relative) + : params.sandbox.containerWorkdir; + return { hostWorkdir: resolved.resolved, containerWorkdir }; + } catch { + params.warnings.push( + `Warning: workdir "${params.workdir}" is unavailable; using "${fallback}".`, + ); + return { + hostWorkdir: fallback, + containerWorkdir: params.sandbox.containerWorkdir, + }; + } +} + function killSession(session: { pid?: number; child?: ChildProcessWithoutNullStreams; diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 35b849ccb..e8adf0d37 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -158,4 +158,33 @@ describe("createClawdisCodingTools", () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it("filters tools by sandbox policy", () => { + const sandbox = { + enabled: true, + sessionKey: "sandbox:test", + workspaceDir: path.join(os.tmpdir(), "clawdis-sandbox"), + containerName: "clawdis-sbx-test", + containerWorkdir: "/workspace", + docker: { + image: "clawdis-sandbox:bookworm-slim", + containerPrefix: "clawdis-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: [], + network: "none", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + tools: { + allow: ["bash"], + deny: ["browser"], + }, + }; + const tools = createClawdisCodingTools({ sandbox }); + expect(tools.some((tool) => tool.name === "bash")).toBe(true); + expect(tools.some((tool) => tool.name === "read")).toBe(false); + expect(tools.some((tool) => tool.name === "browser")).toBe(false); + }); }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index b64b45927..26271f5d6 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -1,5 +1,11 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; -import { codingTools, readTool } from "@mariozechner/pi-coding-agent"; +import { + codingTools, + createEditTool, + createReadTool, + createWriteTool, + readTool, +} from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { detectMime } from "../media/mime.js"; @@ -11,6 +17,8 @@ import { type ProcessToolDefaults, } from "./bash-tools.js"; import { createClawdisTools } from "./clawdis-tools.js"; +import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js"; +import { assertSandboxPath } from "./sandbox-paths.js"; import { sanitizeToolResultImages } from "./tool-images.js"; // NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper @@ -284,6 +292,59 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool { }; } +function normalizeToolNames(list?: string[]) { + if (!list) return []; + return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean); +} + +function filterToolsByPolicy( + tools: AnyAgentTool[], + policy?: SandboxToolPolicy, +) { + if (!policy) return tools; + const deny = new Set(normalizeToolNames(policy.deny)); + const allowRaw = normalizeToolNames(policy.allow); + const allow = allowRaw.length > 0 ? new Set(allowRaw) : null; + return tools.filter((tool) => { + const name = tool.name.toLowerCase(); + if (deny.has(name)) return false; + if (allow) return allow.has(name); + return true; + }); +} + +function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool { + return { + ...tool, + execute: async (toolCallId, args, signal, onUpdate) => { + const record = + args && typeof args === "object" + ? (args as Record) + : undefined; + const filePath = record?.path; + if (typeof filePath === "string" && filePath.trim()) { + await assertSandboxPath({ filePath, cwd: root, root }); + } + return tool.execute(toolCallId, args, signal, onUpdate); + }, + }; +} + +function createSandboxedReadTool(root: string) { + const base = createReadTool(root); + return wrapSandboxPathGuard(createClawdisReadTool(base), root); +} + +function createSandboxedWriteTool(root: string) { + const base = createWriteTool(root); + return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root); +} + +function createSandboxedEditTool(root: string) { + const base = createEditTool(root); + return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root); +} + function createWhatsAppLoginTool(): AnyAgentTool { return { label: "WhatsApp Login", @@ -383,19 +444,45 @@ function shouldIncludeDiscordTool(surface?: string): boolean { export function createClawdisCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; surface?: string; + sandbox?: SandboxContext | null; }): AnyAgentTool[] { const bashToolName = "bash"; + const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; + const sandboxRoot = sandbox?.workspaceDir; const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { - if (tool.name === readTool.name) return [createClawdisReadTool(tool)]; + if (tool.name === readTool.name) { + return sandboxRoot + ? [createSandboxedReadTool(sandboxRoot)] + : [createClawdisReadTool(tool)]; + } if (tool.name === bashToolName) return []; + if (sandboxRoot && (tool.name === "write" || tool.name === "edit")) { + return []; + } return [tool as AnyAgentTool]; }); - const bashTool = createBashTool(options?.bash); + const bashTool = createBashTool({ + ...options?.bash, + sandbox: sandbox + ? { + containerName: sandbox.containerName, + workspaceDir: sandbox.workspaceDir, + containerWorkdir: sandbox.containerWorkdir, + env: sandbox.docker.env, + } + : undefined, + }); const processTool = createProcessTool({ cleanupMs: options?.bash?.cleanupMs, }); const tools: AnyAgentTool[] = [ ...base, + ...(sandboxRoot + ? [ + createSandboxedEditTool(sandboxRoot), + createSandboxedWriteTool(sandboxRoot), + ] + : []), bashTool as unknown as AnyAgentTool, processTool as unknown as AnyAgentTool, createWhatsAppLoginTool(), @@ -405,5 +492,8 @@ export function createClawdisCodingTools(options?: { const filtered = allowDiscord ? tools : tools.filter((tool) => tool.name !== "discord"); - return filtered.map(normalizeToolParameters); + const sandboxed = sandbox + ? filterToolsByPolicy(filtered, sandbox.tools) + : filtered; + return sandboxed.map(normalizeToolParameters); } diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts new file mode 100644 index 000000000..40c0522c1 --- /dev/null +++ b/src/agents/sandbox-paths.ts @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +function normalizeUnicodeSpaces(str: string): string { + return str.replace(UNICODE_SPACES, " "); +} + +function expandPath(filePath: string): string { + const normalized = normalizeUnicodeSpaces(filePath); + if (normalized === "~") { + return os.homedir(); + } + if (normalized.startsWith("~/")) { + return os.homedir() + normalized.slice(1); + } + return normalized; +} + +function resolveToCwd(filePath: string, cwd: string): string { + const expanded = expandPath(filePath); + if (path.isAbsolute(expanded)) return expanded; + return path.resolve(cwd, expanded); +} + +export function resolveSandboxPath(params: { + filePath: string; + cwd: string; + root: string; +}): { resolved: string; relative: string } { + const resolved = resolveToCwd(params.filePath, params.cwd); + const rootResolved = path.resolve(params.root); + const relative = path.relative(rootResolved, resolved); + if (!relative || relative === "") { + return { resolved, relative: "" }; + } + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error( + `Path escapes sandbox root (${shortPath(rootResolved)}): ${params.filePath}`, + ); + } + return { resolved, relative }; +} + +export async function assertSandboxPath(params: { + filePath: string; + cwd: string; + root: string; +}) { + const resolved = resolveSandboxPath(params); + await assertNoSymlink(resolved.relative, path.resolve(params.root)); + return resolved; +} + +async function assertNoSymlink(relative: string, root: string) { + if (!relative) return; + const parts = relative.split(path.sep).filter(Boolean); + let current = root; + for (const part of parts) { + current = path.join(current, part); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + throw new Error(`Symlink not allowed in sandbox path: ${current}`); + } + } catch (err) { + const anyErr = err as { code?: string }; + if (anyErr.code === "ENOENT") { + return; + } + throw err; + } + } +} + +function shortPath(value: string) { + if (value.startsWith(os.homedir())) { + return `~${value.slice(os.homedir().length)}`; + } + return value; +} diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts new file mode 100644 index 000000000..8869318f9 --- /dev/null +++ b/src/agents/sandbox.ts @@ -0,0 +1,438 @@ +import { spawn } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { ClawdisConfig } from "../config/config.js"; +import { STATE_DIR_CLAWDIS } from "../config/config.js"; +import { defaultRuntime } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; +import { + DEFAULT_AGENT_WORKSPACE_DIR, + DEFAULT_AGENTS_FILENAME, + DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_USER_FILENAME, + ensureAgentWorkspace, +} from "./workspace.js"; + +export type SandboxToolPolicy = { + allow?: string[]; + deny?: string[]; +}; + +export type SandboxDockerConfig = { + image: string; + containerPrefix: string; + workdir: string; + readOnlyRoot: boolean; + tmpfs: string[]; + network: string; + user?: string; + capDrop: string[]; + env?: Record; + setupCommand?: string; +}; + +export type SandboxPruneConfig = { + idleHours: number; + maxAgeDays: number; +}; + +export type SandboxConfig = { + mode: "off" | "non-main" | "all"; + perSession: boolean; + workspaceRoot: string; + docker: SandboxDockerConfig; + tools: SandboxToolPolicy; + prune: SandboxPruneConfig; +}; + +export type SandboxContext = { + enabled: boolean; + sessionKey: string; + workspaceDir: string; + containerName: string; + containerWorkdir: string; + docker: SandboxDockerConfig; + tools: SandboxToolPolicy; +}; + +const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join( + os.homedir(), + ".clawdis", + "sandboxes", +); +const DEFAULT_SANDBOX_IMAGE = "clawdis-sandbox:bookworm-slim"; +const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdis-sbx-"; +const DEFAULT_SANDBOX_WORKDIR = "/workspace"; +const DEFAULT_SANDBOX_IDLE_HOURS = 24; +const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7; +const DEFAULT_TOOL_ALLOW = ["bash", "process", "read", "write", "edit"]; +const DEFAULT_TOOL_DENY = [ + "browser", + "canvas", + "nodes", + "cron", + "discord", + "gateway", +]; + +const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDIS, "sandbox"); +const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); + +type SandboxRegistryEntry = { + containerName: string; + sessionKey: string; + createdAtMs: number; + lastUsedAtMs: number; + image: string; +}; + +type SandboxRegistry = { + entries: SandboxRegistryEntry[]; +}; + +let lastPruneAtMs = 0; + +function defaultSandboxConfig(cfg?: ClawdisConfig): SandboxConfig { + const agent = cfg?.agent?.sandbox; + return { + mode: agent?.mode ?? "off", + perSession: agent?.perSession ?? true, + workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, + docker: { + image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE, + containerPrefix: + agent?.docker?.containerPrefix ?? DEFAULT_SANDBOX_CONTAINER_PREFIX, + workdir: agent?.docker?.workdir ?? DEFAULT_SANDBOX_WORKDIR, + readOnlyRoot: agent?.docker?.readOnlyRoot ?? true, + tmpfs: agent?.docker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"], + network: agent?.docker?.network ?? "bridge", + user: agent?.docker?.user, + capDrop: agent?.docker?.capDrop ?? ["ALL"], + env: agent?.docker?.env ?? { LANG: "C.UTF-8" }, + setupCommand: agent?.docker?.setupCommand, + }, + tools: { + allow: agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, + deny: agent?.tools?.deny ?? DEFAULT_TOOL_DENY, + }, + prune: { + idleHours: agent?.prune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS, + maxAgeDays: agent?.prune?.maxAgeDays ?? DEFAULT_SANDBOX_MAX_AGE_DAYS, + }, + }; +} + +function shouldSandboxSession( + cfg: SandboxConfig, + sessionKey: string, + mainKey: string, +) { + if (cfg.mode === "off") return false; + if (cfg.mode === "all") return true; + return sessionKey.trim() !== mainKey.trim(); +} + +function slugifySessionKey(value: string) { + const trimmed = value.trim() || "session"; + const hash = crypto + .createHash("sha1") + .update(trimmed) + .digest("hex") + .slice(0, 8); + const safe = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const base = safe.slice(0, 32) || "session"; + return `${base}-${hash}`; +} + +function resolveSandboxWorkspaceDir(root: string, sessionKey: string) { + const resolvedRoot = resolveUserPath(root); + const slug = slugifySessionKey(sessionKey); + return path.join(resolvedRoot, slug); +} + +async function readRegistry(): Promise { + try { + const raw = await fs.readFile(SANDBOX_REGISTRY_PATH, "utf-8"); + const parsed = JSON.parse(raw) as SandboxRegistry; + if (parsed && Array.isArray(parsed.entries)) return parsed; + } catch { + // ignore + } + return { entries: [] }; +} + +async function writeRegistry(registry: SandboxRegistry) { + await fs.mkdir(SANDBOX_STATE_DIR, { recursive: true }); + await fs.writeFile( + SANDBOX_REGISTRY_PATH, + `${JSON.stringify(registry, null, 2)}\n`, + "utf-8", + ); +} + +async function updateRegistry(entry: SandboxRegistryEntry) { + const registry = await readRegistry(); + const existing = registry.entries.find( + (item) => item.containerName === entry.containerName, + ); + const next = registry.entries.filter( + (item) => item.containerName !== entry.containerName, + ); + next.push({ + ...entry, + createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, + image: existing?.image ?? entry.image, + }); + await writeRegistry({ entries: next }); +} + +async function removeRegistryEntry(containerName: string) { + const registry = await readRegistry(); + const next = registry.entries.filter( + (item) => item.containerName !== containerName, + ); + if (next.length === registry.entries.length) return; + await writeRegistry({ entries: next }); +} + +function execDocker(args: string[], opts?: { allowFailure?: boolean }) { + return new Promise<{ stdout: string; stderr: string; code: number }>( + (resolve, reject) => { + const child = spawn("docker", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("close", (code) => { + const exitCode = code ?? 0; + if (exitCode !== 0 && !opts?.allowFailure) { + reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + }, + ); +} + +async function dockerImageExists(image: string) { + const result = await execDocker(["image", "inspect", image], { + allowFailure: true, + }); + return result.code === 0; +} + +async function ensureDockerImage(image: string) { + const exists = await dockerImageExists(image); + if (exists) return; + if (image === DEFAULT_SANDBOX_IMAGE) { + await execDocker(["pull", "debian:bookworm-slim"]); + await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]); + return; + } + throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`); +} + +async function dockerContainerState(name: string) { + const result = await execDocker( + ["inspect", "-f", "{{.State.Running}}", name], + { allowFailure: true }, + ); + if (result.code !== 0) return { exists: false, running: false }; + return { exists: true, running: result.stdout.trim() === "true" }; +} + +async function ensureSandboxWorkspace(workspaceDir: string, seedFrom?: string) { + await fs.mkdir(workspaceDir, { recursive: true }); + if (seedFrom) { + const seed = resolveUserPath(seedFrom); + const files = [ + DEFAULT_AGENTS_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_USER_FILENAME, + DEFAULT_BOOTSTRAP_FILENAME, + ]; + for (const name of files) { + const src = path.join(seed, name); + const dest = path.join(workspaceDir, name); + try { + await fs.access(dest); + } catch { + try { + const content = await fs.readFile(src, "utf-8"); + await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" }); + } catch { + // ignore missing seed file + } + } + } + } + await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: true }); +} + +async function createSandboxContainer(params: { + name: string; + cfg: SandboxDockerConfig; + workspaceDir: string; + sessionKey: string; +}) { + const { name, cfg, workspaceDir, sessionKey } = params; + await ensureDockerImage(cfg.image); + + const args = ["create", "--name", name]; + args.push("--label", "clawdis.sandbox=1"); + args.push("--label", `clawdis.sessionKey=${sessionKey}`); + args.push("--label", `clawdis.createdAtMs=${Date.now()}`); + if (cfg.readOnlyRoot) args.push("--read-only"); + for (const entry of cfg.tmpfs) { + args.push("--tmpfs", entry); + } + if (cfg.network) args.push("--network", cfg.network); + if (cfg.user) args.push("--user", cfg.user); + for (const cap of cfg.capDrop) { + args.push("--cap-drop", cap); + } + args.push("--security-opt", "no-new-privileges"); + args.push("--workdir", cfg.workdir); + args.push("-v", `${workspaceDir}:${cfg.workdir}`); + args.push(cfg.image, "sleep", "infinity"); + + await execDocker(args); + await execDocker(["start", name]); + + if (cfg.setupCommand?.trim()) { + await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]); + } +} + +async function ensureSandboxContainer(params: { + sessionKey: string; + workspaceDir: string; + cfg: SandboxConfig; +}) { + const slug = params.cfg.perSession + ? slugifySessionKey(params.sessionKey) + : "shared"; + const name = `${params.cfg.docker.containerPrefix}${slug}`; + const containerName = name.slice(0, 63); + const state = await dockerContainerState(containerName); + if (!state.exists) { + await createSandboxContainer({ + name: containerName, + cfg: params.cfg.docker, + workspaceDir: params.workspaceDir, + sessionKey: params.sessionKey, + }); + } else if (!state.running) { + await execDocker(["start", containerName]); + } + const now = Date.now(); + await updateRegistry({ + containerName, + sessionKey: params.sessionKey, + createdAtMs: now, + lastUsedAtMs: now, + image: params.cfg.docker.image, + }); + return containerName; +} + +async function pruneSandboxContainers(cfg: SandboxConfig) { + const now = Date.now(); + const idleHours = cfg.prune.idleHours; + const maxAgeDays = cfg.prune.maxAgeDays; + if (idleHours === 0 && maxAgeDays === 0) return; + const registry = await readRegistry(); + for (const entry of registry.entries) { + const idleMs = now - entry.lastUsedAtMs; + const ageMs = now - entry.createdAtMs; + if ( + (idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) || + (maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000) + ) { + try { + await execDocker(["rm", "-f", entry.containerName], { + allowFailure: true, + }); + } catch { + // ignore prune failures + } finally { + await removeRegistryEntry(entry.containerName); + } + } + } +} + +async function maybePruneSandboxes(cfg: SandboxConfig) { + const now = Date.now(); + if (now - lastPruneAtMs < 5 * 60 * 1000) return; + lastPruneAtMs = now; + try { + await pruneSandboxContainers(cfg); + } catch (error) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : JSON.stringify(error); + defaultRuntime.error?.( + `Sandbox prune failed: ${message ?? "unknown error"}`, + ); + } +} + +export async function resolveSandboxContext(params: { + config?: ClawdisConfig; + sessionKey?: string; + workspaceDir?: string; +}): Promise { + const rawSessionKey = params.sessionKey?.trim(); + if (!rawSessionKey) return null; + const cfg = defaultSandboxConfig(params.config); + const mainKey = params.config?.session?.mainKey?.trim() || "main"; + if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; + + await maybePruneSandboxes(cfg); + + const workspaceRoot = resolveUserPath(cfg.workspaceRoot); + const workspaceDir = cfg.perSession + ? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey) + : workspaceRoot; + const seedWorkspace = + params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; + await ensureSandboxWorkspace(workspaceDir, seedWorkspace); + + const containerName = await ensureSandboxContainer({ + sessionKey: rawSessionKey, + workspaceDir, + cfg, + }); + + return { + enabled: true, + sessionKey: rawSessionKey, + workspaceDir, + containerName, + containerWorkdir: cfg.docker.workdir, + docker: cfg.docker, + tools: cfg.tools, + }; +} diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 8fee5c421..d052cbb54 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -33,7 +33,14 @@ type GatewayRunSignalAction = "stop" | "restart"; function parsePort(raw: unknown): number | null { if (raw === undefined || raw === null) return null; - const parsed = Number.parseInt(String(raw), 10); + const value = + typeof raw === "string" + ? raw + : typeof raw === "number" || typeof raw === "bigint" + ? raw.toString() + : null; + if (value === null) return null; + const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) return null; return parsed; } diff --git a/src/config/config.ts b/src/config/config.ts index fc6676c5f..888e706d1 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -634,6 +634,50 @@ export type ClawdisConfig = { /** How long to keep finished sessions in memory (ms). */ cleanupMs?: number; }; + /** Optional sandbox settings for non-main sessions. */ + sandbox?: { + /** Enable sandboxing for sessions. */ + mode?: "off" | "non-main" | "all"; + /** Use one container per session (recommended for hard isolation). */ + perSession?: boolean; + /** Root directory for sandbox workspaces. */ + workspaceRoot?: string; + /** Docker-specific sandbox settings. */ + docker?: { + /** Docker image to use for sandbox containers. */ + image?: string; + /** Prefix for sandbox container names. */ + containerPrefix?: string; + /** Container workdir mount path (default: /workspace). */ + workdir?: string; + /** Run container rootfs read-only. */ + readOnlyRoot?: boolean; + /** Extra tmpfs mounts for read-only containers. */ + tmpfs?: string[]; + /** Container network mode (bridge|none|custom). */ + network?: string; + /** Container user (uid:gid). */ + user?: string; + /** Drop Linux capabilities. */ + capDrop?: string[]; + /** Extra environment variables for sandbox exec. */ + env?: Record; + /** Optional setup command run once after container creation. */ + setupCommand?: string; + }; + /** Tool allow/deny policy (deny wins). */ + tools?: { + allow?: string[]; + deny?: string[]; + }; + /** Auto-prune sandbox containers. */ + prune?: { + /** Prune if idle for more than N hours (0 disables). */ + idleHours?: number; + /** Prune if older than N days (0 disables). */ + maxAgeDays?: number; + }; + }; }; routing?: RoutingConfig; messages?: MessagesConfig; @@ -1041,6 +1085,41 @@ export const ClawdisSchema = z.object({ cleanupMs: z.number().int().positive().optional(), }) .optional(), + sandbox: z + .object({ + mode: z + .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) + .optional(), + perSession: z.boolean().optional(), + workspaceRoot: z.string().optional(), + docker: z + .object({ + image: z.string().optional(), + containerPrefix: z.string().optional(), + workdir: z.string().optional(), + readOnlyRoot: z.boolean().optional(), + tmpfs: z.array(z.string()).optional(), + network: z.string().optional(), + user: z.string().optional(), + capDrop: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + setupCommand: z.string().optional(), + }) + .optional(), + tools: z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .optional(), + prune: z + .object({ + idleHours: z.number().int().nonnegative().optional(), + maxAgeDays: z.number().int().nonnegative().optional(), + }) + .optional(), + }) + .optional(), }) .optional(), routing: RoutingSchema, diff --git a/src/config/schema.ts b/src/config/schema.ts index 208ecbf5b..25f5c153b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -129,13 +129,16 @@ function buildBaseHints(): ConfigUiHints { }; } for (const [path, label] of Object.entries(FIELD_LABELS)) { - hints[path] = { ...(hints[path] ?? {}), label }; + const current = hints[path]; + hints[path] = current ? { ...current, label } : { label }; } for (const [path, help] of Object.entries(FIELD_HELP)) { - hints[path] = { ...(hints[path] ?? {}), help }; + const current = hints[path]; + hints[path] = current ? { ...current, help } : { help }; } for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) { - hints[path] = { ...(hints[path] ?? {}), placeholder }; + const current = hints[path]; + hints[path] = current ? { ...current, placeholder } : { placeholder }; } return hints; } diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 60a2aec48..e204ebab9 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -210,7 +210,7 @@ export function createAgentEventHandler({ const jobState = evt.stream === "job" && typeof evt.data?.state === "string" - ? (evt.data.state as "done" | "error" | string) + ? evt.data.state : null; if (sessionKey) { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index a5c523df5..4f66dcf9a 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -10,6 +10,19 @@ import { testState, } from "./test-helpers.js"; +const decodeWsData = (data: unknown): string => { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf-8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8"); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString( + "utf-8", + ); + } + return ""; +}; + installGatewayTestHooks(); describe("gateway server cron", () => { @@ -253,7 +266,7 @@ describe("gateway server cron", () => { }>((resolve) => { const timeout = setTimeout(() => resolve(null as never), 8000); ws.on("message", (data) => { - const obj = JSON.parse(String(data)); + const obj = JSON.parse(decodeWsData(data)); if ( obj.type === "event" && obj.event === "cron" && diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts index 199eb2a17..96587d2ca 100644 --- a/src/gateway/server.node-bridge.test.ts +++ b/src/gateway/server.node-bridge.test.ts @@ -20,6 +20,19 @@ import { testState, } from "./test-helpers.js"; +const decodeWsData = (data: unknown): string => { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf-8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8"); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString( + "utf-8", + ); + } + return ""; +}; + installGatewayTestHooks(); describe("gateway server node/bridge", () => { @@ -37,7 +50,7 @@ describe("gateway server node/bridge", () => { payload?: unknown; }>((resolve) => { ws.on("message", (data) => { - const obj = JSON.parse(String(data)) as { + const obj = JSON.parse(decodeWsData(data)) as { type?: string; event?: string; payload?: unknown; @@ -83,7 +96,7 @@ describe("gateway server node/bridge", () => { payload?: unknown; }>((resolve) => { ws.on("message", (data) => { - const obj = JSON.parse(String(data)) as { + const obj = JSON.parse(decodeWsData(data)) as { type?: string; event?: string; payload?: unknown; @@ -805,7 +818,7 @@ describe("gateway server node/bridge", () => { payload?: unknown; }>((resolve) => { ws.on("message", (data) => { - const obj = JSON.parse(String(data)); + const obj = JSON.parse(decodeWsData(data)); if (isVoiceFinalChatEvent(obj)) { resolve(obj as never); } diff --git a/src/gateway/server.providers.test.ts b/src/gateway/server.providers.test.ts index f6f36d017..c91fe4499 100644 --- a/src/gateway/server.providers.test.ts +++ b/src/gateway/server.providers.test.ts @@ -6,6 +6,8 @@ import { startServerWithClient, } from "./test-helpers.js"; +const loadConfigHelpers = async () => await import("../config/config.js"); + installGatewayTestHooks(); describe("gateway server providers", () => { @@ -63,9 +65,8 @@ describe("gateway server providers", () => { test("telegram.logout clears bot token from config", async () => { const prevToken = process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; - const { readConfigFileSnapshot, writeConfigFile } = await import( - "../config/config.js" - ); + const { readConfigFileSnapshot, writeConfigFile } = + await loadConfigHelpers(); await writeConfigFile({ telegram: { botToken: "123:abc", diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index b1bdf3c3a..652e015b8 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -322,6 +322,7 @@ export function installGatewayTestHooks() { testState.allowFrom = undefined; testIsNixMode.value = false; cronIsolatedRun.mockClear(); + agentCommand.mockClear(); drainSystemEvents(); resetAgentRunContextForTest(); const mod = await import("./server.js"); diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 48d5724e7..6e54715e6 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -132,7 +132,16 @@ class WizardSessionPrompter implements WizardPrompter { placeholder: params.placeholder, executor: "client", }); - const value = String(res ?? ""); + const value = + res === null || res === undefined + ? "" + : typeof res === "string" + ? res + : typeof res === "number" || + typeof res === "boolean" || + typeof res === "bigint" + ? String(res) + : ""; const error = params.validate?.(value); if (error) { throw new Error(error);