diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d326517b..3e665c895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - 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`. +- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380. - 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/PR_SUMMARY.md b/PR_SUMMARY.md deleted file mode 100644 index f21887543..000000000 --- a/PR_SUMMARY.md +++ /dev/null @@ -1,203 +0,0 @@ -# PR: Agent-specific Sandbox and Tool Configuration - -## Summary - -Adds support for per-agent sandbox and tool configurations in multi-agent setups. This allows running multiple agents with different security profiles (e.g., personal assistant with full access, family bot with read-only restrictions). - -## Changes - -### Core Implementation (5 files, +49 LoC) - -1. **`src/config/types.ts`** (+4 lines) - - Added `sandbox` and `tools` fields to `routing.agents[agentId]` type - -2. **`src/config/zod-schema.ts`** (+6 lines) - - Added Zod validation for `routing.agents[].sandbox` and `routing.agents[].tools` - -3. **`src/agents/agent-scope.ts`** (+12 lines) - - Extended `resolveAgentConfig()` to return `sandbox` and `tools` fields - -4. **`src/agents/sandbox.ts`** (+12 lines) - - Modified `defaultSandboxConfig()` to accept `agentId` parameter - - Added logic to prefer agent-specific sandbox config over global config - - Updated `resolveSandboxContext()` and `ensureSandboxWorkspaceForSession()` to extract and pass `agentId` - -5. **`src/agents/pi-tools.ts`** (+15 lines) - - Added agent-specific tool filtering before sandbox tool filtering - - Imports `resolveAgentConfig` and `resolveAgentIdFromSessionKey` - -### Tests (3 new test files, 18 tests) - -1. **`src/agents/agent-scope.test.ts`** (7 tests) - - Tests for `resolveAgentConfig()` with sandbox and tools fields - -2. **`src/agents/sandbox-agent-config.test.ts`** (6 tests) - - Tests for agent-specific sandbox mode, scope, and workspaceRoot overrides - - Tests for multiple agents with different sandbox configs - -3. **`src/agents/pi-tools-agent-config.test.ts`** (5 tests) - - Tests for agent-specific tool filtering - - Tests for combined global + agent + sandbox tool policies - -### Documentation (3 files) - -1. **`docs/multi-agent-sandbox-tools.md`** (new) - - Comprehensive guide for per-agent sandbox and tool configuration - - Examples for common use cases - - Migration guide from single-agent configs - -2. **`docs/concepts/multi-agent.md`** (updated) - - Added section on per-agent sandbox and tool configuration - - Link to detailed guide - -3. **`docs/gateway/configuration.md`** (updated) - - Added documentation for `routing.agents[].sandbox` and `routing.agents[].tools` fields - -## Features - -### Agent-specific Sandbox Config - -```json -{ - "routing": { - "agents": { - "main": { - "workspace": "~/clawd", - "sandbox": { "mode": "off" } - }, - "family": { - "workspace": "~/clawd-family", - "sandbox": { - "mode": "all", - "scope": "agent" - } - } - } - } -} -``` - -**Result:** -- `main` agent runs on host (no Docker) -- `family` agent runs in Docker with one container per agent - -### Agent-specific Tool Restrictions - -```json -{ - "routing": { - "agents": { - "family": { - "workspace": "~/clawd-family", - "tools": { - "allow": ["read"], - "deny": ["bash", "write", "edit", "process"] - } - } - } - } -} -``` - -**Result:** -- `family` agent can only use the `read` tool -- All other tools are denied - -## Configuration Precedence - -### Sandbox Config -Agent-specific settings override global: -- `routing.agents[id].sandbox.mode` > `agent.sandbox.mode` -- `routing.agents[id].sandbox.scope` > `agent.sandbox.scope` -- `routing.agents[id].sandbox.workspaceRoot` > `agent.sandbox.workspaceRoot` - -Note: `docker`, `browser`, `tools`, and `prune` settings from `agent.sandbox` remain global. - -### Tool Filtering -Filtering order (each level can only further restrict): -1. Global tool policy (`agent.tools`) -2. **Agent-specific tool policy** (`routing.agents[id].tools`) ← NEW -3. Sandbox tool policy (`agent.sandbox.tools`) -4. Subagent tool policy (if applicable) - -## Backward Compatibility - -✅ **100% backward compatible** -- All existing configs work unchanged -- New fields (`routing.agents[].sandbox`, `routing.agents[].tools`) are optional -- Default behavior: if no agent-specific config exists, use global config -- All 1325 existing tests pass - -## Testing - -### New Tests: 18 tests, all passing -``` -✓ src/agents/agent-scope.test.ts (7 tests) -✓ src/agents/sandbox-agent-config.test.ts (6 tests) -✓ src/agents/pi-tools-agent-config.test.ts (5 tests) -``` - -### Existing Tests: All passing -``` -Test Files 227 passed | 2 skipped (229) -Tests 1325 passed | 2 skipped (1327) -``` - -Specifically verified: -- Discord provider tests: ✓ 23 tests -- Telegram provider tests: ✓ 42 tests -- Routing tests: ✓ 7 tests -- Gateway tests: ✓ All passed - -## Use Cases - -### Use Case 1: Personal Assistant + Restricted Family Bot -- Personal agent: Host, all tools -- Family agent: Docker, read-only - -### Use Case 2: Work Agent with Limited Access -- Personal agent: Full access -- Work agent: Docker, no browser/gateway tools - -### Use Case 3: Public-facing Bot -- Main agent: Trusted, full access -- Public agent: Always sandboxed, minimal tools - -## Migration Path - -**Before (global config):** -```json -{ - "agent": { - "sandbox": { "mode": "non-main" } - } -} -``` - -**After (per-agent config):** -```json -{ - "routing": { - "agents": { - "main": { "sandbox": { "mode": "off" } }, - "family": { "sandbox": { "mode": "all", "scope": "agent" } } - } - } -} -``` - -## Related Issues - -- Addresses need for per-agent security policies in multi-agent setups -- Complements existing multi-agent routing feature (introduced in 7360abad) -- Prepares for upcoming `clawdbot agents` CLI (announced 2026-01-07) - -## Checklist - -- [x] Code changes implemented -- [x] Tests written and passing -- [x] Documentation updated -- [x] Backward compatibility verified -- [x] No breaking changes -- [x] TypeScript types updated -- [x] Zod schema validation added diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 1196a9619..131ed3a96 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -168,4 +168,4 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio - **Resource control**: Sandbox specific agents while keeping others on host - **Flexible policies**: Different permissions per agent -See [Multi-Agent Sandbox & Tools](/docs/multi-agent-sandbox-tools) for detailed examples. +See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for detailed examples. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e3ce95beb..8a5be2d8c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -336,8 +336,10 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `model`: per-agent default model (provider/model), overrides `agent.model` for that agent. - `sandbox`: per-agent sandbox config (overrides `agent.sandbox`). - `mode`: `"off"` | `"non-main"` | `"all"` + - `workspaceAccess`: `"none"` | `"ro"` | `"rw"` - `scope`: `"session"` | `"agent"` | `"shared"` - `workspaceRoot`: custom sandbox workspace root + - `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`) - `tools`: per-agent tool restrictions (applied before sandbox tool policy). - `allow`: array of allowed tool names - `deny`: array of denied tool names (deny wins) diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index 102c68c5c..124b69cc8 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -3,7 +3,7 @@ ## Overview Each agent in a multi-agent setup can now have its own: -- **Sandbox configuration** (`mode`, `scope`, `workspaceRoot`) +- **Sandbox configuration** (`mode`, `scope`, `workspaceRoot`, `workspaceAccess`, `tools`) - **Tool restrictions** (`allow`, `deny`) This allows you to run multiple agents with different security profiles: @@ -141,9 +141,10 @@ Agent-specific settings override global: routing.agents[id].sandbox.mode > agent.sandbox.mode routing.agents[id].sandbox.scope > agent.sandbox.scope routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot +routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess ``` -**Note:** `docker`, `browser`, `tools`, and `prune` settings from `agent.sandbox` are still **global** and apply to all sandboxed agents. +**Note:** `docker`, `browser`, and `prune` settings from `agent.sandbox` are still **global** and apply to all sandboxed agents. ### Tool Restrictions The filtering order is: @@ -153,6 +154,7 @@ The filtering order is: 4. **Subagent tool policy** (if applicable) Each level can further restrict tools, but cannot grant back denied tools from earlier levels. +If `routing.agents[id].sandbox.tools` is set, it replaces `agent.sandbox.tools` for that agent. --- diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 339087959..322e66ac9 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -55,7 +55,12 @@ describe("resolveAgentConfig", () => { mode: "all", scope: "agent", perSession: false, + workspaceAccess: "ro", workspaceRoot: "~/sandboxes", + tools: { + allow: ["read"], + deny: ["bash"], + }, }, }, }, @@ -66,7 +71,12 @@ describe("resolveAgentConfig", () => { mode: "all", scope: "agent", perSession: false, + workspaceAccess: "ro", workspaceRoot: "~/sandboxes", + tools: { + allow: ["read"], + deny: ["bash"], + }, }); }); diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index adc5e3789..384976e9c 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -29,9 +29,14 @@ export function resolveAgentConfig( model?: string; sandbox?: { mode?: "off" | "non-main" | "all"; + workspaceAccess?: "none" | "ro" | "rw"; scope?: "session" | "agent" | "shared"; perSession?: boolean; workspaceRoot?: string; + tools?: { + allow?: string[]; + deny?: string[]; + }; }; tools?: { allow?: string[]; diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 0b8affd39..65c429781 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; import { createClawdbotCodingTools } from "./pi-tools.js"; +import type { SandboxDockerConfig } from "./sandbox.js"; describe("Agent-specific tool filtering", () => { it("should apply global tool policy when no agent-specific policy exists", () => { @@ -188,7 +189,15 @@ describe("Agent-specific tool filtering", () => { workspaceAccess: "none", containerName: "test-container", containerWorkdir: "/workspace", - docker: {} as any, + docker: { + image: "test-image", + containerPrefix: "test-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: [], + network: "none", + capDrop: [], + } satisfies SandboxDockerConfig, tools: { allow: ["read", "write", "bash"], deny: [], diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 7687e788d..80de703fd 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -596,7 +596,7 @@ export function createClawdbotCodingTools(options?: { options.config.agent.tools.deny?.length) ? filterToolsByPolicy(filtered, options.config.agent.tools) : filtered; - + // Agent-specific tool policy let agentFiltered = globallyFiltered; if (options?.sessionKey && options?.config) { @@ -606,7 +606,7 @@ export function createClawdbotCodingTools(options?: { agentFiltered = filterToolsByPolicy(globallyFiltered, agentConfig.tools); } } - + const sandboxed = sandbox ? filterToolsByPolicy(agentFiltered, sandbox.tools) : agentFiltered; diff --git a/src/agents/sandbox-agent-config.test.ts b/src/agents/sandbox-agent-config.test.ts index 040b3d483..2333e67fc 100644 --- a/src/agents/sandbox-agent-config.test.ts +++ b/src/agents/sandbox-agent-config.test.ts @@ -1,13 +1,33 @@ -import { describe, expect, it } from "vitest"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; // We need to test the internal defaultSandboxConfig function, but it's not exported. // Instead, we test the behavior through resolveSandboxContext which uses it. +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: () => { + const child = new EventEmitter() as { + stdout?: Readable; + stderr?: Readable; + on: (event: string, cb: (...args: unknown[]) => void) => void; + }; + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + queueMicrotask(() => child.emit("close", 0)); + return child; + }, + }; +}); + describe("Agent-specific sandbox config", () => { it("should use global sandbox config when no agent-specific config exists", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); - + const cfg: ClawdbotConfig = { agent: { sandbox: { @@ -36,7 +56,7 @@ describe("Agent-specific sandbox config", () => { it("should override with agent-specific sandbox mode 'off'", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); - + const cfg: ClawdbotConfig = { agent: { sandbox: { @@ -68,7 +88,7 @@ describe("Agent-specific sandbox config", () => { it("should use agent-specific sandbox mode 'all'", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); - + const cfg: ClawdbotConfig = { agent: { sandbox: { @@ -100,7 +120,7 @@ describe("Agent-specific sandbox config", () => { it("should use agent-specific scope", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); - + const cfg: ClawdbotConfig = { agent: { sandbox: { @@ -134,7 +154,7 @@ describe("Agent-specific sandbox config", () => { it("should use agent-specific workspaceRoot", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); - + const cfg: ClawdbotConfig = { agent: { sandbox: { @@ -169,7 +189,7 @@ describe("Agent-specific sandbox config", () => { it("should prefer agent config over global for multiple agents", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); - + const cfg: ClawdbotConfig = { agent: { sandbox: { @@ -213,4 +233,48 @@ describe("Agent-specific sandbox config", () => { expect(familyContext).toBeDefined(); expect(familyContext?.enabled).toBe(true); }); + + it("should prefer agent-specific sandbox tool policy", async () => { + const { resolveSandboxContext } = await import("./sandbox.js"); + + const cfg: ClawdbotConfig = { + agent: { + sandbox: { + mode: "all", + scope: "agent", + tools: { + allow: ["read"], + deny: ["bash"], + }, + }, + }, + routing: { + agents: { + restricted: { + workspace: "~/clawd-restricted", + sandbox: { + mode: "all", + scope: "agent", + tools: { + allow: ["read", "write"], + deny: ["edit"], + }, + }, + }, + }, + }, + }; + + const context = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:restricted:main", + workspaceDir: "/tmp/test-restricted", + }); + + expect(context).toBeDefined(); + expect(context?.tools).toEqual({ + allow: ["read", "write"], + deny: ["edit"], + }); + }); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 547553268..eeb2ea96f 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -226,9 +226,12 @@ function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) { return `agent:${agentId}`; } -function defaultSandboxConfig(cfg?: ClawdbotConfig, agentId?: string): SandboxConfig { +function defaultSandboxConfig( + cfg?: ClawdbotConfig, + agentId?: string, +): SandboxConfig { const agent = cfg?.agent?.sandbox; - + // Agent-specific sandbox config overrides global let agentSandbox: typeof agent | undefined; if (agentId && cfg?.routing?.agents) { @@ -237,15 +240,19 @@ function defaultSandboxConfig(cfg?: ClawdbotConfig, agentId?: string): SandboxCo agentSandbox = agentConfig.sandbox; } } - + return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", scope: resolveSandboxScope({ scope: agentSandbox?.scope ?? agent?.scope, perSession: agentSandbox?.perSession ?? agent?.perSession, }), - workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", - workspaceRoot: agentSandbox?.workspaceRoot ?? agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, + workspaceAccess: + agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", + workspaceRoot: + agentSandbox?.workspaceRoot ?? + agent?.workspaceRoot ?? + DEFAULT_SANDBOX_WORKSPACE_ROOT, docker: { image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE, containerPrefix: @@ -281,8 +288,10 @@ function defaultSandboxConfig(cfg?: ClawdbotConfig, agentId?: string): SandboxCo enableNoVnc: agent?.browser?.enableNoVnc ?? true, }, tools: { - allow: agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, - deny: agent?.tools?.deny ?? DEFAULT_TOOL_DENY, + allow: + agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, + deny: + agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY, }, prune: { idleHours: agent?.prune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS, diff --git a/src/config/types.ts b/src/config/types.ts index a1c8adb44..e8a16f23d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -586,11 +586,18 @@ export type RoutingConfig = { model?: string; sandbox?: { mode?: "off" | "non-main" | "all"; + /** Agent workspace access inside the sandbox. */ + workspaceAccess?: "none" | "ro" | "rw"; /** Container/workspace scope for sandbox isolation. */ scope?: "session" | "agent" | "shared"; /** Legacy alias for scope ("session" when true, "shared" when false). */ perSession?: boolean; workspaceRoot?: string; + /** Tool allow/deny policy for sandboxed sessions (deny wins). */ + tools?: { + allow?: string[]; + deny?: string[]; + }; }; tools?: { allow?: string[]; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0f4e018d3..b3dfef5ab 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -236,6 +236,9 @@ const RoutingSchema = z z.literal("all"), ]) .optional(), + workspaceAccess: z + .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) + .optional(), scope: z .union([ z.literal("session"), @@ -245,6 +248,12 @@ const RoutingSchema = z .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), + tools: z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .optional(), }) .optional(), tools: z