diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 6d29ef009..45c965548 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1234,8 +1234,24 @@ Example: } ``` +Per-agent override (further restrict): +```json5 +{ + agents: { + list: [ + { + id: "family", + tools: { + elevated: { enabled: false } + } + } + ] + } +} +``` + Notes: -- `tools.elevated` is **global** (not per-agent). Availability is based on sender allowlists. +- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow). - `/elevated on|off` stores state per session key; inline directives apply to a single message. - Elevated `bash` runs on the host and bypasses sandboxing. - Tool policy still applies; if `bash` is denied, elevated cannot be used. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 5dc3066ea..6a04f38b6 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -172,7 +172,7 @@ Also consider agent workspace access inside the sandbox: - `agents.defaults.sandbox.workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`) - `agents.defaults.sandbox.workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace` -Important: `tools.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and don’t enable it for strangers. See [Elevated Mode](/tools/elevated). +Important: `tools.elevated` is the global baseline escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and don’t enable it for strangers. You can further restrict elevated per agent via `agents.list[].tools.elevated`. See [Elevated Mode](/tools/elevated). ## Per-agent access profiles (multi-agent) diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index 8405f4148..d9e90f6e5 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -172,13 +172,14 @@ The filtering order is: Each level can further restrict tools, but cannot grant back denied tools from earlier levels. If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. -### Elevated Mode (global) -`tools.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent. +### Elevated Mode +`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow). Mitigation patterns: - Deny `bash` for untrusted agents (`agents.list[].tools.deny: ["bash"]`) - Avoid allowlisting senders that route to restricted agents - Disable elevated globally (`tools.elevated.enabled: false`) if you only want sandboxed execution +- Disable elevated per agent (`agents.list[].tools.elevated.enabled: false`) for sensitive profiles --- diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index abdf34740..6079394d5 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -12,7 +12,7 @@ read_when: - Only `on|off` are accepted; anything else returns a hint and does not change state. ## What it controls (and what it doesn’t) -- **Global availability gate**: `tools.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere. +- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Inline directive**: `/elevated on` inside a message applies to that message only. - **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. @@ -42,8 +42,10 @@ Note: ## Availability + allowlists - Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it). - Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). -- Both must pass; otherwise elevated is treated as unavailable. -- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override. +- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict). +- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists). +- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback. +- All gates must pass; otherwise elevated is treated as unavailable. ## Logging + status - Elevated bash calls are logged at info level. diff --git a/docs/tools/index.md b/docs/tools/index.md index f4e7c0aa4..09ef96ea5 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -39,7 +39,7 @@ Notes: - Returns `status: "running"` with a `sessionId` when backgrounded. - Use `process` to poll/log/write/kill/clear background sessions. - If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. -- `elevated` is gated by `tools.elevated` (global sender allowlist) and runs on the host. +- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host. - `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). ### `process` diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index d6e447ceb..e510c287c 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -85,6 +85,10 @@ describe("resolveAgentConfig", () => { tools: { allow: ["read"], deny: ["bash", "write", "edit"], + elevated: { + enabled: false, + allowFrom: { whatsapp: ["+15555550123"] }, + }, }, }, ], @@ -94,6 +98,10 @@ describe("resolveAgentConfig", () => { expect(result?.tools).toEqual({ allow: ["read"], deny: ["bash", "write", "edit"], + elevated: { + enabled: false, + allowFrom: { whatsapp: ["+15555550123"] }, + }, }); }); diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 6bb9b2b2a..7e4ae4ab9 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -33,6 +33,40 @@ describe("Agent-specific tool filtering", () => { expect(toolNames).not.toContain("Bash"); }); + it("should keep global tool policy when agent only sets tools.elevated", () => { + const cfg: ClawdbotConfig = { + tools: { + deny: ["write"], + }, + agents: { + list: [ + { + id: "main", + workspace: "~/clawd", + tools: { + elevated: { + enabled: true, + allowFrom: { whatsapp: ["+15555550123"] }, + }, + }, + }, + ], + }, + }; + + const tools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/test", + agentDir: "/tmp/agent", + }); + + const toolNames = tools.map((t) => t.name); + expect(toolNames).toContain("Bash"); + expect(toolNames).toContain("Read"); + expect(toolNames).not.toContain("Write"); + }); + it("should apply agent-specific tool policy", () => { const cfg: ClawdbotConfig = { tools: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f78d9e248..cf0794842 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -348,11 +348,13 @@ function resolveEffectiveToolPolicy(params: { params.config && agentId ? resolveAgentConfig(params.config, agentId) : undefined; - const hasAgentTools = agentConfig?.tools !== undefined; + const agentTools = agentConfig?.tools; + const hasAgentToolPolicy = + Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny); const globalTools = params.config?.tools; return { agentId, - policy: hasAgentTools ? agentConfig?.tools : globalTools, + policy: hasAgentToolPolicy ? agentTools : globalTools, }; } diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 0b555e9bf..737c897e6 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -528,6 +528,145 @@ describe("directive behavior", () => { }); }); + it("rejects per-agent elevated when disabled", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + SessionKey: "agent:restricted:main", + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + list: [ + { + id: "restricted", + tools: { + elevated: { enabled: false }, + }, + }, + ], + }, + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("elevated is not available right now."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("requires per-agent allowlist in addition to global", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + SessionKey: "agent:work:main", + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + list: [ + { + id: "work", + tools: { + elevated: { + allowFrom: { whatsapp: ["+1333"] }, + }, + }, + }, + ], + }, + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222", "+1333"] }, + }, + }, + whatsapp: { allowFrom: ["+1222", "+1333"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("elevated is not available right now."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("allows elevated when both global and per-agent allowlists match", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1333", + To: "+1333", + Provider: "whatsapp", + SenderE164: "+1333", + SessionKey: "agent:work:main", + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + list: [ + { + id: "work", + tools: { + elevated: { + allowFrom: { whatsapp: ["+1333"] }, + }, + }, + }, + ], + }, + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222", "+1333"] }, + }, + }, + whatsapp: { allowFrom: ["+1222", "+1333"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode enabled"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("warns when elevated is used in direct runtime", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); @@ -676,6 +815,51 @@ describe("directive behavior", () => { }); }); + it("shows elevated off in status when per-agent elevated is disabled", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + SessionKey: "agent:restricted:main", + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + list: [ + { + id: "restricted", + tools: { + elevated: { enabled: false }, + }, + }, + ], + }, + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated: off"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("acks queue directive and persists override", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index c8d0d664d..d762953d1 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { + resolveAgentConfig, resolveAgentDir, resolveAgentIdFromSessionKey, resolveAgentWorkspaceDir, @@ -205,6 +206,43 @@ function isApprovedElevatedSender(params: { return false; } +function resolveElevatedPermissions(params: { + cfg: ClawdbotConfig; + agentId: string; + ctx: MsgContext; + provider: string; +}): { enabled: boolean; allowed: boolean } { + const globalConfig = params.cfg.tools?.elevated; + const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.tools + ?.elevated; + const globalEnabled = globalConfig?.enabled !== false; + const agentEnabled = agentConfig?.enabled !== false; + const enabled = globalEnabled && agentEnabled; + if (!enabled) return { enabled, allowed: false }; + if (!params.provider) return { enabled, allowed: false }; + + const discordFallback = + params.provider === "discord" + ? params.cfg.discord?.dm?.allowFrom + : undefined; + const globalAllowed = isApprovedElevatedSender({ + provider: params.provider, + ctx: params.ctx, + allowFrom: globalConfig?.allowFrom, + discordFallback, + }); + if (!globalAllowed) return { enabled, allowed: false }; + + const agentAllowed = agentConfig?.allowFrom + ? isApprovedElevatedSender({ + provider: params.provider, + ctx: params.ctx, + allowFrom: agentConfig.allowFrom, + }) + : true; + return { enabled, allowed: globalAllowed && agentAllowed }; +} + export async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, @@ -391,21 +429,13 @@ export async function getReplyFromConfig( sessionCtx.Provider?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ?? ""; - const elevatedConfig = cfg.tools?.elevated; - const discordElevatedFallback = - messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; - const elevatedEnabled = elevatedConfig?.enabled !== false; - const elevatedAllowed = - elevatedEnabled && - Boolean( - messageProviderKey && - isApprovedElevatedSender({ - provider: messageProviderKey, - ctx, - allowFrom: elevatedConfig?.allowFrom, - discordFallback: discordElevatedFallback, - }), - ); + const { enabled: elevatedEnabled, allowed: elevatedAllowed } = + resolveElevatedPermissions({ + cfg, + agentId, + ctx, + provider: messageProviderKey, + }); if ( directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed) @@ -573,7 +603,7 @@ export async function getReplyFromConfig( resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, resolvedReasoningLevel: (currentReasoningLevel ?? "off") as ReasoningLevel, - resolvedElevatedLevel: currentElevatedLevel, + resolvedElevatedLevel, resolveDefaultThinkingLevel: async () => currentThinkLevel ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined), diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 22015846c..092c95bec 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -993,6 +993,39 @@ describe("legacy config detection", () => { expect((res.config as { agent?: unknown }).agent).toBeUndefined(); }); + it("accepts per-agent tools.elevated overrides", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + tools: { + elevated: { + allowFrom: { whatsapp: ["+15555550123"] }, + }, + }, + agents: { + list: [ + { + id: "work", + workspace: "~/clawd-work", + tools: { + elevated: { + enabled: false, + allowFrom: { whatsapp: ["+15555550123"] }, + }, + }, + }, + ], + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config?.agents?.list?.[0]?.tools?.elevated).toEqual({ + enabled: false, + allowFrom: { whatsapp: ["+15555550123"] }, + }); + } + }); + it("rejects telegram.requireMention", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); diff --git a/src/config/types.ts b/src/config/types.ts index 28dda35a1..569a1173f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -843,6 +843,13 @@ export type QueueConfig = { export type AgentToolsConfig = { allow?: string[]; deny?: string[]; + /** Per-agent elevated bash gate (can only further restrict global tools.elevated). */ + elevated?: { + /** Enable or disable elevated mode for this agent (default: true). */ + enabled?: boolean; + /** Approved senders for /elevated (per-provider allowlists). */ + allowFrom?: AgentElevatedAllowFromConfig; + }; sandbox?: { tools?: { allow?: string[]; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 823c218db..9199b64a8 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -749,6 +749,12 @@ const AgentToolsSchema = z .object({ allow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), + elevated: z + .object({ + enabled: z.boolean().optional(), + allowFrom: ElevatedAllowFromSchema, + }) + .optional(), sandbox: z .object({ tools: ToolPolicySchema,