fix: override agent tools + sync bash without process

This commit is contained in:
Peter Steinberger
2026-01-07 23:18:21 +01:00
parent 434c25331e
commit 090390cd77
8 changed files with 111 additions and 48 deletions

View File

@@ -28,6 +28,7 @@
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first). - Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`. - 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. - 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. - 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. - 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. - ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.

View File

@@ -24,6 +24,7 @@ Behavior:
- Foreground runs return output directly. - Foreground runs return output directly.
- When backgrounded (explicit or timeout), the tool returns `status: "running"` + `sessionId` and a short tail. - 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. - 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: Environment overrides:
- `PI_BASH_YIELD_MS`: default yield (ms) - `PI_BASH_YIELD_MS`: default yield (ms)

View File

@@ -340,7 +340,7 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o
- `scope`: `"session"` | `"agent"` | `"shared"` - `scope`: `"session"` | `"agent"` | `"shared"`
- `workspaceRoot`: custom sandbox workspace root - `workspaceRoot`: custom sandbox workspace root
- `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`) - `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 - `allow`: array of allowed tool names
- `deny`: array of denied tool names (deny wins) - `deny`: array of denied tool names (deny wins)
- `routing.bindings[]`: routes inbound messages to an `agentId`. - `routing.bindings[]`: routes inbound messages to an `agentId`.

View File

@@ -8,6 +8,7 @@ read_when:
# Bash tool # Bash tool
Run shell commands in the workspace. Supports foreground + background execution via `process`. Run shell commands in the workspace. Supports foreground + background execution via `process`.
If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
## Parameters ## Parameters

View File

@@ -42,6 +42,7 @@ Core parameters:
Notes: Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions. - Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
### `process` ### `process`
Manage background bash sessions. Manage background bash sessions.

View File

@@ -58,6 +58,7 @@ export type BashToolDefaults = {
timeoutSec?: number; timeoutSec?: number;
sandbox?: BashSandboxConfig; sandbox?: BashSandboxConfig;
elevated?: BashElevatedDefaults; elevated?: BashElevatedDefaults;
allowBackground?: boolean;
}; };
export type ProcessToolDefaults = { export type ProcessToolDefaults = {
@@ -128,6 +129,7 @@ export function createBashTool(
10, 10,
120_000, 120_000,
); );
const allowBackground = defaults?.allowBackground ?? true;
const defaultTimeoutSec = const defaultTimeoutSec =
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0 typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
? defaults.timeoutSec ? defaults.timeoutSec
@@ -154,14 +156,23 @@ export function createBashTool(
throw new Error("Provide a command to start."); throw new Error("Provide a command to start.");
} }
const yieldWindow = params.background const backgroundRequested = params.background === true;
? 0 const yieldRequested = typeof params.yieldMs === "number";
: clampNumber( if (!allowBackground && (backgroundRequested || yieldRequested)) {
params.yieldMs ?? defaultBackgroundMs, warnings.push(
defaultBackgroundMs, "Warning: background execution is disabled; running synchronously.",
10, );
120_000, }
); const yieldWindow = allowBackground
? backgroundRequested
? 0
: clampNumber(
params.yieldMs ?? defaultBackgroundMs,
defaultBackgroundMs,
10,
120_000,
)
: null;
const maxOutput = DEFAULT_MAX_OUTPUT; const maxOutput = DEFAULT_MAX_OUTPUT;
const startedAt = Date.now(); const startedAt = Date.now();
const sessionId = randomUUID(); const sessionId = randomUUID();
@@ -353,15 +364,17 @@ export function createBashTool(
resolveRunning(); resolveRunning();
}; };
if (yieldWindow === 0) { if (allowBackground && yieldWindow !== null) {
onYieldNow(); if (yieldWindow === 0) {
} else { onYieldNow();
yieldTimer = setTimeout(() => { } else {
if (settled) return; yieldTimer = setTimeout(() => {
yielded = true; if (settled) return;
markBackgrounded(session); yielded = true;
resolveRunning(); markBackgrounded(session);
}, yieldWindow); resolveRunning();
}, yieldWindow);
}
} }
const handleExit = ( const handleExit = (

View File

@@ -114,7 +114,7 @@ describe("Agent-specific tool filtering", () => {
expect(familyToolNames).not.toContain("edit"); 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 = { const cfg: ClawdbotConfig = {
agent: { agent: {
tools: { tools: {
@@ -126,7 +126,7 @@ describe("Agent-specific tool filtering", () => {
work: { work: {
workspace: "~/clawd-work", workspace: "~/clawd-work",
tools: { 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); const toolNames = tools.map((t) => t.name);
// Both global and agent denies should be applied // Agent policy overrides global: browser is allowed again
expect(toolNames).not.toContain("browser"); expect(toolNames).toContain("browser");
expect(toolNames).not.toContain("bash"); expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("process"); expect(toolNames).not.toContain("process");
}); });
@@ -213,4 +213,30 @@ describe("Agent-specific tool filtering", () => {
expect(toolNames).not.toContain("bash"); expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("write"); 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");
});
}); });

View File

@@ -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<SandboxToolPolicy | undefined>,
) {
return policies.every((policy) => isToolAllowedByPolicy(name, policy));
}
function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool { function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
return { return {
...tool, ...tool,
@@ -595,6 +613,25 @@ export function createClawdbotCodingTools(options?: {
}): AnyAgentTool[] { }): AnyAgentTool[] {
const bashToolName = "bash"; const bashToolName = "bash";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; 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 sandboxRoot = sandbox?.workspaceDir;
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro"; const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
@@ -611,6 +648,7 @@ export function createClawdbotCodingTools(options?: {
}); });
const bashTool = createBashTool({ const bashTool = createBashTool({
...options?.bash, ...options?.bash,
allowBackground,
sandbox: sandbox sandbox: sandbox
? { ? {
containerName: sandbox.containerName, containerName: sandbox.containerName,
@@ -656,33 +694,15 @@ export function createClawdbotCodingTools(options?: {
if (tool.name === "whatsapp") return allowWhatsApp; if (tool.name === "whatsapp") return allowWhatsApp;
return true; return true;
}); });
const globallyFiltered = const toolsFiltered = effectiveToolsPolicy
options?.config?.agent?.tools && ? filterToolsByPolicy(filtered, effectiveToolsPolicy)
(options.config.agent.tools.allow?.length || : filtered;
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 sandboxed = sandbox const sandboxed = sandbox
? filterToolsByPolicy(agentFiltered, sandbox.tools) ? filterToolsByPolicy(toolsFiltered, sandbox.tools)
: agentFiltered; : toolsFiltered;
const subagentFiltered = const subagentFiltered = subagentPolicy
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey ? filterToolsByPolicy(sandboxed, subagentPolicy)
? filterToolsByPolicy( : sandboxed;
sandboxed,
resolveSubagentToolPolicy(options.config),
)
: sandboxed;
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai. // 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. // Without this, some providers (notably OpenAI) will reject root-level union schemas.
return subagentFiltered.map(normalizeToolParameters); return subagentFiltered.map(normalizeToolParameters);