fix: honor wildcard tool allowlists

This commit is contained in:
Peter Steinberger
2026-01-24 01:15:49 +00:00
parent aabe0bed30
commit 00fd57b8f5
6 changed files with 149 additions and 16 deletions

View File

@@ -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):

View File

@@ -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`.

View File

@@ -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<unknown, unknown> {
return {
name,
label: name,
description: "",
parameters: {},
execute: async () => ({}) as AgentToolResult<unknown>,
};
}
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);
});
});

View File

@@ -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 = {

View File

@@ -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);
});
});

View File

@@ -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(