feat(tools): add tool profiles and group shorthands
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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”
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
116
src/agents/tool-policy.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user