diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ff43e00..5387c0a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - 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: make per-agent tool policies override global defaults and run bash synchronously when `process` is disallowed. - Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412. - Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414. - ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398. diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index 49fdc7559..8658de949 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -24,6 +24,7 @@ Behavior: - Foreground runs return output directly. - When backgrounded (explicit or timeout), the tool returns `status: "running"` + `sessionId` and a short tail. - Output is kept in memory until the session is polled or cleared. +- If the `process` tool is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. Environment overrides: - `PI_BASH_YIELD_MS`: default yield (ms) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 94305b55f..7209d2967 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -340,7 +340,7 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `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). + - `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy). - `allow`: array of allowed tool names - `deny`: array of denied tool names (deny wins) - `routing.bindings[]`: routes inbound messages to an `agentId`. diff --git a/docs/tools/bash.md b/docs/tools/bash.md index 75211c2d9..57095a6c2 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -8,6 +8,7 @@ read_when: # Bash tool Run shell commands in the workspace. Supports foreground + background execution via `process`. +If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. ## Parameters diff --git a/docs/tools/index.md b/docs/tools/index.md index af7e2609b..7965357e7 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -42,6 +42,7 @@ Core parameters: 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`. ### `process` Manage background bash sessions. diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index 6aedf1e13..51f2ebb1b 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -58,6 +58,7 @@ export type BashToolDefaults = { timeoutSec?: number; sandbox?: BashSandboxConfig; elevated?: BashElevatedDefaults; + allowBackground?: boolean; }; export type ProcessToolDefaults = { @@ -128,6 +129,7 @@ export function createBashTool( 10, 120_000, ); + const allowBackground = defaults?.allowBackground ?? true; const defaultTimeoutSec = typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0 ? defaults.timeoutSec @@ -154,14 +156,23 @@ export function createBashTool( throw new Error("Provide a command to start."); } - const yieldWindow = params.background - ? 0 - : clampNumber( - params.yieldMs ?? defaultBackgroundMs, - defaultBackgroundMs, - 10, - 120_000, - ); + const backgroundRequested = params.background === true; + const yieldRequested = typeof params.yieldMs === "number"; + if (!allowBackground && (backgroundRequested || yieldRequested)) { + warnings.push( + "Warning: background execution is disabled; running synchronously.", + ); + } + const yieldWindow = allowBackground + ? backgroundRequested + ? 0 + : clampNumber( + params.yieldMs ?? defaultBackgroundMs, + defaultBackgroundMs, + 10, + 120_000, + ) + : null; const maxOutput = DEFAULT_MAX_OUTPUT; const startedAt = Date.now(); const sessionId = randomUUID(); @@ -353,15 +364,17 @@ export function createBashTool( resolveRunning(); }; - if (yieldWindow === 0) { - onYieldNow(); - } else { - yieldTimer = setTimeout(() => { - if (settled) return; - yielded = true; - markBackgrounded(session); - resolveRunning(); - }, yieldWindow); + if (allowBackground && yieldWindow !== null) { + if (yieldWindow === 0) { + onYieldNow(); + } else { + yieldTimer = setTimeout(() => { + if (settled) return; + yielded = true; + markBackgrounded(session); + resolveRunning(); + }, yieldWindow); + } } const handleExit = ( diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 65c429781..a89b27c69 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -114,7 +114,7 @@ describe("Agent-specific tool filtering", () => { expect(familyToolNames).not.toContain("edit"); }); - it("should combine global and agent-specific deny lists", () => { + it("should prefer agent-specific tool policy over global", () => { const cfg: ClawdbotConfig = { agent: { tools: { @@ -126,7 +126,7 @@ describe("Agent-specific tool filtering", () => { work: { workspace: "~/clawd-work", tools: { - deny: ["bash", "process"], // Agent deny + deny: ["bash", "process"], // Agent deny (override) }, }, }, @@ -141,8 +141,8 @@ describe("Agent-specific tool filtering", () => { }); const toolNames = tools.map((t) => t.name); - // Both global and agent denies should be applied - expect(toolNames).not.toContain("browser"); + // Agent policy overrides global: browser is allowed again + expect(toolNames).toContain("browser"); expect(toolNames).not.toContain("bash"); expect(toolNames).not.toContain("process"); }); @@ -213,4 +213,30 @@ describe("Agent-specific tool filtering", () => { expect(toolNames).not.toContain("bash"); expect(toolNames).not.toContain("write"); }); + + it("should run bash synchronously when process is denied", async () => { + const cfg: ClawdbotConfig = { + agent: { + tools: { + deny: ["process"], + }, + }, + }; + + const tools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/test-main", + agentDir: "/tmp/agent-main", + }); + const bash = tools.find((tool) => tool.name === "bash"); + expect(bash).toBeDefined(); + + const result = await bash?.execute("call1", { + command: "node -e \"setTimeout(() => { console.log('done') }, 50)\"", + yieldMs: 10, + }); + + expect(result?.details.status).toBe("completed"); + }); }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index ffbd038e8..449fd2068 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -432,6 +432,24 @@ function filterToolsByPolicy( }); } +function isToolAllowedByPolicy(name: string, policy?: SandboxToolPolicy) { + if (!policy) return true; + const deny = new Set(normalizeToolNames(policy.deny)); + const allowRaw = normalizeToolNames(policy.allow); + const allow = allowRaw.length > 0 ? new Set(allowRaw) : null; + const normalized = name.trim().toLowerCase(); + if (deny.has(normalized)) return false; + if (allow) return allow.has(normalized); + return true; +} + +function isToolAllowedByPolicies( + name: string, + policies: Array, +) { + return policies.every((policy) => isToolAllowedByPolicy(name, policy)); +} + function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool { return { ...tool, @@ -595,6 +613,25 @@ export function createClawdbotCodingTools(options?: { }): AnyAgentTool[] { const bashToolName = "bash"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; + const agentConfig = + options?.sessionKey && options?.config + ? resolveAgentConfig( + options.config, + resolveAgentIdFromSessionKey(options.sessionKey), + ) + : undefined; + const hasAgentTools = agentConfig?.tools !== undefined; + const globalTools = options?.config?.agent?.tools; + const effectiveToolsPolicy = hasAgentTools ? agentConfig?.tools : globalTools; + const subagentPolicy = + isSubagentSessionKey(options?.sessionKey) && options?.sessionKey + ? resolveSubagentToolPolicy(options.config) + : undefined; + const allowBackground = isToolAllowedByPolicies("process", [ + effectiveToolsPolicy, + sandbox?.tools, + subagentPolicy, + ]); const sandboxRoot = sandbox?.workspaceDir; const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro"; const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { @@ -611,6 +648,7 @@ export function createClawdbotCodingTools(options?: { }); const bashTool = createBashTool({ ...options?.bash, + allowBackground, sandbox: sandbox ? { containerName: sandbox.containerName, @@ -656,33 +694,15 @@ export function createClawdbotCodingTools(options?: { if (tool.name === "whatsapp") return allowWhatsApp; return true; }); - const globallyFiltered = - options?.config?.agent?.tools && - (options.config.agent.tools.allow?.length || - 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) { - const agentId = resolveAgentIdFromSessionKey(options.sessionKey); - const agentConfig = resolveAgentConfig(options.config, agentId); - if (agentConfig?.tools) { - agentFiltered = filterToolsByPolicy(globallyFiltered, agentConfig.tools); - } - } - + const toolsFiltered = effectiveToolsPolicy + ? filterToolsByPolicy(filtered, effectiveToolsPolicy) + : filtered; const sandboxed = sandbox - ? filterToolsByPolicy(agentFiltered, sandbox.tools) - : agentFiltered; - const subagentFiltered = - isSubagentSessionKey(options?.sessionKey) && options?.sessionKey - ? filterToolsByPolicy( - sandboxed, - resolveSubagentToolPolicy(options.config), - ) - : sandboxed; + ? filterToolsByPolicy(toolsFiltered, sandbox.tools) + : toolsFiltered; + const subagentFiltered = subagentPolicy + ? filterToolsByPolicy(sandboxed, subagentPolicy) + : sandboxed; // Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai. // Without this, some providers (notably OpenAI) will reject root-level union schemas. return subagentFiltered.map(normalizeToolParameters);