fix: honor wildcard tool allowlists
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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`.
|
||||
|
||||
36
src/agents/pi-tools.policy.test.ts
Normal file
36
src/agents/pi-tools.policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
21
src/agents/sandbox/tool-policy.test.ts
Normal file
21
src/agents/sandbox/tool-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user