feat(tools): add tool profiles and group shorthands

This commit is contained in:
Peter Steinberger
2026-01-13 06:28:15 +00:00
parent d682b604de
commit 780a43711f
11 changed files with 449 additions and 82 deletions

View File

@@ -583,6 +583,7 @@ Inbound messages are routed to an agent via bindings.
- `subagents`: per-agent sub-agent defaults. - `subagents`: per-agent sub-agent defaults.
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent) - `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
- `tools`: per-agent tool restrictions (applied before sandbox tool policy). - `tools`: per-agent tool restrictions (applied before sandbox tool policy).
- `profile`: base tool profile (applied before allow/deny)
- `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)
- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.). - `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
@@ -1503,6 +1504,34 @@ Legacy: `tools.bash` is still accepted as an alias.
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Per-agent override: `agents.list[].tools.profile`.
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins). `tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**. This is applied even when the Docker sandbox is **off**.
@@ -1513,6 +1542,17 @@ Example (disable browser/canvas everywhere):
} }
``` ```
Tool groups (shorthands) work in **global** and **per-agent** tool policies:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
`tools.elevated` controls elevated (host) exec access: `tools.elevated` controls elevated (host) exec access:
- `enabled`: allow elevated mode (default true) - `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled) - `allowFrom`: per-provider allowlists (empty = disabled)

View File

@@ -50,6 +50,7 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
## Tool policy: which tools exist/are callable ## Tool policy: which tools exist/are callable
Two layers matter: Two layers matter:
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny` - **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*` - **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
@@ -59,7 +60,7 @@ Rules of thumb:
### Tool groups (shorthands) ### Tool groups (shorthands)
For sandbox tool policy, you can use `group:*` entries that expand to multiple tools: Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
```json5 ```json5
{ {
@@ -78,6 +79,11 @@ Available groups:
- `group:fs`: `read`, `write`, `edit`, `apply_patch` - `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get` - `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
## Elevated: exec-only “run on host” ## Elevated: exec-only “run on host”

View File

