diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index fcc39560e..ab41221a7 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1970,6 +1970,7 @@ Example (provider/model-specific allowlist): ``` `tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins). +Matching is case-insensitive and supports `*` wildcards (`"*"` means all tools). This is applied even when the Docker sandbox is **off**. Example (disable browser/canvas everywhere): diff --git a/docs/tools/index.md b/docs/tools/index.md index 9731b4f7d..95372d109 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -22,6 +22,10 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot. } ``` +Notes: +- Matching is case-insensitive. +- `*` wildcards are supported (`"*"` means all tools). + ## Tool profiles (base allowlist) `tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`. diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/pi-tools.policy.test.ts new file mode 100644 index 000000000..1405d2735 --- /dev/null +++ b/src/agents/pi-tools.policy.test.ts @@ -0,0 +1,36 @@ +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { filterToolsByPolicy, isToolAllowedByPolicyName } from "./pi-tools.policy.js"; + +function createStubTool(name: string): AgentTool { + return { + name, + label: name, + description: "", + parameters: {}, + execute: async () => ({}) as AgentToolResult, + }; +} + +describe("pi-tools.policy", () => { + it("treats * in allow as allow-all", () => { + const tools = [createStubTool("read"), createStubTool("exec")]; + const filtered = filterToolsByPolicy(tools, { allow: ["*"] }); + expect(filtered.map((tool) => tool.name)).toEqual(["read", "exec"]); + }); + + it("treats * in deny as deny-all", () => { + const tools = [createStubTool("read"), createStubTool("exec")]; + const filtered = filterToolsByPolicy(tools, { deny: ["*"] }); + expect(filtered).toEqual([]); + }); + + it("supports wildcard allow/deny patterns", () => { + expect(isToolAllowedByPolicyName("web_fetch", { allow: ["web_*"] })).toBe(true); + expect(isToolAllowedByPolicyName("web_search", { deny: ["web_*"] })).toBe(false); + }); + + it("keeps apply_patch when exec is allowlisted", () => { + expect(isToolAllowedByPolicyName("apply_patch", { allow: ["exec"] })).toBe(true); + }); +}); diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index a25bd0c2b..ea4004ec9 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -4,6 +4,52 @@ import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxToolPolicy } from "./sandbox.js"; import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; +type CompiledPattern = + | { kind: "all" } + | { kind: "exact"; value: string } + | { kind: "regex"; value: RegExp }; + +function compilePattern(pattern: string): CompiledPattern { + const normalized = normalizeToolName(pattern); + if (!normalized) return { kind: "exact", value: "" }; + if (normalized === "*") return { kind: "all" }; + if (!normalized.includes("*")) return { kind: "exact", value: normalized }; + const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return { + kind: "regex", + value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`), + }; +} + +function compilePatterns(patterns?: string[]): CompiledPattern[] { + if (!Array.isArray(patterns)) return []; + return expandToolGroups(patterns) + .map(compilePattern) + .filter((pattern) => pattern.kind !== "exact" || pattern.value); +} + +function matchesAny(name: string, patterns: CompiledPattern[]): boolean { + for (const pattern of patterns) { + if (pattern.kind === "all") return true; + if (pattern.kind === "exact" && name === pattern.value) return true; + if (pattern.kind === "regex" && pattern.value.test(name)) return true; + } + return false; +} + +function makeToolPolicyMatcher(policy: SandboxToolPolicy) { + const deny = compilePatterns(policy.deny); + const allow = compilePatterns(policy.allow); + return (name: string) => { + const normalized = normalizeToolName(name); + if (matchesAny(normalized, deny)) return false; + if (allow.length === 0) return true; + if (matchesAny(normalized, allow)) return true; + if (normalized === "apply_patch" && matchesAny("exec", allow)) return true; + return false; + }; +} + const DEFAULT_SUBAGENT_TOOL_DENY = [ // Session management - main agent orchestrates "sessions_list", @@ -35,22 +81,13 @@ export function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPoli export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean { if (!policy) return true; - const deny = new Set(expandToolGroups(policy.deny)); - const allowRaw = expandToolGroups(policy.allow); - const allow = allowRaw.length > 0 ? new Set(allowRaw) : null; - const normalized = normalizeToolName(name); - if (deny.has(normalized)) return false; - if (allow) { - if (allow.has(normalized)) return true; - if (normalized === "apply_patch" && allow.has("exec")) return true; - return false; - } - return true; + return makeToolPolicyMatcher(policy)(name); } export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) { if (!policy) return tools; - return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy)); + const matcher = makeToolPolicyMatcher(policy); + return tools.filter((tool) => matcher(tool.name)); } type ToolPolicyConfig = { diff --git a/src/agents/sandbox/tool-policy.test.ts b/src/agents/sandbox/tool-policy.test.ts new file mode 100644 index 000000000..319a84a97 --- /dev/null +++ b/src/agents/sandbox/tool-policy.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import type { SandboxToolPolicy } from "./types.js"; +import { isToolAllowed } from "./tool-policy.js"; + +describe("sandbox tool policy", () => { + it("allows all tools with * allow", () => { + const policy: SandboxToolPolicy = { allow: ["*"], deny: [] }; + expect(isToolAllowed(policy, "browser")).toBe(true); + }); + + it("denies all tools with * deny", () => { + const policy: SandboxToolPolicy = { allow: [], deny: ["*"] }; + expect(isToolAllowed(policy, "read")).toBe(false); + }); + + it("supports wildcard patterns", () => { + const policy: SandboxToolPolicy = { allow: ["web_*"] }; + expect(isToolAllowed(policy, "web_fetch")).toBe(true); + expect(isToolAllowed(policy, "read")).toBe(false); + }); +}); diff --git a/src/agents/sandbox/tool-policy.ts b/src/agents/sandbox/tool-policy.ts index 09fcba9b8..130734c71 100644 --- a/src/agents/sandbox/tool-policy.ts +++ b/src/agents/sandbox/tool-policy.ts @@ -8,12 +8,46 @@ import type { SandboxToolPolicySource, } from "./types.js"; +type CompiledPattern = + | { kind: "all" } + | { kind: "exact"; value: string } + | { kind: "regex"; value: RegExp }; + +function compilePattern(pattern: string): CompiledPattern { + const normalized = pattern.trim().toLowerCase(); + if (!normalized) return { kind: "exact", value: "" }; + if (normalized === "*") return { kind: "all" }; + if (!normalized.includes("*")) return { kind: "exact", value: normalized }; + const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return { + kind: "regex", + value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`), + }; +} + +function compilePatterns(patterns?: string[]): CompiledPattern[] { + if (!Array.isArray(patterns)) return []; + return expandToolGroups(patterns) + .map(compilePattern) + .filter((pattern) => pattern.kind !== "exact" || pattern.value); +} + +function matchesAny(name: string, patterns: CompiledPattern[]): boolean { + for (const pattern of patterns) { + if (pattern.kind === "all") return true; + if (pattern.kind === "exact" && name === pattern.value) return true; + if (pattern.kind === "regex" && pattern.value.test(name)) return true; + } + return false; +} + export function isToolAllowed(policy: SandboxToolPolicy, name: string) { - const deny = new Set(expandToolGroups(policy.deny)); - if (deny.has(name.toLowerCase())) return false; - const allow = expandToolGroups(policy.allow); + const normalized = name.trim().toLowerCase(); + const deny = compilePatterns(policy.deny); + if (matchesAny(normalized, deny)) return false; + const allow = compilePatterns(policy.allow); if (allow.length === 0) return true; - return allow.includes(name.toLowerCase()); + return matchesAny(normalized, allow); } export function resolveSandboxToolPolicyForAgent(