From fa8d9b91892f9c0ee41d6e87f3d8ac5d6e49823e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 09:59:21 +0000 Subject: [PATCH] feat: add provider-specific tool policies --- CHANGELOG.md | 3 + src/agents/pi-tools.policy.ts | 78 +++++++++++++++++-- src/agents/pi-tools.ts | 42 ++++++++-- src/agents/schema/clean-for-gemini.ts | 4 +- src/auto-reply/reply/reply-dispatcher.test.ts | 1 + src/auto-reply/tokens.ts | 7 +- src/config/schema.ts | 2 + 7 files changed, 121 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc44bd4ef..7eebf93fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). +- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. ## 2026.1.14 @@ -78,6 +79,7 @@ - Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default. - Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config. - Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer. +- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider. - Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06. - Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior. - Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4. @@ -90,6 +92,7 @@ ### Fixes - Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds. +- Tools: apply global tool allow/deny even when agent-specific tool policy is set. - Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ. - Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax. - Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg. diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 05e0dfc0f..fb8266735 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -41,24 +41,92 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy)); } +type ToolPolicyConfig = { + allow?: string[]; + deny?: string[]; + profile?: string; +}; + +function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined { + if (!config) return undefined; + const allow = Array.isArray(config.allow) ? config.allow : undefined; + const deny = Array.isArray(config.deny) ? config.deny : undefined; + if (!allow && !deny) return undefined; + return { allow, deny }; +} + +function normalizeProviderKey(value: string): string { + return value.trim().toLowerCase(); +} + +function resolveProviderToolPolicy(params: { + byProvider?: Record; + modelProvider?: string; + modelId?: string; +}): ToolPolicyConfig | undefined { + const provider = params.modelProvider?.trim(); + if (!provider || !params.byProvider) return undefined; + + const entries = Object.entries(params.byProvider); + if (entries.length === 0) return undefined; + + const lookup = new Map(); + for (const [key, value] of entries) { + const normalized = normalizeProviderKey(key); + if (!normalized) continue; + lookup.set(normalized, value); + } + + const normalizedProvider = normalizeProviderKey(provider); + const rawModelId = params.modelId?.trim().toLowerCase(); + const fullModelId = + rawModelId && !rawModelId.includes("/") + ? `${normalizedProvider}/${rawModelId}` + : rawModelId; + + const candidates = [ + ...(fullModelId ? [fullModelId] : []), + normalizedProvider, + ]; + + for (const key of candidates) { + const match = lookup.get(key); + if (match) return match; + } + return undefined; +} + export function resolveEffectiveToolPolicy(params: { config?: ClawdbotConfig; sessionKey?: string; + modelProvider?: string; + modelId?: string; }) { const agentId = params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined; const agentConfig = params.config && agentId ? resolveAgentConfig(params.config, agentId) : undefined; const agentTools = agentConfig?.tools; - const hasAgentToolPolicy = - Array.isArray(agentTools?.allow) || - Array.isArray(agentTools?.deny) || - typeof agentTools?.profile === "string"; const globalTools = params.config?.tools; + const profile = agentTools?.profile ?? globalTools?.profile; + const providerPolicy = resolveProviderToolPolicy({ + byProvider: globalTools?.byProvider, + modelProvider: params.modelProvider, + modelId: params.modelId, + }); + const agentProviderPolicy = resolveProviderToolPolicy({ + byProvider: agentTools?.byProvider, + modelProvider: params.modelProvider, + modelId: params.modelId, + }); return { agentId, - policy: hasAgentToolPolicy ? agentTools : globalTools, + globalPolicy: pickToolPolicy(globalTools), + globalProviderPolicy: pickToolPolicy(providerPolicy), + agentPolicy: pickToolPolicy(agentTools), + agentProviderPolicy: pickToolPolicy(agentProviderPolicy), profile, + providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile, }; } diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 512ee8564..6ff841a8c 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -111,21 +111,33 @@ export function createClawdbotCodingTools(options?: { const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const { agentId, - policy: effectiveToolsPolicy, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, profile, + providerProfile, } = resolveEffectiveToolPolicy({ config: options?.config, sessionKey: options?.sessionKey, + modelProvider: options?.modelProvider, + modelId: options?.modelId, }); const profilePolicy = resolveToolProfilePolicy(profile); - const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); + const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); + const scopeKey = + options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); const subagentPolicy = isSubagentSessionKey(options?.sessionKey) && options?.sessionKey ? resolveSubagentToolPolicy(options.config) : undefined; const allowBackground = isToolAllowedByPolicies("process", [ profilePolicy, - effectiveToolsPolicy, + providerProfilePolicy, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, sandbox?.tools, subagentPolicy, ]); @@ -228,11 +240,27 @@ export function createClawdbotCodingTools(options?: { hasRepliedRef: options?.hasRepliedRef, }), ]; - const toolsFiltered = profilePolicy ? filterToolsByPolicy(tools, profilePolicy) : tools; - const policyFiltered = effectiveToolsPolicy - ? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy) + const toolsFiltered = profilePolicy + ? filterToolsByPolicy(tools, profilePolicy) + : tools; + const providerProfileFiltered = providerProfilePolicy + ? filterToolsByPolicy(toolsFiltered, providerProfilePolicy) : toolsFiltered; - const sandboxed = sandbox ? filterToolsByPolicy(policyFiltered, sandbox.tools) : policyFiltered; + const globalFiltered = globalPolicy + ? filterToolsByPolicy(providerProfileFiltered, globalPolicy) + : providerProfileFiltered; + const globalProviderFiltered = globalProviderPolicy + ? filterToolsByPolicy(globalFiltered, globalProviderPolicy) + : globalFiltered; + const agentFiltered = agentPolicy + ? filterToolsByPolicy(globalProviderFiltered, agentPolicy) + : globalProviderFiltered; + const agentProviderFiltered = agentProviderPolicy + ? filterToolsByPolicy(agentFiltered, agentProviderPolicy) + : agentFiltered; + const sandboxed = sandbox + ? filterToolsByPolicy(agentProviderFiltered, sandbox.tools) + : agentProviderFiltered; const subagentFiltered = subagentPolicy ? filterToolsByPolicy(sandboxed, subagentPolicy) : sandboxed; diff --git a/src/agents/schema/clean-for-gemini.ts b/src/agents/schema/clean-for-gemini.ts index f838ed7a0..3c5712a1d 100644 --- a/src/agents/schema/clean-for-gemini.ts +++ b/src/agents/schema/clean-for-gemini.ts @@ -2,7 +2,7 @@ // This module scrubs/normalizes tool schemas to keep Gemini happy. // Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset) -const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ +export const GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ "patternProperties", "additionalProperties", "$schema", @@ -254,7 +254,7 @@ function cleanSchemaForGeminiWithDefs( const cleaned: Record = {}; for (const [key, value] of Object.entries(obj)) { - if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue; + if (GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue; if (key === "const") { cleaned.enum = [value]; diff --git a/src/auto-reply/reply/reply-dispatcher.test.ts b/src/auto-reply/reply/reply-dispatcher.test.ts index cc99130cf..3c4780505 100644 --- a/src/auto-reply/reply/reply-dispatcher.test.ts +++ b/src/auto-reply/reply/reply-dispatcher.test.ts @@ -11,6 +11,7 @@ describe("createReplyDispatcher", () => { expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); await dispatcher.waitForIdle(); expect(deliver).not.toHaveBeenCalled(); diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index 62a99c157..b503eff69 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -10,6 +10,9 @@ export function isSilentReplyText( token: string = SILENT_REPLY_TOKEN, ): boolean { if (!text) return false; - const re = new RegExp(`^\\s*${escapeRegExp(token)}(?=$|\\W)`); - return re.test(text); + const escaped = escapeRegExp(token); + const prefix = new RegExp(`^\\s*${escaped}(?=$|\\W)`); + if (prefix.test(text)) return true; + const suffix = new RegExp(`\\b${escaped}\\b\\W*$`); + return suffix.test(text); } diff --git a/src/config/schema.ts b/src/config/schema.ts index 76775cbf4..0534ca96a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -101,6 +101,8 @@ const FIELD_LABELS: Record = { "tools.audio.transcription.timeoutSeconds": "Audio Transcription Timeout (sec)", "tools.profile": "Tool Profile", "agents.list[].tools.profile": "Agent Tool Profile", + "tools.byProvider": "Tool Policy by Provider", + "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", "tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "gateway.controlUi.basePath": "Control UI Base Path",