@@ -106,6 +106,28 @@ For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevate
--- ---
### Example 2b: Global coding profile + messaging-only agent
```json
{
"tools": { "profile": "coding" },
"agents": {
"list": [
{
"id": "support",
"tools": { "profile": "messaging", "allow": ["slack"] }
}
]
}
}
```
**Result:**
- default agents get coding tools
- `support` agent is messaging-only (+ Slack tool)
---
### Example 3: Different Sandbox Modes per Agent ### Example 3: Different Sandbox Modes per Agent
```json ```json
@@ -165,22 +187,29 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
### Tool Restrictions ### Tool Restrictions
The filtering order is: The filtering order is:
1. **Global tool policy** (`tools.allow` / `tools.deny`) 1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
2. **Agent-specific tool policy** (`agents.list[].tools`) 2. **Global tool policy** (`tools.allow` / `tools.deny`)
3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`) 3. **Agent-specific tool policy** (`agents.list[].tools`)
4. **Subagent tool policy** (`tools.subagents.tools`, if applicable) 4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
Each level can further restrict tools, but cannot grant back denied tools from earlier levels. Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
### Tool groups (shorthands) ### Tool groups (shorthands)
Sandbox tool policy supports `group:*` entries that expand to multiple concrete tools: Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
- `group:runtime`: `exec`, `bash`, `process` - `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch` - `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get` - `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
### Elevated Mode ### Elevated Mode
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow). `tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).

View File

@@ -22,6 +22,77 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
} }
``` ```
## Tool profiles (base allowlist)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
Per-agent override: `agents.list[].tools.profile`.
Profiles:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
Example (global coding profile, messaging-only support agent):
```json5
{
tools: { profile: "coding" },
agents: {
list: [
{
id: "support",
tools: { profile: "messaging", allow: ["slack"] }
}
]
}
}
```
## Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
Use these in `tools.allow` / `tools.deny`.
Available groups:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
Example (allow only file tools + browser):
```json5
{
tools: {
allow: ["group:fs", "browser"]
}
}
```
## Plugins + tools ## Plugins + tools
Plugins can register **additional tools** (and CLI commands) beyond the core set. Plugins can register **additional tools** (and CLI commands) beyond the core set.

View File

@@ -5,6 +5,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp"; import sharp from "sharp";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js"; import { createBrowserTool } from "./tools/browser-tool.js";
@@ -16,7 +17,7 @@ describe("createClawdbotCodingTools", () => {
expect(schema.anyOf).toBeUndefined(); expect(schema.anyOf).toBeUndefined();
}); });
it("merges properties for union tool schemas", () => { it("keeps browser tool schema properties after normalization", () => {
const tools = createClawdbotCodingTools(); const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser"); const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined(); expect(browser).toBeDefined();
@@ -266,6 +267,95 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
const tools = createClawdbotCodingTools();
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("keeps raw core tool schemas union-free", () => {
const tools = createClawdbotTools();
const coreTools = new Set([
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
]);
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
if (!coreTools.has(tool.name)) continue;
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("does not expose provider-specific message tools", () => { it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" }); const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name)); const names = new Set(tools.map((tool) => tool.name));
@@ -517,6 +607,46 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "browser")).toBe(false); expect(tools.some((tool) => tool.name === "browser")).toBe(false);
}); });
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => { it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools(); const tools = createClawdbotCodingTools();

View File

@@ -28,6 +28,11 @@ import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js"; import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { sanitizeToolResultImages } from "./tool-images.js"; import { sanitizeToolResultImages } from "./tool-images.js";
import {
expandToolGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper // NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to normalize payloads and sanitize oversized images before they hit providers. // to normalize payloads and sanitize oversized images before they hit providers.
@@ -291,21 +296,6 @@ function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema); return cleanSchemaForGemini(schema);
} }
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
function isOpenAIProvider(provider?: string) { function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase(); const normalized = provider?.trim().toLowerCase();
return normalized === "openai" || normalized === "openai-codex"; return normalized === "openai" || normalized === "openai-codex";
@@ -357,8 +347,8 @@ function isToolAllowedByPolicyName(
policy?: SandboxToolPolicy, policy?: SandboxToolPolicy,
): boolean { ): boolean {
if (!policy) return true; if (!policy) return true;
const deny = new Set(normalizeToolNames(policy.deny)); const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = normalizeToolNames(policy.allow); const allowRaw = expandToolGroups(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null; const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name); const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false; if (deny.has(normalized)) return false;
@@ -391,11 +381,15 @@ function resolveEffectiveToolPolicy(params: {
: undefined; : undefined;
const agentTools = agentConfig?.tools; const agentTools = agentConfig?.tools;
const hasAgentToolPolicy = const hasAgentToolPolicy =
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny); Array.isArray(agentTools?.allow) ||
Array.isArray(agentTools?.deny) ||
typeof agentTools?.profile === "string";
const globalTools = params.config?.tools; const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
return { return {
agentId, agentId,
policy: hasAgentToolPolicy ? agentTools : globalTools, policy: hasAgentToolPolicy ? agentTools : globalTools,
profile,
}; };
} }
@@ -703,10 +697,15 @@ export function createClawdbotCodingTools(options?: {
}): AnyAgentTool[] { }): AnyAgentTool[] {
const execToolName = "exec"; const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const { agentId, policy: effectiveToolsPolicy } = resolveEffectiveToolPolicy({ const {
agentId,
policy: effectiveToolsPolicy,
profile,
} = resolveEffectiveToolPolicy({
config: options?.config, config: options?.config,
sessionKey: options?.sessionKey, sessionKey: options?.sessionKey,
}); });
const profilePolicy = resolveToolProfilePolicy(profile);
const scopeKey = const scopeKey =
options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy = const subagentPolicy =
@@ -714,6 +713,7 @@ export function createClawdbotCodingTools(options?: {
? resolveSubagentToolPolicy(options.config) ? resolveSubagentToolPolicy(options.config)
: undefined; : undefined;
const allowBackground = isToolAllowedByPolicies("process", [ const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
effectiveToolsPolicy, effectiveToolsPolicy,
sandbox?.tools, sandbox?.tools,
subagentPolicy, subagentPolicy,
@@ -829,12 +829,15 @@ export function createClawdbotCodingTools(options?: {
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
}), }),
]; ];
const toolsFiltered = effectiveToolsPolicy const toolsFiltered = profilePolicy
? filterToolsByPolicy(tools, effectiveToolsPolicy) ? filterToolsByPolicy(tools, profilePolicy)
: tools; : tools;
const sandboxed = sandbox const policyFiltered = effectiveToolsPolicy
? filterToolsByPolicy(toolsFiltered, sandbox.tools) ? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
: toolsFiltered; : toolsFiltered;
const sandboxed = sandbox
? filterToolsByPolicy(policyFiltered, sandbox.tools)
: policyFiltered;
const subagentFiltered = subagentPolicy const subagentFiltered = subagentPolicy
? filterToolsByPolicy(sandboxed, subagentPolicy) ? filterToolsByPolicy(sandboxed, subagentPolicy)
: sandboxed; : sandboxed;

View File

@@ -33,6 +33,7 @@ import {
resolveSessionAgentId, resolveSessionAgentId,
} from "./agent-scope.js"; } from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js"; import { syncSkillsToWorkspace } from "./skills.js";
import { expandToolGroups } from "./tool-policy.js";
import { import {
DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
@@ -239,58 +240,10 @@ const BROWSER_BRIDGES = new Map<
{ bridge: BrowserBridge; containerName: string } { bridge: BrowserBridge; containerName: string }
>(); >();
function normalizeToolList(values?: string[]) {
if (!values) return [];
return values
.map((value) => value.trim())
.filter(Boolean)
.map((value) => value.toLowerCase());
}
const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
};
function expandToolGroupEntry(entry: string): string[] {
const raw = entry.trim();
if (!raw) return [];
const lower = raw.toLowerCase();
const group = TOOL_GROUPS[lower];
if (group) return group;
return [raw];
}
function expandToolGroups(values?: string[]): string[] {
if (!values) return [];
const out: string[] = [];
for (const value of values) {
for (const expanded of expandToolGroupEntry(value)) {
const trimmed = expanded.trim();
if (!trimmed) continue;
out.push(trimmed);
}
}
return out;
}
function isToolAllowed(policy: SandboxToolPolicy, name: string) { function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(normalizeToolList(expandToolGroups(policy.deny))); const deny = new Set(expandToolGroups(policy.deny));
if (deny.has(name.toLowerCase())) return false; if (deny.has(name.toLowerCase())) return false;
const allow = normalizeToolList(expandToolGroups(policy.allow)); const allow = expandToolGroups(policy.allow);
if (allow.length === 0) return true; if (allow.length === 0) return true;
return allow.includes(name.toLowerCase()); return allow.includes(name.toLowerCase());
} }
@@ -687,8 +640,8 @@ export function formatSandboxToolPolicyBlockedMessage(params: {
}); });
if (!runtime.sandboxed) return undefined; if (!runtime.sandboxed) return undefined;
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny)); const deny = new Set(expandToolGroups(runtime.toolPolicy.deny));
const allow = normalizeToolList(runtime.toolPolicy.allow); const allow = expandToolGroups(runtime.toolPolicy.allow);
const allowSet = allow.length > 0 ? new Set(allow) : null; const allowSet = allow.length > 0 ? new Set(allow) : null;
const blockedByDeny = deny.has(tool); const blockedByDeny = deny.has(tool);
const blockedByAllow = allowSet ? !allowSet.has(tool) : false; const blockedByAllow = allowSet ? !allowSet.has(tool) : false;

116
src/agents/tool-policy.ts Normal file
View File

@@ -0,0 +1,116 @@
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
type ToolProfilePolicy = {
allow?: string[];
deny?: string[];
};
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
export const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// UI helpers
"group:ui": ["browser", "canvas"],
// Automation + infra
"group:automation": ["cron", "gateway"],
// Messaging surface
"group:messaging": ["message"],
// Nodes + device tools
"group:nodes": ["nodes"],
// All Clawdbot native tools (excludes provider plugins).
"group:clawdbot": [
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
],
};
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
minimal: {
allow: ["session_status"],
},
coding: {
allow: [
"group:fs",
"group:runtime",
"group:sessions",
"group:memory",
"image",
],
},
messaging: {
allow: [
"group:messaging",
"sessions_list",
"sessions_history",
"sessions_send",
"session_status",
],
},
full: {},
};
export function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
export function normalizeToolList(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
export function expandToolGroups(list?: string[]) {
const normalized = normalizeToolList(list);
const expanded: string[] = [];
for (const value of normalized) {
const group = TOOL_GROUPS[value];
if (group) {
expanded.push(...group);
continue;
}
expanded.push(value);
}
return Array.from(new Set(expanded));
}
export function resolveToolProfilePolicy(
profile?: string,
): ToolProfilePolicy | undefined {
if (!profile) return undefined;
const resolved = TOOL_PROFILES[profile as ToolProfileId];
if (!resolved) return undefined;
if (!resolved.allow && !resolved.deny) return undefined;
return {
allow: resolved.allow ? [...resolved.allow] : undefined,
deny: resolved.deny ? [...resolved.deny] : undefined,
};
}

View File

@@ -107,6 +107,8 @@ const FIELD_LABELS: Record<string, string> = {
"tools.audio.transcription.args": "Audio Transcription Args", "tools.audio.transcription.args": "Audio Transcription Args",
"tools.audio.transcription.timeoutSeconds": "tools.audio.transcription.timeoutSeconds":
"Audio Transcription Timeout (sec)", "Audio Transcription Timeout (sec)",
"tools.profile": "Tool Profile",
"agents.list[].tools.profile": "Agent Tool Profile",
"tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.enabled": "Enable apply_patch",
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
"gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.basePath": "Control UI Base Path",

View File

@@ -988,7 +988,11 @@ export type QueueConfig = {
drop?: QueueDropPolicy; drop?: QueueDropPolicy;
}; };
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type AgentToolsConfig = { export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
/** Per-agent elevated exec gate (can only further restrict global tools.elevated). */ /** Per-agent elevated exec gate (can only further restrict global tools.elevated). */
@@ -1053,6 +1057,8 @@ export type MemorySearchConfig = {
}; };
export type ToolsConfig = { export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
audio?: { audio?: {

View File

@@ -837,6 +837,15 @@ const ToolPolicySchema = z
}) })
.optional(); .optional();
const ToolProfileSchema = z
.union([
z.literal("minimal"),
z.literal("coding"),
z.literal("messaging"),
z.literal("full"),
])
.optional();
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers). // Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
const ElevatedAllowFromSchema = z const ElevatedAllowFromSchema = z
.record(z.string(), z.array(z.union([z.string(), z.number()]))) .record(z.string(), z.array(z.union([z.string(), z.number()])))
@@ -866,6 +875,7 @@ const AgentSandboxSchema = z
const AgentToolsSchema = z const AgentToolsSchema = z
.object({ .object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
elevated: z elevated: z
@@ -962,6 +972,7 @@ const AgentEntrySchema = z.object({
const ToolsSchema = z const ToolsSchema = z
.object({ .object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
audio: z audio: z