diff --git a/Cargo.lock b/Cargo.lock index a74f3d683..08914917b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1134,7 +1134,7 @@ dependencies = [ "core-foundation-sys 0.8.7", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -2409,6 +2409,34 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "html2md" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4" +dependencies = [ + "html5ever 0.27.0", + "jni 0.19.0", + "lazy_static", + "markup5ever_rcdom", + "percent-encoding", + "regex", +] + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "html5ever" version = "0.35.0" @@ -2416,7 +2444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "markup5ever", + "markup5ever 0.35.0", "match_token", ] @@ -2960,6 +2988,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "jni" version = "0.21.1" @@ -3318,6 +3360,20 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + [[package]] name = "markup5ever" version = "0.35.0" @@ -3329,6 +3385,18 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever 0.27.0", + "markup5ever 0.12.1", + "tendril", + "xml5ever", +] + [[package]] name = "match_token" version = "0.35.0" @@ -4270,7 +4338,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni", + "jni 0.21.1", "ndk", "ndk-context", "num-derive", @@ -4339,6 +4407,7 @@ dependencies = [ "hmac 0.12.1", "hostname", "hound", + "html2md", "image", "landlock", "lettre", @@ -5631,7 +5700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a6dcd6e9823e177d15460d3cd3a413f38a2beea381f26aca1001c05cd6954ff" dependencies = [ "as_variant", - "html5ever", + "html5ever 0.35.0", "tracing", "wildmatch", ] @@ -5773,7 +5842,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys 0.8.7", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -8488,6 +8557,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/Cargo.toml b/Cargo.toml index 9a319398e..b8f5463b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,13 @@ crate-type = ["rlib"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +# Used by composio post-processing to convert Gmail HTML bodies to +# markdown. `html2md` is the canonical pure-Rust crate (no system deps); +# `htmd` (>0.5) is more actively maintained but our usage is one +# function call (`parse_html`) and the migration cost outweighs the +# upside today. Re-evaluate if html2md goes unmaintained or we need +# GFM/table fidelity beyond what html2md emits. +html2md = "0.2" reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] } tokio = { version = "1", features = ["full", "sync"] } once_cell = "1.19" diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx index b8c229385..4a2edd2cd 100644 --- a/app/src/components/composio/ComposioConnectModal.tsx +++ b/app/src/components/composio/ComposioConnectModal.tsx @@ -16,8 +16,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { authorize, deleteConnection, listConnections } from '../../lib/composio/composioApi'; -import { type ComposioConnection, deriveComposioState } from '../../lib/composio/types'; +import { + authorize, + deleteConnection, + getUserScopes, + listConnections, + setUserScopes, +} from '../../lib/composio/composioApi'; +import { + type ComposioConnection, + type ComposioUserScopePref, + deriveComposioState, +} from '../../lib/composio/types'; import { openUrl } from '../../utils/openUrl'; import type { ComposioToolkitMeta } from './toolkitMeta'; @@ -58,6 +68,16 @@ export default function ComposioConnectModal({ connection ); + // ── Scope preferences (read/write/admin) ──────────────────────── + // The pref gates which curated Composio actions the agent may call. + // We load it lazily once the toolkit is connected, so the toggles in + // the success view always reflect what the core actually has stored. + const [scopes, setScopes] = useState(null); + const [scopeError, setScopeError] = useState(null); + // Per-key in-flight flag so spamming a single toggle disables only + // that row while the RPC round-trips. + const [savingScope, setSavingScope] = useState(null); + // Escape to close useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -175,6 +195,78 @@ export default function ComposioConnectModal({ } }, [startPolling, toolkit.slug]); + // Fetch the stored scope pref whenever the modal lands in the + // 'connected' phase. Re-fetching each time we transition (rather + // than once on mount) keeps the toggles correct after a fresh OAuth + // handoff completes inside this modal. + useEffect(() => { + if (phase !== 'connected') return; + let cancelled = false; + void (async () => { + try { + const pref = await getUserScopes(toolkit.slug); + if (!cancelled) setScopes(pref); + } catch (err) { + if (!cancelled) { + const msg = err instanceof Error ? err.message : String(err); + setScopeError(`Couldn't load scope preferences: ${msg}`); + } + } + })(); + return () => { + cancelled = true; + }; + }, [phase, toolkit.slug]); + + const handleToggleScope = useCallback( + async (key: keyof ComposioUserScopePref) => { + if (!scopes || savingScope) { + console.debug( + '[composio][scopes] toggle ignored toolkit=%s key=%s reason=%s', + toolkit.slug, + key, + !scopes ? 'pref-not-loaded' : 'another-save-in-flight' + ); + return; + } + const optimistic: ComposioUserScopePref = { ...scopes, [key]: !scopes[key] }; + console.debug( + '[composio][scopes] toggle toolkit=%s key=%s old=%s new=%s', + toolkit.slug, + key, + scopes[key], + optimistic[key] + ); + setScopes(optimistic); + setSavingScope(key); + setScopeError(null); + try { + const persisted = await setUserScopes(toolkit.slug, optimistic); + console.debug( + '[composio][scopes] toggle persisted toolkit=%s key=%s pref=%o', + toolkit.slug, + key, + persisted + ); + setScopes(persisted); + } catch (err) { + // Roll back on failure so the toggle reflects reality. + const msg = err instanceof Error ? err.message : String(err); + console.error( + '[composio][scopes] toggle failed toolkit=%s key=%s error=%o', + toolkit.slug, + key, + err + ); + setScopes(scopes); + setScopeError(`Couldn't save ${key} scope: ${msg}`); + } finally { + setSavingScope(null); + } + }, + [savingScope, scopes, toolkit.slug] + ); + const handleDisconnect = useCallback(async () => { if (!activeConnection) return; setPhase('disconnecting'); @@ -299,6 +391,12 @@ export default function ComposioConnectModal({ id: {activeConnection.id}

)} + + + ); + })} + + {error &&

{error}

} + + ); +} diff --git a/app/src/lib/composio/composioApi.ts b/app/src/lib/composio/composioApi.ts index d204f6a08..a682017e5 100644 --- a/app/src/lib/composio/composioApi.ts +++ b/app/src/lib/composio/composioApi.ts @@ -18,6 +18,7 @@ import type { ComposioExecuteResponse, ComposioToolkitsResponse, ComposioToolsResponse, + ComposioUserScopePref, } from './types'; /** @@ -89,6 +90,53 @@ export async function deleteConnection(connectionId: string): Promise(raw); } +/** + * Read the per-toolkit user scope preference (read/write/admin) used + * to gate `composio_execute`. Returns the default + * `{ read: true, write: true, admin: false }` when nothing is stored. + */ +export async function getUserScopes(toolkit: string): Promise { + console.debug('[composio][scopes] → openhuman.composio_get_user_scopes toolkit=%s', toolkit); + const raw = await callCoreRpc({ + method: 'openhuman.composio_get_user_scopes', + params: { toolkit }, + }); + const pref = unwrapCliEnvelope(raw); + console.debug( + '[composio][scopes] ← openhuman.composio_get_user_scopes toolkit=%s pref=%o', + toolkit, + pref + ); + return pref; +} + +/** + * Persist a per-toolkit user scope preference. The agent will only be + * able to invoke composio actions whose classified scope is enabled + * here. + */ +export async function setUserScopes( + toolkit: string, + pref: ComposioUserScopePref +): Promise { + console.debug( + '[composio][scopes] → openhuman.composio_set_user_scopes toolkit=%s pref=%o', + toolkit, + pref + ); + const raw = await callCoreRpc({ + method: 'openhuman.composio_set_user_scopes', + params: { toolkit, ...pref }, + }); + const persisted = unwrapCliEnvelope(raw); + console.debug( + '[composio][scopes] ← openhuman.composio_set_user_scopes toolkit=%s persisted=%o', + toolkit, + persisted + ); + return persisted; +} + /** * Execute a Composio action slug (e.g. `GMAIL_SEND_EMAIL`). The core * charges the caller, tracks usage, and publishes a diff --git a/app/src/lib/composio/types.ts b/app/src/lib/composio/types.ts index 455ba6f26..aa6145a57 100644 --- a/app/src/lib/composio/types.ts +++ b/app/src/lib/composio/types.ts @@ -56,6 +56,16 @@ export interface ComposioExecuteResponse { costUsd: number; } +/** + * Per-toolkit scope preference stored in the core's KV. Default is + * `{ read: true, write: true, admin: false }`. + */ +export interface ComposioUserScopePref { + read: boolean; + write: boolean; + admin: boolean; +} + // ── UI helpers ──────────────────────────────────────────────────── /** diff --git a/src/openhuman/agent/agents/integrations_agent/agent.toml b/src/openhuman/agent/agents/integrations_agent/agent.toml index 5d0e2eba6..ff5464c71 100644 --- a/src/openhuman/agent/agents/integrations_agent/agent.toml +++ b/src/openhuman/agent/agents/integrations_agent/agent.toml @@ -13,4 +13,4 @@ omit_skills_catalog = true hint = "agentic" [tools] -named = ["composio_list_tools", "file_read"] +named = ["composio_list_tools", "file_read", "composio_execute"] diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs index 5f13d4b72..feca8dd5e 100644 --- a/src/openhuman/agent/harness/subagent_runner.rs +++ b/src/openhuman/agent/harness/subagent_runner.rs @@ -40,7 +40,6 @@ use async_trait::async_trait; use futures::stream::StreamExt; use regex::Regex; use std::collections::{HashMap, HashSet}; -use std::fmt::Write as _; use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::{Duration, Instant}; use thiserror::Error; @@ -1731,7 +1730,16 @@ fn top_k_for_toolkit(toolkit: &str) -> usize { /// correctly while staying within budget. If the model needs deeper /// schema detail it can surface the error and the orchestrator will /// clarify on the next turn. -pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { +pub(crate) fn build_text_mode_tool_instructions(_specs: &[ToolSpec]) -> String { + // The tool catalog is already rendered in the prompt's `## Tools` + // section (see `prompts::ToolsSection::build`) with full + // `Call as: NAME[arg|arg]` signatures. We previously also emitted + // an `### Available Tools` subsection here with a different + // formatting (`Parameters: name:type, ...`), which doubled the + // tool list bytes for text-mode agents — especially expensive for + // the integrations_agent toolkit-scoped spawns (~50 actions × + // 2 listings). Keep only the protocol explanation; the tool + // catalog itself comes from the prompt template. let mut out = String::new(); out.push_str("## Tool Use Protocol\n\n"); out.push_str( @@ -1740,73 +1748,11 @@ pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { in the same response if you need to run calls in parallel.\n\n", ); out.push_str( - "```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n", + "```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n", ); - out.push_str("### Available Tools\n\n"); - for spec in specs { - let _ = writeln!( - out, - "- **{}**: {}", - spec.name, - first_line_truncated(&spec.description, 120) - ); - let params_line = summarise_parameters(&spec.parameters); - if !params_line.is_empty() { - let _ = writeln!(out, " Parameters: {}", params_line); - } - } out } -/// Render a JSON-schema `parameters` object as a single-line, -/// ultra-compact parameter list — `*name: type, optName: type` for each -/// top-level property (leading `*` marks required). Deeply nested -/// shapes, enums, descriptions, and examples are all dropped. -/// -/// Kept intentionally terse: Composio action schemas routinely contain -/// per-parameter descriptions several hundred tokens long, so even a -/// "short description" per param balloons to tens of thousands of -/// tokens across a 27-tool integrations_agent toolkit and pushes the prompt -/// past the 196 607-token context window. The model can infer usage -/// from the parameter names + the tool's overall description; any -/// validation mismatch surfaces at call time and the orchestrator can -/// course-correct on the next turn. -fn summarise_parameters(params: &serde_json::Value) -> String { - let Some(props) = params.get("properties").and_then(|v| v.as_object()) else { - return String::new(); - }; - let required: std::collections::HashSet<&str> = params - .get("required") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) - .unwrap_or_default(); - - let mut parts: Vec = Vec::with_capacity(props.len()); - for (name, schema) in props { - let ty = schema.get("type").and_then(|v| v.as_str()).unwrap_or("any"); - let marker = if required.contains(name.as_str()) { - "*" - } else { - "" - }; - parts.push(format!("{marker}{name}:{ty}")); - } - parts.join(", ") -} - -/// Return the first line of `s`, trimmed and truncated to `max_chars` -/// with a trailing ellipsis when it overflows. Used to keep -/// tool/parameter descriptions on a single grep-friendly line. -fn first_line_truncated(s: &str, max_chars: usize) -> String { - let first = s.lines().next().unwrap_or("").trim(); - if first.chars().count() <= max_chars { - first.to_string() - } else { - let truncated: String = first.chars().take(max_chars).collect(); - format!("{truncated}…") - } -} - // ───────────────────────────────────────────────────────────────────────────── // Tool filtering // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index b8d6756b8..c573bf278 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -587,37 +587,49 @@ async fn fetch_connected_integrations_uncached( // Build one entry per allowlisted toolkit. Connected entries // carry their action catalogue; not-connected entries carry an // empty `tools` vec. - let mut integrations: Vec = unique_toolkits - .iter() - .map(|slug| { - let connected = connected_slugs.contains(slug); - // Anchor the prefix with an underscore so slugs that share - // a text prefix (e.g. `git` vs `github`) don't false-match - // each other's actions. `GMAIL_SEND_EMAIL` matches `gmail_`, - // not just `gmail`, so siblings stay in their own buckets. - let action_prefix = format!("{}_", slug.to_uppercase()); - let tools: Vec = if connected { - tools_by_toolkit - .iter() - .filter(|t| t.function.name.starts_with(&action_prefix)) - .map(|t| ConnectedIntegrationTool { - name: t.function.name.clone(), - description: t.function.description.clone().unwrap_or_default(), - parameters: t.function.parameters.clone(), - }) - .collect() - } else { - Vec::new() - }; + let mut integrations: Vec = Vec::with_capacity(unique_toolkits.len()); + for slug in &unique_toolkits { + let connected = connected_slugs.contains(slug); + // Anchor the prefix with an underscore so slugs that share + // a text prefix (e.g. `git` vs `github`) don't false-match + // each other's actions. `GMAIL_SEND_EMAIL` matches `gmail_`, + // not just `gmail`, so siblings stay in their own buckets. + let action_prefix = format!("{}_", slug.to_uppercase()); + let tools: Vec = if connected { + // Apply the same curated-whitelist + user-scope filter the + // meta-tool layer uses, so the integrations_agent prompt + // only advertises actions the agent is actually allowed to + // call. One pref load per toolkit (not per action). + let pref = super::providers::load_user_scope_or_default(slug).await; + let filtered: Vec<&super::types::ComposioToolSchema> = tools_by_toolkit + .iter() + .filter(|t| t.function.name.starts_with(&action_prefix)) + .filter(|t| super::providers::is_action_visible_with_pref(&t.function.name, &pref)) + .collect(); + tracing::debug!( + toolkit = %slug, + kept = filtered.len(), + "[composio][scopes] integrations prompt action set" + ); + filtered + .into_iter() + .map(|t| ConnectedIntegrationTool { + name: t.function.name.clone(), + description: t.function.description.clone().unwrap_or_default(), + parameters: t.function.parameters.clone(), + }) + .collect() + } else { + Vec::new() + }; - ConnectedIntegration { - toolkit: slug.clone(), - description: toolkit_description(slug).to_string(), - tools, - connected, - } - }) - .collect(); + integrations.push(ConnectedIntegration { + toolkit: slug.clone(), + description: toolkit_description(slug).to_string(), + tools, + connected, + }); + } integrations.sort_by(|a, b| a.toolkit.cmp(&b.toolkit)); @@ -671,10 +683,14 @@ pub async fn fetch_toolkit_actions( .await .map_err(|e| anyhow::anyhow!("list_tools failed for toolkit `{toolkit_slug}`: {e}"))?; let action_prefix = format!("{}_", toolkit_slug.to_uppercase()); + // Apply curated whitelist + user scope so spawn-time tool + // discovery agrees with the bulk path and the meta-tool layer. + let pref = super::providers::load_user_scope_or_default(toolkit_slug).await; let actions: Vec = resp .tools .into_iter() .filter(|t| t.function.name.starts_with(&action_prefix)) + .filter(|t| super::providers::is_action_visible_with_pref(&t.function.name, &pref)) .map(|t| ConnectedIntegrationTool { name: t.function.name, description: t.function.description.unwrap_or_default(), diff --git a/src/openhuman/composio/providers/catalogs.rs b/src/openhuman/composio/providers/catalogs.rs new file mode 100644 index 000000000..efcea1739 --- /dev/null +++ b/src/openhuman/composio/providers/catalogs.rs @@ -0,0 +1,2048 @@ +//! Curated catalogs for Composio toolkits that don't (yet) have a +//! native [`super::ComposioProvider`] implementation. +//! +//! These slices are consulted by [`super::catalog_for_toolkit`] alongside +//! provider-supplied catalogs (gmail, notion, github), so the meta-tool +//! layer applies the same whitelist + scope filtering. +//! +//! Slugs sourced from `https://docs.composio.dev/toolkits/.md` — +//! best-effort. Slugs that don't exist on the backend simply never +//! appear in `composio_list_tools`, so extras are harmless. + +use super::tool_scope::{CuratedTool, ToolScope}; + +// ── slack ─────────────────────────────────────────────────────────── +pub const SLACK_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SLACK_FIND_CHANNELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FIND_USERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FETCH_CONVERSATION_HISTORY", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_LIST_ALL_CHANNELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_LIST_ALL_USERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_LIST_CONVERSATIONS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FETCH_TEAM_INFO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_GET_USER_PRESENCE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_ASSISTANT_SEARCH_CONTEXT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_SEND_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_POST_MESSAGE_TO_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_SEND_MESSAGE_TO_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_CREATE_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_ADD_REACTION_TO_AN_ITEM", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_UPLOAD_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_CREATE_A_REMINDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_CREATE_USER_GROUP", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_DELETE_CHANNEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_ARCHIVE_CONVERSATION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_DELETE_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_DELETE_REMINDER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_LEAVE_CONVERSATION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_INVITE_USER_TO_WORKSPACE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_CONVERT_CHANNEL_TO_PRIVATE", + scope: ToolScope::Admin, + }, +]; + +// ── discord ───────────────────────────────────────────────────────── +pub const DISCORD_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "DISCORD_GET_MY_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_GET_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_LIST_MY_GUILDS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_GET_MY_GUILD_MEMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_INVITE_RESOLVE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_GET_GUILD_WIDGET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_LIST_MY_CONNECTIONS", + scope: ToolScope::Read, + }, +]; + +// ── googlecalendar ────────────────────────────────────────────────── +pub const GOOGLECALENDAR_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_LIST", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_FIND_EVENT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_LIST_CALENDARS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_GET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_FIND_FREE_SLOTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_GET_CALENDAR", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_LIST_ALL_CALENDARS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_CREATE_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_UPDATE_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_PATCH_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_QUICK_ADD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_MOVE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_REMOVE_ATTENDEE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_IMPORT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_DELETE_EVENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_CLEAR_CALENDAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_CALENDARS_DELETE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_DUPLICATE_CALENDAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_PATCH_CALENDAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_ACL_INSERT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_ACL_DELETE", + scope: ToolScope::Admin, + }, +]; + +// ── googledrive ───────────────────────────────────────────────────── +pub const GOOGLEDRIVE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLEDRIVE_FIND_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_LIST_FILES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_GET_FILE_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_DOWNLOAD_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_LIST_PERMISSIONS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_FIND_FOLDER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_GET_ABOUT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_UPLOAD_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_FILE_FROM_TEXT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_COPY_FILE_ADVANCED", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_MOVE_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_EDIT_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_RENAME_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_DELETE_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_UPDATE_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_DELETE_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_GOOGLE_DRIVE_DELETE_FOLDER_OR_FILE_ACTION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_EMPTY_TRASH", + scope: ToolScope::Admin, + }, +]; + +// ── googledocs ────────────────────────────────────────────────────── +pub const GOOGLEDOCS_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLEDOCS_GET_DOCUMENT_BY_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDOCS_SEARCH_DOCUMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_INSERT_TEXT_ACTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_INSERT_TABLE_ACTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_INSERT_INLINE_IMAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_UPDATE_EXISTING_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_UPDATE_DOCUMENT_SECTION_MARKDOWN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_REPLACE_ALL_TEXT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_COPY_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_HEADER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_FOOTER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_CONTENT_RANGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_HEADER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_FOOTER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_NAMED_RANGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_TABLE_ROW", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_TABLE_COLUMN", + scope: ToolScope::Admin, + }, +]; + +// ── googlesheets ──────────────────────────────────────────────────── +pub const GOOGLESHEETS_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLESHEETS_BATCH_GET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_VALUES_GET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_LOOKUP_SPREADSHEET_ROW", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_GET_SPREADSHEET_INFO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_GET_SHEET_NAMES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_SEARCH_SPREADSHEETS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_VALUES_UPDATE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPDATE_VALUES_BATCH", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_APPEND", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPSERT_ROWS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_CREATE_GOOGLE_SHEET1", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_ADD_SHEET", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_CREATE_SPREADSHEET_ROW", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_CREATE_SPREADSHEET_COLUMN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_FIND_REPLACE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_FORMAT_CELL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_SET_DATA_VALIDATION_RULE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_BATCH_CLEAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_DELETE_SHEET", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_DELETE_DIMENSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPDATE_SHEET_PROPERTIES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPDATE_SPREADSHEET_PROPERTIES", + scope: ToolScope::Admin, + }, +]; + +// ── outlook ───────────────────────────────────────────────────────── +pub const OUTLOOK_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "OUTLOOK_GET_MESSAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_MESSAGES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_SEARCH_MESSAGES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_CALENDARS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_CALENDAR_EVENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_GET_CALENDAR_EVENT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_CONTACTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_MAIL_FOLDERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_SEND_EMAIL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_SEND_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_DRAFT_REPLY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_ME_FORWARD_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CALENDAR_CREATE_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_MAIL_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_DELETE_MESSAGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_BATCH_MOVE_MESSAGES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_BATCH_UPDATE_MESSAGES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_ACCEPT_EVENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_CANCEL_EVENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_ME_CALENDAR_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_EMAIL_RULE", + scope: ToolScope::Admin, + }, +]; + +// ── microsoft_teams ───────────────────────────────────────────────── +pub const MICROSOFT_TEAMS_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_CHAT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_CHANNEL", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_TEAM_FROM_GROUP", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CHATS_GET_ALL_CHATS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_PRESENCE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_ONLINE_MEETING", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_SCHEDULE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_TEAM", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_MEETING", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ADD_TEAM_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ADD_CHAT_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_SHIFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_TIME_OFF_REQUEST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_TEAM", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_CHANNEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ARCHIVE_TEAM", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ARCHIVE_CHANNEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_TAB", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_TIME_OFF", + scope: ToolScope::Admin, + }, +]; + +// ── linear ────────────────────────────────────────────────────────── +pub const LINEAR_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "LINEAR_LIST_LINEAR_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_GET_LINEAR_ISSUE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_TEAMS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_PROJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_STATES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_SEARCH_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_GET_CYCLES_BY_TEAM_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_USERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_LABELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_GET_LINEAR_PROJECT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_UPDATE_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_ATTACHMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_LABEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_UPDATE_LINEAR_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_ISSUE_RELATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_UPDATE_LINEAR_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_DELETE_LINEAR_ISSUE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "LINEAR_REMOVE_ISSUE_LABEL", + scope: ToolScope::Admin, + }, +]; + +// ── jira ──────────────────────────────────────────────────────────── +pub const JIRA_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "JIRA_GET_ISSUE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_ALL_PROJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_FETCH_BULK_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_ISSUE_TYPES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_PROJECT_ROLES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_FIND_USERS2", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_FIELDS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_ISSUE_EDIT_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_PROJECT_VERSIONS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_CREATE_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_BULK_CREATE_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_EDIT_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ADD_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ASSIGN_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ADD_ATTACHMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_CREATE_ISSUE_LINK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ADD_WORKLOG", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_TRANSITION_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_DELETE_ISSUE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_DELETE_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_DELETE_VERSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_DELETE_WORKLOG", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_CREATE_PROJECT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_ADD_USERS_TO_PROJECT_ROLE", + scope: ToolScope::Admin, + }, +]; + +// ── trello ────────────────────────────────────────────────────────── +pub const TRELLO_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "TRELLO_GET_BOARDS_BY_ID_BOARD", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_ACTIONS_BY_ID_ACTION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_BATCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_BOARDS_ACTIONS_BY_ID_BOARD", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_MEMBERS_BOARDS_BY_ID_MEMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_BOARDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_LISTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS_ACTIONS_COMMENTS_BY_ID_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_MEMBER_TO_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_CREATE_CARD_LABEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS_ATTACHMENTS_BY_ID_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS_CHECKLISTS_BY_ID_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_CREATE_WEBHOOK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_DELETE_CARDS_BY_ID_CARD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_BOARD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_CHECKLISTS_BY_ID_CHECKLIST", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_ARCHIVE_ALL_LIST_CARDS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_CARD_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_LABELS_BY_ID_LABEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_ORGANIZATIONS_BY_ID_ORG", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_WEBHOOKS_BY_ID_WEBHOOK", + scope: ToolScope::Admin, + }, +]; + +// ── asana ─────────────────────────────────────────────────────────── +pub const ASANA_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "ASANA_GET_A_TASK", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_A_PROJECT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_MULTIPLE_TASKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_MULTIPLE_PROJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_CURRENT_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_MULTIPLE_WORKSPACES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_PORTFOLIO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_GOALS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_CUSTOM_FIELDS_FOR_WORKSPACE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_CREATE_A_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_A_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_SUBTASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_TASK_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_UPDATE_A_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_FOLLOWERS_TO_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_TAG_TO_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_PROJECT_FOR_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_TASK_DEPENDENCIES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_ATTACHMENT_FOR_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_DELETE_TASK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_PROJECT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_SECTION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_TAG", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_CUSTOM_FIELD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_MEMBERSHIP", + scope: ToolScope::Admin, + }, +]; + +// ── dropbox ───────────────────────────────────────────────────────── +pub const DROPBOX_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "DROPBOX_GET_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_FILES_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_LIST_FILE_MEMBERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_GET_SHARED_LINK_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_GET_ABOUT_ME", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_GET_SPACE_USAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_ALPHA_UPLOAD_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_CREATE_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_COPY_FILE_OR_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_CREATE_SHARED_LINK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_ADD_FILE_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_DELETE_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_DELETE_BATCH", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_ADD_TEAM_MEMBERS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_CREATE_TEAM_FOLDER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_ARCHIVE_TEAM_FOLDER", + scope: ToolScope::Admin, + }, +]; + +// ── twitter ───────────────────────────────────────────────────────── +pub const TWITTER_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "TWITTER_RECENT_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_GET_USER_BY_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_POST_LOOKUP_BY_POST_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_FOLLOWERS_BY_USER_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_FOLLOWING_BY_USER_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_BOOKMARKS_BY_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_GET_LIST_MEMBERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_FULL_ARCHIVE_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_CREATION_OF_A_POST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_RETWEET_POST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_ADD_POST_TO_BOOKMARKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_FOLLOW_USER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_MUTE_USER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_CREATE_DM_CONVERSATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_CREATE_LIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_ADD_LIST_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_POST_DELETE_BY_POST_ID", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_DELETE_LIST", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_REMOVE_LIST_MEMBER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_DELETE_DM", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_REMOVE_POST_FROM_BOOKMARKS", + scope: ToolScope::Admin, + }, +]; + +// ── spotify ───────────────────────────────────────────────────────── +pub const SPOTIFY_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SPOTIFY_GET_CURRENT_USER_S_PROFILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_USER_S_TOP_TRACKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_PLAYLIST", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_PLAYLIST_ITEMS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_RECENTLY_PLAYED_TRACKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_USER_S_SAVED_TRACKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_SEARCH_FOR_ITEM", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_AVAILABLE_DEVICES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_ADD_ITEMS_TO_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_CREATE_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_SAVE_TRACKS_FOR_CURRENT_USER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_PAUSE_PLAYBACK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_ADD_ITEM_TO_PLAYBACK_QUEUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_CHANGE_PLAYLIST_DETAILS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_REMOVE_PLAYLIST_ITEMS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SPOTIFY_REMOVE_USER_S_SAVED_TRACKS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SPOTIFY_UNFOLLOW_ARTISTS_OR_USERS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SPOTIFY_REMOVE_USER_S_SAVED_ALBUMS", + scope: ToolScope::Admin, + }, +]; + +// ── telegram ──────────────────────────────────────────────────────── +pub const TELEGRAM_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "TELEGRAM_GET_UPDATES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_HISTORY", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_MEMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_MEMBERS_COUNT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_ADMINISTRATORS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_ME", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_SEND_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_PHOTO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_LOCATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_POLL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_FORWARD_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_EDIT_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_ANSWER_CALLBACK_QUERY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_DELETE_MESSAGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TELEGRAM_CREATE_CHAT_INVITE_LINK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TELEGRAM_SET_MY_COMMANDS", + scope: ToolScope::Admin, + }, +]; + +// ── whatsapp ──────────────────────────────────────────────────────── +pub const WHATSAPP_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "WHATSAPP_GET_PHONE_NUMBERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_MESSAGE_TEMPLATES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_PHONE_NUMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_BUSINESS_PROFILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_TEMPLATE_STATUS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_MEDIA_INFO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_SEND_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_TEMPLATE_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_MEDIA", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_MEDIA_BY_ID", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_INTERACTIVE_BUTTONS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_INTERACTIVE_LIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_UPLOAD_MEDIA", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_CREATE_MESSAGE_TEMPLATE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "WHATSAPP_DELETE_MESSAGE_TEMPLATE", + scope: ToolScope::Admin, + }, +]; + +// ── shopify ───────────────────────────────────────────────────────── +pub const SHOPIFY_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SHOPIFY_BULK_QUERY_OPERATION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_PRODUCTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_ORDERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_FULFILLMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_CUSTOMERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_ORDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_PRODUCT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_DRAFT_ORDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_FULFILLMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_CUSTOMER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_PRICE_RULE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_ADJUST_INVENTORY_LEVEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_DISCOUNT_CODE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_UPDATE_PRODUCT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_CUSTOM_COLLECTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CANCEL_ORDER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_CANCEL_FULFILLMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_DELETE_PRODUCT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_BULK_DELETE_CUSTOMER_ADDRESSES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_BULK_DELETE_METAFIELDS", + scope: ToolScope::Admin, + }, +]; + +// ── stripe ────────────────────────────────────────────────────────── +pub const STRIPE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "STRIPE_GET_PAYMENT_INTENT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_LIST_INVOICES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_GET_CUSTOMER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_LIST_CHARGES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_GET_SUBSCRIPTION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_CREATE_PAYMENT_INTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_INVOICE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_CUSTOMER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_CUSTOMER_SUBSCRIPTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_CHECKOUT_SESSION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CONFIRM_PAYMENT_INTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CAPTURE_PAYMENT_INTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_ATTACH_PAYMENT_METHOD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CANCEL_SUBSCRIPTION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CANCEL_PAYMENT_INTENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CREATE_CHARGE_REFUND", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CLOSE_DISPUTE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CANCEL_SETUP_INTENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_ARCHIVE_BILLING_ALERT", + scope: ToolScope::Admin, + }, +]; + +// ── hubspot ───────────────────────────────────────────────────────── +pub const HUBSPOT_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "HUBSPOT_GET_CONTACTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_SEARCH_CONTACTS_BY_CRITERIA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_LIST_CONTACTS_PAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_GET_COMPANIES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_GET_DEALS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_GET_CRM_OBJECT_BY_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_BATCH_READ_COMPANIES_BY_PROPERTIES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_COMPANY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_DEAL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_CONTACTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_UPDATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_UPDATE_COMPANY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_OBJECT_ASSOCIATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_A_NEW_MARKETING_EMAIL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_BATCH_OF_OBJECTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_BATCH_UPDATE_QUOTES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_CONTACT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_COMPANY", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_DEAL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_CONTACTS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_COMPANIES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_DEALS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_CRM_OBJECT_BY_ID", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_PROPERTY_BY_OBJECT_TYPE_AND_NAME", + scope: ToolScope::Admin, + }, +]; + +// ── salesforce ────────────────────────────────────────────────────── +pub const SALESFORCE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SALESFORCE_RUN_SOQL_QUERY", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_EXECUTE_SOSL_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_ACCOUNT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_CAMPAIGN", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_ALL_FIELDS_FOR_OBJECT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_ALL_CUSTOM_OBJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_ACCOUNT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_LEAD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_OPPORTUNITY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_CAMPAIGN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_UPDATE_ACCOUNT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_UPDATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_UPDATE_OPPORTUNITY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ADD_OPPORTUNITY_LINE_ITEM", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ADD_CONTACT_TO_CAMPAIGN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ADD_LEAD_TO_CAMPAIGN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ASSOCIATE_CONTACT_TO_ACCOUNT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CLONE_OPPORTUNITY_WITH_PRODUCTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_ACCOUNT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_CONTACT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_LEAD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_OPPORTUNITY", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_CAMPAIGN", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_SOBJECT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_SOBJECT_COLLECTIONS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_CUSTOM_FIELD", + scope: ToolScope::Admin, + }, +]; + +// ── airtable ──────────────────────────────────────────────────────── +pub const AIRTABLE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "AIRTABLE_LIST_RECORDS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_GET_RECORD", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_GET_BASE_SCHEMA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_LIST_BASES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_LIST_COMMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_RECORDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_RECORD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_MULTIPLE_RECORDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_FIELD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_TABLE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPLOAD_ATTACHMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_FIELD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_TABLE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_DELETE_RECORD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "AIRTABLE_DELETE_MULTIPLE_RECORDS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "AIRTABLE_DELETE_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_BASE", + scope: ToolScope::Admin, + }, +]; + +// ── figma ─────────────────────────────────────────────────────────── +pub const FIGMA_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "FIGMA_GET_FILE_JSON", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_FILE_NODES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_COMMENTS_IN_A_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_CURRENT_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_DISCOVER_FIGMA_RESOURCES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_FILE_COMPONENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_LOCAL_VARIABLES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_EXTRACT_DESIGN_TOKENS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_ADD_A_COMMENT_TO_A_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "FIGMA_CREATE_DEV_RESOURCES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "FIGMA_CREATE_MODIFY_DELETE_VARIABLES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "FIGMA_DELETE_A_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "FIGMA_DELETE_A_WEBHOOK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "FIGMA_DELETE_DEV_RESOURCE", + scope: ToolScope::Admin, + }, +]; + +// ── youtube ───────────────────────────────────────────────────────── +pub const YOUTUBE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "YOUTUBE_SEARCH_YOU_TUBE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_CHANNEL_VIDEOS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_GET_CHANNEL_STATISTICS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_COMMENT_THREADS2", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_COMMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_GET_VIDEO_DETAILS_BATCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_USER_PLAYLISTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_PLAYLIST_ITEMS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_UPLOAD_VIDEO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_UPDATE_VIDEO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_CREATE_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_ADD_VIDEO_TO_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_POST_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_RATE_VIDEO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_UPDATE_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_VIDEO", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_PLAYLIST", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_PLAYLIST_ITEM", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/github/mod.rs b/src/openhuman/composio/providers/github/mod.rs new file mode 100644 index 000000000..acee9477a --- /dev/null +++ b/src/openhuman/composio/providers/github/mod.rs @@ -0,0 +1,11 @@ +//! GitHub Composio toolkit — curated tool catalog only. +//! +//! There is no native [`super::ComposioProvider`] implementation for +//! GitHub yet (no profile fetch / sync). The curated catalog here is +//! still consulted by [`super::catalog_for_toolkit`] so the meta-tool +//! layer applies the same whitelist + scope filtering it does for +//! Gmail and Notion. + +pub mod tools; + +pub use tools::GITHUB_CURATED; diff --git a/src/openhuman/composio/providers/github/tools.rs b/src/openhuman/composio/providers/github/tools.rs new file mode 100644 index 000000000..d22f7963c --- /dev/null +++ b/src/openhuman/composio/providers/github/tools.rs @@ -0,0 +1,188 @@ +//! Curated catalog of GitHub Composio actions exposed to the agent. +//! +//! Composio publishes hundreds of GitHub actions; this hand-tuned slice +//! covers the day-to-day operations an AI assistant actually performs +//! (browsing repos, reading/writing issues + PRs, code search, basic +//! workflow control) and hides the long tail of admin endpoints. + +use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope}; + +pub const GITHUB_CURATED: &[CuratedTool] = &[ + // ── Read: user / repos ────────────────────────────────────────── + CuratedTool { + slug: "GITHUB_GET_AUTHENTICATED_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_LIST_REPOSITORIES_FOR_AUTHENTICATED_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_GET_A_REPOSITORY", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_LIST_REPOSITORY_COLLABORATORS", + scope: ToolScope::Read, + }, + // ── Read: search ──────────────────────────────────────────────── + CuratedTool { + slug: "GITHUB_SEARCH_REPOSITORIES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_SEARCH_CODE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_SEARCH_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_SEARCH_USERS", + scope: ToolScope::Read, + }, + // ── Read: issues ──────────────────────────────────────────────── + CuratedTool { + slug: "GITHUB_LIST_REPOSITORY_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_GET_AN_ISSUE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_LIST_ISSUE_COMMENTS", + scope: ToolScope::Read, + }, + // ── Read: pull requests ───────────────────────────────────────── + CuratedTool { + slug: "GITHUB_LIST_PULL_REQUESTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_GET_A_PULL_REQUEST", + scope: ToolScope::Read, + }, + // CuratedTool { slug: "GITHUB_CHECK_IF_PULL_REQUEST_HAS_BEEN_MERGED", scope: ToolScope::Read }, + // ── Read: branches / commits ──────────────────────────────────── + CuratedTool { + slug: "GITHUB_LIST_BRANCHES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_GET_A_BRANCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_LIST_COMMITS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GITHUB_GET_A_COMMIT", + scope: ToolScope::Read, + }, + // CuratedTool { slug: "GITHUB_COMPARE_TWO_COMMITS", scope: ToolScope::Read }, + // // ── Read: contents / releases / gists ─────────────────────────── + // CuratedTool { slug: "GITHUB_GET_REPOSITORY_CONTENTS", scope: ToolScope::Read }, + // CuratedTool { slug: "GITHUB_LIST_RELEASES", scope: ToolScope::Read }, + // CuratedTool { slug: "GITHUB_LIST_GISTS", scope: ToolScope::Read }, + // // ── Read: workflows ───────────────────────────────────────────── + // CuratedTool { slug: "GITHUB_LIST_WORKFLOWS", scope: ToolScope::Read }, + // CuratedTool { slug: "GITHUB_LIST_WORKFLOW_RUNS", scope: ToolScope::Read }, + // ── Write: repos / contents ───────────────────────────────────── + CuratedTool { + slug: "GITHUB_CREATE_A_REPOSITORY_FOR_AUTHENTICATED_USER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_CREATE_OR_UPDATE_FILE_CONTENTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_CREATE_A_COMMIT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_COMMIT_MULTIPLE_FILES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_CREATE_A_COMMIT_COMMENT", + scope: ToolScope::Write, + }, + // ── Write: issues ─────────────────────────────────────────────── + CuratedTool { + slug: "GITHUB_CREATE_AN_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_UPDATE_AN_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_CLOSE_AN_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_CREATE_AN_ISSUE_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_ADD_LABELS_TO_AN_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_ADD_ASSIGNEES_TO_AN_ISSUE", + scope: ToolScope::Write, + }, + // ── Write: pull requests ──────────────────────────────────────── + CuratedTool { + slug: "GITHUB_CREATE_A_PULL_REQUEST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_UPDATE_A_PULL_REQUEST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_MERGE_A_PULL_REQUEST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_CREATE_A_REVIEW_FOR_A_PULL_REQUEST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GITHUB_CREATE_A_REVIEW_COMMENT_FOR_A_PULL_REQUEST", + scope: ToolScope::Write, + }, + // // ── Write: releases / gists / workflows ───────────────────────── + // CuratedTool { slug: "GITHUB_CREATE_A_RELEASE", scope: ToolScope::Write }, + CuratedTool { + slug: "GITHUB_CREATE_A_GIST", + scope: ToolScope::Write, + }, + // CuratedTool { slug: "GITHUB_CREATE_WORKFLOW_DISPATCH", scope: ToolScope::Write }, + // ── Admin: destructive / permission-changing ──────────────────── + CuratedTool { + slug: "GITHUB_DELETE_A_REPOSITORY", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GITHUB_DELETE_A_BRANCH", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GITHUB_DELETE_A_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GITHUB_ADD_A_REPOSITORY_COLLABORATOR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GITHUB_CANCEL_WORKFLOW_RUN", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/gmail/mod.rs b/src/openhuman/composio/providers/gmail/mod.rs index 23f03dac2..1fc96a33f 100644 --- a/src/openhuman/composio/providers/gmail/mod.rs +++ b/src/openhuman/composio/providers/gmail/mod.rs @@ -2,5 +2,7 @@ mod provider; mod sync; #[cfg(test)] mod tests; +pub mod tools; pub use provider::GmailProvider; +pub use tools::GMAIL_CURATED; diff --git a/src/openhuman/composio/providers/gmail/provider.rs b/src/openhuman/composio/providers/gmail/provider.rs index 05c333a17..10c9aa588 100644 --- a/src/openhuman/composio/providers/gmail/provider.rs +++ b/src/openhuman/composio/providers/gmail/provider.rs @@ -25,7 +25,8 @@ use crate::openhuman::composio::providers::sync_state::{ extract_item_id, persist_single_item, SyncState, }; use crate::openhuman::composio::providers::{ - pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, + pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome, + SyncReason, }; const ACTION_GET_PROFILE: &str = "GMAIL_GET_PROFILE"; @@ -79,6 +80,10 @@ impl ComposioProvider for GmailProvider { "gmail" } + fn curated_tools(&self) -> Option<&'static [CuratedTool]> { + Some(super::tools::GMAIL_CURATED) + } + fn sync_interval_secs(&self) -> Option { Some(15 * 60) } diff --git a/src/openhuman/composio/providers/gmail/tools.rs b/src/openhuman/composio/providers/gmail/tools.rs new file mode 100644 index 000000000..3f64ad929 --- /dev/null +++ b/src/openhuman/composio/providers/gmail/tools.rs @@ -0,0 +1,145 @@ +//! Curated catalog of Gmail Composio actions exposed to the agent. +//! +//! Composio publishes 60+ Gmail actions; this hand-tuned slice covers +//! the cases the agent actually plans for (read, compose, manage) and +//! hides the long tail of edge-case admin endpoints. + +use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope}; + +pub const GMAIL_CURATED: &[CuratedTool] = &[ + // ── Read: messages & threads ──────────────────────────────────── + CuratedTool { + slug: "GMAIL_FETCH_EMAILS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_LIST_MESSAGES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_FETCH_MESSAGE_BY_THREAD_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_LIST_THREADS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_GET_ATTACHMENT", + scope: ToolScope::Read, + }, + // ── Read: profile & settings ──────────────────────────────────── + CuratedTool { + slug: "GMAIL_GET_PROFILE", + scope: ToolScope::Read, + }, + // CuratedTool { slug: "GMAIL_GET_LANGUAGE_SETTINGS", scope: ToolScope::Read }, + // CuratedTool { slug: "GMAIL_GET_VACATION_SETTINGS", scope: ToolScope::Read }, + // CuratedTool { slug: "GMAIL_GET_AUTO_FORWARDING", scope: ToolScope::Read }, + // ── Read: contacts & people ───────────────────────────────────── + CuratedTool { + slug: "GMAIL_GET_CONTACTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_GET_PEOPLE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_SEARCH_PEOPLE", + scope: ToolScope::Read, + }, + // ── Read: drafts & labels ─────────────────────────────────────── + CuratedTool { + slug: "GMAIL_LIST_DRAFTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_GET_DRAFT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_LIST_LABELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GMAIL_GET_LABEL", + scope: ToolScope::Read, + }, + // ── Write: send & compose ─────────────────────────────────────── + CuratedTool { + slug: "GMAIL_SEND_EMAIL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GMAIL_REPLY_TO_THREAD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GMAIL_FORWARD_MESSAGE", + scope: ToolScope::Write, + }, + // ── Write: drafts ─────────────────────────────────────────────── + CuratedTool { + slug: "GMAIL_CREATE_EMAIL_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GMAIL_UPDATE_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GMAIL_SEND_DRAFT", + scope: ToolScope::Write, + }, + // ── Write: labels (create/update on user labels) ──────────────── + // CuratedTool { slug: "GMAIL_CREATE_LABEL", scope: ToolScope::Write }, + // CuratedTool { slug: "GMAIL_UPDATE_LABEL", scope: ToolScope::Write }, + // CuratedTool { slug: "GMAIL_PATCH_LABEL", scope: ToolScope::Write }, + CuratedTool { + slug: "GMAIL_ADD_LABEL_TO_EMAIL", + scope: ToolScope::Write, + }, + // ── Admin: destructive & permission-changing ──────────────────── + CuratedTool { + slug: "GMAIL_DELETE_MESSAGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GMAIL_BATCH_DELETE_MESSAGES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GMAIL_MOVE_TO_TRASH", + scope: ToolScope::Admin, + }, + // CuratedTool { slug: "GMAIL_UNTRASH_MESSAGE", scope: ToolScope::Admin }, + CuratedTool { + slug: "GMAIL_DELETE_THREAD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GMAIL_MOVE_THREAD_TO_TRASH", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GMAIL_UNTRASH_THREAD", + scope: ToolScope::Admin, + }, + // CuratedTool { slug: "GMAIL_MODIFY_THREAD_LABELS", scope: ToolScope::Admin }, + // CuratedTool { slug: "GMAIL_BATCH_MODIFY_MESSAGES", scope: ToolScope::Admin }, + CuratedTool { + slug: "GMAIL_DELETE_DRAFT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GMAIL_DELETE_LABEL", + scope: ToolScope::Admin, + }, + // CuratedTool { slug: "GMAIL_PATCH_SEND_AS", scope: ToolScope::Admin }, + // CuratedTool { slug: "GMAIL_UPDATE_IMAP_SETTINGS", scope: ToolScope::Admin }, +]; diff --git a/src/openhuman/composio/providers/mod.rs b/src/openhuman/composio/providers/mod.rs index 4bd5bacd4..7460a7e39 100644 --- a/src/openhuman/composio/providers/mod.rs +++ b/src/openhuman/composio/providers/mod.rs @@ -34,22 +34,105 @@ mod descriptions; pub(crate) mod helpers; +pub mod tool_scope; mod traits; mod types; +pub mod user_scopes; +pub mod catalogs; +pub mod github; pub mod gmail; pub mod notion; +pub mod post_process; pub mod profile; pub mod registry; pub mod sync_state; +/// Static toolkit → curated catalog map. +/// +/// This is consulted by the meta-tool layer alongside any registered +/// provider's [`ComposioProvider::curated_tools`]. It lets toolkits +/// without a full native provider (e.g. `github`, which has no sync +/// logic yet) still benefit from curated whitelisting. +/// +/// Lookup key is the lowercased prefix returned by +/// [`toolkit_from_slug`] applied to the action slug — e.g. +/// `GOOGLECALENDAR_CREATE_EVENT` → `"googlecalendar"`. Multi-segment +/// prefixes like `MICROSOFT_TEAMS_*` are matched via their first +/// segment with an extra arm. +/// Synchronous visibility check for a Composio action slug given a +/// pre-loaded user scope preference. +/// +/// Returns `true` if the action should appear in the agent's tool +/// surface — i.e. it's in the toolkit's curated whitelist (or the +/// toolkit has no curation) **and** the user's scope pref allows its +/// classification. Falls back to [`classify_unknown`] for un-curated +/// toolkits. +/// +/// Use this when the user pref has already been loaded for the +/// toolkit (typical inside a `for slug in toolkits {...}` loop where +/// awaiting once per toolkit is cheaper than once per action). +pub fn is_action_visible_with_pref(slug: &str, pref: &UserScopePref) -> bool { + let Some(toolkit) = toolkit_from_slug(slug) else { + return true; + }; + let catalog = get_provider(&toolkit) + .and_then(|p| p.curated_tools()) + .or_else(|| catalog_for_toolkit(&toolkit)); + match catalog { + Some(catalog) => match find_curated(catalog, slug) { + Some(curated) => pref.allows(curated.scope), + None => false, + }, + None => pref.allows(classify_unknown(slug)), + } +} + +pub fn catalog_for_toolkit(toolkit: &str) -> Option<&'static [CuratedTool]> { + match toolkit.trim().to_ascii_lowercase().as_str() { + // Native providers + "gmail" => Some(gmail::GMAIL_CURATED), + "notion" => Some(notion::NOTION_CURATED), + "github" => Some(github::GITHUB_CURATED), + // Catalog-only toolkits + "slack" => Some(catalogs::SLACK_CURATED), + "discord" => Some(catalogs::DISCORD_CURATED), + "googlecalendar" | "google_calendar" => Some(catalogs::GOOGLECALENDAR_CURATED), + "googledrive" | "google_drive" => Some(catalogs::GOOGLEDRIVE_CURATED), + "googledocs" | "google_docs" => Some(catalogs::GOOGLEDOCS_CURATED), + "googlesheets" | "google_sheets" => Some(catalogs::GOOGLESHEETS_CURATED), + "outlook" => Some(catalogs::OUTLOOK_CURATED), + // MICROSOFT_TEAMS_* slugs extract to "microsoft" via toolkit_from_slug. + "microsoft" | "microsoft_teams" => Some(catalogs::MICROSOFT_TEAMS_CURATED), + "linear" => Some(catalogs::LINEAR_CURATED), + "jira" => Some(catalogs::JIRA_CURATED), + "trello" => Some(catalogs::TRELLO_CURATED), + "asana" => Some(catalogs::ASANA_CURATED), + "dropbox" => Some(catalogs::DROPBOX_CURATED), + "twitter" => Some(catalogs::TWITTER_CURATED), + "spotify" => Some(catalogs::SPOTIFY_CURATED), + "telegram" => Some(catalogs::TELEGRAM_CURATED), + "whatsapp" => Some(catalogs::WHATSAPP_CURATED), + "shopify" => Some(catalogs::SHOPIFY_CURATED), + "stripe" => Some(catalogs::STRIPE_CURATED), + "hubspot" => Some(catalogs::HUBSPOT_CURATED), + "salesforce" => Some(catalogs::SALESFORCE_CURATED), + "airtable" => Some(catalogs::AIRTABLE_CURATED), + "figma" => Some(catalogs::FIGMA_CURATED), + "youtube" => Some(catalogs::YOUTUBE_CURATED), + _ => None, + } +} + pub use descriptions::toolkit_description; pub(crate) use helpers::pick_str; pub use registry::{ all_providers, get_provider, init_default_providers, register_provider, ProviderArc, }; +pub use tool_scope::{classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope}; pub use traits::ComposioProvider; pub use types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason}; +pub use user_scopes::{load_or_default as load_user_scope_or_default, UserScopePref}; #[cfg(test)] mod tests { diff --git a/src/openhuman/composio/providers/notion/mod.rs b/src/openhuman/composio/providers/notion/mod.rs index 0a94909c0..427b6fb3f 100644 --- a/src/openhuman/composio/providers/notion/mod.rs +++ b/src/openhuman/composio/providers/notion/mod.rs @@ -2,5 +2,7 @@ mod provider; mod sync; #[cfg(test)] mod tests; +pub mod tools; pub use provider::NotionProvider; +pub use tools::NOTION_CURATED; diff --git a/src/openhuman/composio/providers/notion/provider.rs b/src/openhuman/composio/providers/notion/provider.rs index f11a7df5f..a545a49fe 100644 --- a/src/openhuman/composio/providers/notion/provider.rs +++ b/src/openhuman/composio/providers/notion/provider.rs @@ -23,7 +23,8 @@ use crate::openhuman::composio::providers::sync_state::{ extract_item_id, persist_single_item, SyncState, }; use crate::openhuman::composio::providers::{ - pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, + pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome, + SyncReason, }; pub(crate) const ACTION_GET_ABOUT_ME: &str = "NOTION_GET_ABOUT_ME"; @@ -69,6 +70,10 @@ impl ComposioProvider for NotionProvider { "notion" } + fn curated_tools(&self) -> Option<&'static [CuratedTool]> { + Some(super::tools::NOTION_CURATED) + } + fn sync_interval_secs(&self) -> Option { Some(30 * 60) } diff --git a/src/openhuman/composio/providers/notion/tools.rs b/src/openhuman/composio/providers/notion/tools.rs new file mode 100644 index 000000000..760d93e9a --- /dev/null +++ b/src/openhuman/composio/providers/notion/tools.rs @@ -0,0 +1,196 @@ +//! Curated catalog of Notion Composio actions exposed to the agent. + +use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope}; + +pub const NOTION_CURATED: &[CuratedTool] = &[ + // ── Read: search & fetch ──────────────────────────────────────── + CuratedTool { + slug: "NOTION_SEARCH_NOTION_PAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_FETCH_DATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_FETCH_DATABASE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_FETCH_ROW", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_FETCH_BLOCK_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_FETCH_BLOCK_CONTENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_FETCH_ALL_BLOCK_CONTENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_FETCH_COMMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_GET_PAGE_MARKDOWN", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_GET_PAGE_PROPERTY_ACTION", + scope: ToolScope::Read, + }, + // ── Read: query & retrieve ────────────────────────────────────── + CuratedTool { + slug: "NOTION_QUERY_DATABASE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_QUERY_DATABASE_WITH_FILTER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_QUERY_DATA_SOURCE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_RETRIEVE_PAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_RETRIEVE_COMMENT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_RETRIEVE_DATABASE_PROPERTY", + scope: ToolScope::Read, + }, + // ── Read: profile / users / files ─────────────────────────────── + CuratedTool { + slug: "NOTION_LIST_USERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_GET_ABOUT_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_GET_ABOUT_ME", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_LIST_FILE_UPLOADS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_RETRIEVE_FILE_UPLOAD", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "NOTION_LIST_DATA_SOURCE_TEMPLATES", + scope: ToolScope::Read, + }, + // ── Write: create ─────────────────────────────────────────────── + CuratedTool { + slug: "NOTION_CREATE_NOTION_PAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_CREATE_DATABASE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_CREATE_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_CREATE_FILE_UPLOAD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_SEND_FILE_UPLOAD", + scope: ToolScope::Write, + }, + // ── Write: update / append ────────────────────────────────────── + CuratedTool { + slug: "NOTION_UPDATE_PAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_UPDATE_BLOCK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_UPDATE_ROW_DATABASE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_INSERT_ROW_DATABASE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_INSERT_ROW_FROM_NL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_REPLACE_PAGE_CONTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_ADD_PAGE_CONTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_ADD_MULTIPLE_PAGE_CONTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_APPEND_BLOCK_CHILDREN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_APPEND_TEXT_BLOCKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_APPEND_TASK_BLOCKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_APPEND_CODE_BLOCKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_APPEND_MEDIA_BLOCKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_APPEND_LAYOUT_BLOCKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_APPEND_TABLE_BLOCKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_DUPLICATE_PAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "NOTION_MOVE_PAGE", + scope: ToolScope::Write, + }, + // ── Admin: destructive ────────────────────────────────────────── + CuratedTool { + slug: "NOTION_DELETE_BLOCK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "NOTION_ARCHIVE_NOTION_PAGE", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/post_process.rs b/src/openhuman/composio/providers/post_process.rs new file mode 100644 index 000000000..e66cb5d2c --- /dev/null +++ b/src/openhuman/composio/providers/post_process.rs @@ -0,0 +1,188 @@ +//! Per-toolkit post-processing of Composio action responses. +//! +//! Some upstream services return content in formats that are noisy for +//! the agent's context window (e.g. Gmail's full HTML message body). +//! This module gives each toolkit a chance to rewrite the response +//! before it is handed back to the LLM — for instance converting HTML +//! email bodies to markdown so the model spends fewer tokens parsing +//! presentational markup. +//! +//! The dispatch is intentionally tiny: one function per toolkit that +//! mutates a `serde_json::Value` in place. The Composio backend keeps +//! evolving its response shapes so we walk values defensively rather +//! than hard-coding field paths. + +use serde_json::Value; + +/// Apply toolkit-specific post-processing to an `composio_execute` +/// response. Mutates `value` in place. +/// +/// Calling this with an unknown toolkit slug is a no-op. +pub fn post_process(toolkit: &str, _slug: &str, value: &mut Value) { + let key = toolkit.trim().to_ascii_lowercase(); + match key.as_str() { + "gmail" => convert_html_strings(value, "gmail"), + _ => {} + } +} + +/// Walk `value` recursively. Any string field whose contents look like +/// HTML is replaced with its markdown rendering. +/// +/// We use a substring heuristic instead of a full parse for speed — +/// if the string contains both `<` and one of a few common email +/// tags it's almost certainly HTML. False positives are harmless +/// (the html2md output for a non-HTML string is essentially the +/// stripped text). +fn convert_html_strings(value: &mut Value, toolkit: &str) { + match value { + Value::String(s) => { + if looks_like_html(s) { + let md = html2md::parse_html(s); + tracing::debug!( + toolkit, + before_bytes = s.len(), + after_bytes = md.len(), + "[composio][post-process] html → markdown" + ); + *s = md; + } + } + Value::Array(items) => { + for item in items { + convert_html_strings(item, toolkit); + } + } + Value::Object(map) => { + for (_, v) in map.iter_mut() { + convert_html_strings(v, toolkit); + } + } + _ => {} + } +} + +/// Heuristic HTML detector. Returns `true` if the string contains an +/// opening `<` followed (anywhere) by one of a handful of common tags +/// found in email bodies. Tuned to avoid matching on stray angle +/// brackets in plain-text quoted replies. +fn looks_like_html(s: &str) -> bool { + if s.len() < 4 || !s.contains('<') { + return false; + } + // Cheap substring scan; case-insensitive via lowercased haystack. + // Bound the work for very large strings — we only need the first + // few KB to make the call. Walk back to a UTF-8 char boundary so we + // never slice in the middle of a multibyte sequence (4096 may land + // mid-codepoint). + let head = if s.len() > 4096 { + let mut end = 4096; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] + } else { + s + }; + let lower = head.to_ascii_lowercase(); + const MARKERS: &[&str] = &[ + "", + "", + "

", + "
", + "", + "Hi")); + assert!(looks_like_html("

Hello world

")); + assert!(looks_like_html("
Mixed Case
")); + assert!(looks_like_html("
link")); + } + + #[test] + fn looks_like_html_does_not_panic_on_multibyte_at_boundary() { + // Build a string > 4096 bytes whose 4096th byte lands inside a + // 3-byte UTF-8 codepoint. Naive `&s[..4096]` slicing panics here. + let mut s = String::with_capacity(4200); + // 4095 ASCII bytes, then a 3-byte CJK char straddling 4095..4098. + s.push_str(&"a".repeat(4095)); + s.push('日'); + s.push_str(&"b".repeat(100)); + // Should not panic; result doesn't matter, only that it returns. + let _ = looks_like_html(&s); + } + + #[test] + fn looks_like_html_rejects_plain_text() { + assert!(!looks_like_html("just a normal email body")); + assert!(!looks_like_html("")); + assert!(!looks_like_html("a < b but no tags")); + assert!(!looks_like_html("<<>>")); // not a real tag + } + + #[test] + fn convert_walks_nested_arrays_and_objects() { + let mut v = json!({ + "messages": [ + { "id": "m1", "messageText": "

hello world

" }, + { "id": "m2", "messageText": "plain text only" } + ], + "nextPageToken": null + }); + convert_html_strings(&mut v, "gmail"); + let m1 = v["messages"][0]["messageText"].as_str().unwrap(); + // html2md emits at least the text content. + assert!(m1.contains("hello")); + assert!(m1.contains("world")); + assert!(!m1.contains("

")); + // Plain text untouched. + assert_eq!(v["messages"][1]["messageText"], "plain text only"); + } + + #[test] + fn post_process_unknown_toolkit_is_noop() { + let mut v = json!({ "body": "

hi

" }); + let original = v.clone(); + post_process("notion", "NOTION_FETCH_DATA", &mut v); + assert_eq!(v, original); + } + + #[test] + fn post_process_gmail_converts_html_fields() { + let mut v = json!({ + "data": { "html": "

Hi

body

", "subject": "no tags here" } + }); + post_process("gmail", "GMAIL_FETCH_EMAILS", &mut v); + let html_field = v["data"]["html"].as_str().unwrap(); + assert!(!html_field.contains("

")); + assert!(html_field.contains("Hi")); + assert_eq!(v["data"]["subject"], "no tags here"); + } +} diff --git a/src/openhuman/composio/providers/tool_scope.rs b/src/openhuman/composio/providers/tool_scope.rs new file mode 100644 index 000000000..558641fdc --- /dev/null +++ b/src/openhuman/composio/providers/tool_scope.rs @@ -0,0 +1,185 @@ +//! Per-action scope classification (read / write / admin) plus the +//! [`CuratedTool`] catalog type that providers use to whitelist the +//! actions they want surfaced to the agent. +//! +//! Composio publishes 60+ actions per toolkit; most are noise for the +//! agent's planning loop. Each provider exports a hand-curated +//! [`CuratedTool`] slice via [`super::ComposioProvider::curated_tools`] +//! that pares the surface down to a useful subset and tags every action +//! with a [`ToolScope`] so per-user scope preferences can gate execution. + +use serde::{Deserialize, Serialize}; + +/// Classification of how invasive an action is. +/// +/// Used both to filter the agent's visible tool list and to enforce +/// per-user scope preferences at execution time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ToolScope { + /// Pure reads — `GET` / `FETCH` / `LIST` / `SEARCH` / `GET_PROFILE`. + Read, + /// Side-effectful actions that create or mutate user data — + /// `SEND` / `CREATE` / `UPDATE` / `REPLY` / `APPEND`. + Write, + /// Destructive or permission-changing actions — `DELETE` / `TRASH` / + /// `REMOVE` / `MODIFY_LABELS` / `SHARE`. + Admin, +} + +impl ToolScope { + pub fn as_str(self) -> &'static str { + match self { + ToolScope::Read => "read", + ToolScope::Write => "write", + ToolScope::Admin => "admin", + } + } +} + +/// One curated entry in a provider's tool catalog. +/// +/// `slug` is the Composio action slug as returned by `composio_list_tools` +/// (e.g. `"GMAIL_SEND_EMAIL"`). `scope` controls whether the action is +/// gated by the user's read / write / admin preference. +#[derive(Debug, Clone, Copy)] +pub struct CuratedTool { + pub slug: &'static str, + pub scope: ToolScope, +} + +/// Heuristic fallback when we need to gate a tool that isn't in any +/// provider's curated list. Prefer the curated classification when +/// available; only call this when [`super::ComposioProvider::curated_tools`] +/// returned `None` or didn't include the slug. +pub fn classify_unknown(slug: &str) -> ToolScope { + let upper = slug.to_ascii_uppercase(); + // Admin verbs are checked first so e.g. `MODIFY_LABELS` doesn't slip + // into the Write bucket on the `UPDATE`-substring rule. + const ADMIN: &[&str] = &[ + "DELETE", + "TRASH", + "REMOVE", + "MODIFY_LABELS", + "SHARE", + "REVOKE", + "DESTROY", + ]; + const WRITE: &[&str] = &[ + "SEND", "CREATE", "UPDATE", "REPLY", "APPEND", "INSERT", "ADD", "POST", "PATCH", "WRITE", + "DRAFT", + ]; + if ADMIN.iter().any(|kw| upper.contains(kw)) { + return ToolScope::Admin; + } + if WRITE.iter().any(|kw| upper.contains(kw)) { + return ToolScope::Write; + } + ToolScope::Read +} + +/// Look up a slug inside a curated catalog. +pub fn find_curated<'a>(catalog: &'a [CuratedTool], slug: &str) -> Option<&'a CuratedTool> { + catalog.iter().find(|t| t.slug.eq_ignore_ascii_case(slug)) +} + +/// Extract the toolkit slug from a Composio action slug. +/// +/// All Composio action slugs follow the convention `__…` +/// (e.g. `GMAIL_SEND_EMAIL` → `gmail`). Returns the lowercased prefix +/// before the first underscore, or `None` if the slug has no underscore. +/// +/// **Assumption:** toolkit identifiers themselves do not contain +/// underscores. Composio honours this for every action we curate today +/// (`gmail`, `notion`, `googlecalendar`, …). The one historical +/// exception — `MICROSOFT_TEAMS_*` — extracts to `"microsoft"`, and +/// [`super::catalog_for_toolkit`] handles the alias by mapping both +/// `"microsoft"` and `"microsoft_teams"` to the same catalog. +/// +/// If a future toolkit ships with a multi-word slug containing an +/// underscore in the *toolkit* portion (e.g. a hypothetical +/// `FOO_BAR_LIST_ITEMS` whose toolkit is `foo_bar`), this naive split +/// must be revised — either by consulting a known-toolkits map or by +/// taking the longest-matching prefix from the registered catalogs. +pub fn toolkit_from_slug(slug: &str) -> Option { + let trimmed = slug.trim(); + if trimmed.is_empty() { + return None; + } + let prefix = trimmed.split('_').next()?; + if prefix.is_empty() { + None + } else { + Some(prefix.to_ascii_lowercase()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_unknown_picks_admin_for_destructive_verbs() { + assert_eq!(classify_unknown("GMAIL_DELETE_EMAIL"), ToolScope::Admin); + assert_eq!(classify_unknown("GMAIL_TRASH_EMAIL"), ToolScope::Admin); + assert_eq!(classify_unknown("GMAIL_MODIFY_LABELS"), ToolScope::Admin); + } + + #[test] + fn classify_unknown_picks_write_for_mutating_verbs() { + assert_eq!(classify_unknown("GMAIL_SEND_EMAIL"), ToolScope::Write); + assert_eq!(classify_unknown("NOTION_CREATE_PAGE"), ToolScope::Write); + assert_eq!(classify_unknown("NOTION_UPDATE_PAGE"), ToolScope::Write); + } + + #[test] + fn classify_unknown_defaults_to_read() { + assert_eq!(classify_unknown("GMAIL_FETCH_EMAILS"), ToolScope::Read); + assert_eq!(classify_unknown("NOTION_SEARCH"), ToolScope::Read); + assert_eq!(classify_unknown("GMAIL_GET_PROFILE"), ToolScope::Read); + } + + #[test] + fn classify_unknown_admin_takes_precedence_over_write() { + // MODIFY_LABELS contains no write verb but DELETE_DRAFT does — make + // sure the admin check wins. + assert_eq!(classify_unknown("GMAIL_DELETE_DRAFT"), ToolScope::Admin); + } + + #[test] + fn toolkit_from_slug_extracts_lowercase_prefix() { + assert_eq!( + toolkit_from_slug("GMAIL_SEND_EMAIL"), + Some("gmail".to_string()) + ); + assert_eq!( + toolkit_from_slug("NOTION_FETCH_DATA"), + Some("notion".to_string()) + ); + assert_eq!(toolkit_from_slug(""), None); + assert_eq!( + toolkit_from_slug("noUnderscore"), + Some("nounderscore".into()) + ); + } + + #[test] + fn find_curated_is_case_insensitive() { + let catalog = &[CuratedTool { + slug: "GMAIL_SEND_EMAIL", + scope: ToolScope::Write, + }]; + assert!(find_curated(catalog, "gmail_send_email").is_some()); + assert!(find_curated(catalog, "GMAIL_SEND_EMAIL").is_some()); + assert!(find_curated(catalog, "GMAIL_DELETE_EMAIL").is_none()); + } + + #[test] + fn tool_scope_serializes_lowercase() { + assert_eq!(serde_json::to_string(&ToolScope::Read).unwrap(), "\"read\""); + assert_eq!( + serde_json::to_string(&ToolScope::Admin).unwrap(), + "\"admin\"" + ); + } +} diff --git a/src/openhuman/composio/providers/traits.rs b/src/openhuman/composio/providers/traits.rs index c1aec19e4..f053380e8 100644 --- a/src/openhuman/composio/providers/traits.rs +++ b/src/openhuman/composio/providers/traits.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; +use super::tool_scope::CuratedTool; use super::types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason}; /// Native provider implementation for a specific Composio toolkit. @@ -22,6 +23,22 @@ pub trait ComposioProvider: Send + Sync { Some(15 * 60) } + /// Curated whitelist of Composio actions this provider considers + /// useful for the agent, classified by [`super::tool_scope::ToolScope`]. + /// + /// When `Some(&[...])`, the meta-tool layer hides every action not + /// in this list from `composio_list_tools` and rejects execution of + /// any slug not in this list (or whose scope is disabled in the + /// user's pref). + /// + /// Default: `None` — toolkits without a curated catalog (e.g. + /// integrations not yet hand-tuned) pass through all actions and + /// rely on the [`super::tool_scope::classify_unknown`] heuristic for + /// scope gating. + fn curated_tools(&self) -> Option<&'static [CuratedTool]> { + None + } + /// Fetch a normalized user profile for the current connection in /// `ctx`. Most providers implement this by calling a provider /// "get profile / about me" action via [`super::super::ops::composio_execute`]. diff --git a/src/openhuman/composio/providers/user_scopes.rs b/src/openhuman/composio/providers/user_scopes.rs new file mode 100644 index 000000000..f17ea5ddb --- /dev/null +++ b/src/openhuman/composio/providers/user_scopes.rs @@ -0,0 +1,201 @@ +//! Per-user, per-toolkit scope preferences. +//! +//! For each Composio toolkit a user has connected (or could connect), +//! we store a [`UserScopePref`] that records whether the agent is +//! allowed to call **read**, **write**, and / or **admin**-classified +//! actions for that toolkit. Defaults are `read=true, write=true, +//! admin=false` — the agent can use the integration productively out of +//! the box, but destructive / permission-changing actions require +//! explicit opt-in. +//! +//! Storage uses the same KV surface as [`super::sync_state`] +//! (`MemoryClient::kv_get` / `kv_set`) under a dedicated namespace so +//! prefs survive process restarts without any extra file management. + +use serde::{Deserialize, Serialize}; + +use crate::openhuman::memory::MemoryClientRef; + +use super::tool_scope::ToolScope; + +/// KV namespace for scope prefs. Separate from `composio-sync-state` so +/// the two never collide. +const KV_NAMESPACE: &str = "composio-user-scopes"; + +/// Per-toolkit scope preference. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserScopePref { + #[serde(default = "default_true")] + pub read: bool, + #[serde(default = "default_true")] + pub write: bool, + #[serde(default)] + pub admin: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for UserScopePref { + fn default() -> Self { + Self { + read: true, + write: true, + admin: false, + } + } +} + +impl UserScopePref { + /// Returns `true` if the given scope is enabled in this preference. + pub fn allows(&self, scope: ToolScope) -> bool { + match scope { + ToolScope::Read => self.read, + ToolScope::Write => self.write, + ToolScope::Admin => self.admin, + } + } +} + +fn kv_key(toolkit: &str) -> String { + toolkit.trim().to_ascii_lowercase() +} + +/// Load the scope pref for `toolkit`. Returns the default +/// (`read+write`, no `admin`) when nothing is stored or when the KV +/// store can't be reached — the agent should always be able to use +/// connected integrations productively, even if pref storage is +/// temporarily unavailable. +pub async fn load(memory: &MemoryClientRef, toolkit: &str) -> UserScopePref { + let key = kv_key(toolkit); + if key.is_empty() { + return UserScopePref::default(); + } + match memory.kv_get(Some(KV_NAMESPACE), &key).await { + Ok(Some(value)) => match serde_json::from_value::(value) { + Ok(pref) => { + tracing::debug!( + toolkit = %key, + read = pref.read, + write = pref.write, + admin = pref.admin, + "[composio][scopes] pref loaded" + ); + pref + } + Err(e) => { + tracing::warn!( + toolkit = %key, + error = %e, + "[composio][scopes] pref deserialize failed, falling back to default" + ); + UserScopePref::default() + } + }, + Ok(None) => { + tracing::debug!( + toolkit = %key, + "[composio][scopes] no pref stored, using default (read+write)" + ); + UserScopePref::default() + } + Err(e) => { + tracing::warn!( + toolkit = %key, + error = %e, + "[composio][scopes] kv_get failed, falling back to default" + ); + UserScopePref::default() + } + } +} + +/// Persist a scope pref for `toolkit`. +pub async fn save( + memory: &MemoryClientRef, + toolkit: &str, + pref: UserScopePref, +) -> Result<(), String> { + let key = kv_key(toolkit); + if key.is_empty() { + return Err("user_scopes: toolkit must not be empty".to_string()); + } + let value = serde_json::to_value(pref) + .map_err(|e| format!("[composio][scopes] serialize failed: {e}"))?; + memory.kv_set(Some(KV_NAMESPACE), &key, &value).await?; + tracing::info!( + toolkit = %key, + read = pref.read, + write = pref.write, + admin = pref.admin, + "[composio][scopes] pref saved" + ); + Ok(()) +} + +/// Best-effort load that resolves the active memory client itself. Used +/// from the meta-tool layer where we don't have a `MemoryClientRef` in +/// scope. Falls back to the default pref when memory isn't initialised. +pub async fn load_or_default(toolkit: &str) -> UserScopePref { + match crate::openhuman::memory::global::client_if_ready() { + Some(client) => load(&client, toolkit).await, + None => { + // Match the normalized key form `load()` logs so traces + // grouped by `key` correlate across both code paths. + let key = kv_key(toolkit); + tracing::debug!( + toolkit = %toolkit, + key = %key, + "[composio][scopes] memory not ready, using default pref" + ); + UserScopePref::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_read_write_no_admin() { + let p = UserScopePref::default(); + assert!(p.read); + assert!(p.write); + assert!(!p.admin); + } + + #[test] + fn allows_matches_scope() { + let p = UserScopePref { + read: true, + write: false, + admin: false, + }; + assert!(p.allows(ToolScope::Read)); + assert!(!p.allows(ToolScope::Write)); + assert!(!p.allows(ToolScope::Admin)); + } + + #[test] + fn round_trip_serde() { + let p = UserScopePref { + read: true, + write: true, + admin: true, + }; + let v = serde_json::to_value(p).unwrap(); + let back: UserScopePref = serde_json::from_value(v).unwrap(); + assert_eq!(p, back); + } + + #[test] + fn missing_fields_default_to_true_for_read_write() { + // Forward-compat: if we ever drop a field, existing stored + // documents still deserialize sensibly. + let v = serde_json::json!({}); + let p: UserScopePref = serde_json::from_value(v).unwrap(); + assert_eq!(p, UserScopePref::default()); + } +} diff --git a/src/openhuman/composio/schemas.rs b/src/openhuman/composio/schemas.rs index be498e9a4..d137d1cea 100644 --- a/src/openhuman/composio/schemas.rs +++ b/src/openhuman/composio/schemas.rs @@ -51,6 +51,8 @@ pub fn all_controller_schemas() -> Vec { schemas("get_user_profile"), schemas("sync"), schemas("list_trigger_history"), + schemas("get_user_scopes"), + schemas("set_user_scopes"), ] } @@ -100,6 +102,14 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("list_trigger_history"), handler: handle_list_trigger_history, }, + RegisteredController { + schema: schemas("get_user_scopes"), + handler: handle_get_user_scopes, + }, + RegisteredController { + schema: schemas("set_user_scopes"), + handler: handle_set_user_scopes, + }, ] } @@ -334,6 +344,65 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "get_user_scopes" => ControllerSchema { + namespace: "composio", + function: "get_user_scopes", + description: + "Read the per-toolkit user scope preference (read/write/admin) used to gate \ + composio_execute. Defaults to {read:true, write:true, admin:false} when no \ + pref is stored.", + inputs: vec![FieldSchema { + name: "toolkit", + ty: TypeSchema::String, + comment: "Toolkit slug, e.g. 'gmail' or 'notion'.", + required: true, + }], + outputs: vec![FieldSchema { + name: "pref", + ty: TypeSchema::Json, + comment: "Scope pref: { read: bool, write: bool, admin: bool }.", + required: true, + }], + }, + "set_user_scopes" => ControllerSchema { + namespace: "composio", + function: "set_user_scopes", + description: + "Persist a per-toolkit user scope preference. The agent will only be able to \ + invoke composio actions whose classified scope is enabled here.", + inputs: vec![ + FieldSchema { + name: "toolkit", + ty: TypeSchema::String, + comment: "Toolkit slug, e.g. 'gmail' or 'notion'.", + required: true, + }, + FieldSchema { + name: "read", + ty: TypeSchema::Bool, + comment: "Allow read-classified actions (GET / FETCH / LIST / SEARCH).", + required: true, + }, + FieldSchema { + name: "write", + ty: TypeSchema::Bool, + comment: "Allow write-classified actions (SEND / CREATE / UPDATE).", + required: true, + }, + FieldSchema { + name: "admin", + ty: TypeSchema::Bool, + comment: "Allow admin-classified actions (DELETE / TRASH / SHARE).", + required: true, + }, + ], + outputs: vec![FieldSchema { + name: "pref", + ty: TypeSchema::Json, + comment: "Stored scope pref: { read, write, admin }.", + required: true, + }], + }, _other => ControllerSchema { namespace: "composio", function: "unknown", @@ -459,6 +528,94 @@ fn handle_sync(params: Map) -> ControllerFuture { }) } +fn handle_get_user_scopes(params: Map) -> ControllerFuture { + Box::pin(async move { + let toolkit = match read_required_non_empty(¶ms, "toolkit") { + Ok(v) => v, + Err(e) => { + tracing::warn!( + method = "composio.get_user_scopes", + error = %e, + "[composio:scopes] missing required `toolkit` param" + ); + return Err(e); + } + }; + tracing::debug!( + method = "composio.get_user_scopes", + toolkit = %toolkit, + "[composio:scopes] handler entry" + ); + let pref = super::providers::user_scopes::load_or_default(&toolkit).await; + tracing::debug!( + method = "composio.get_user_scopes", + toolkit = %toolkit, + read = pref.read, + write = pref.write, + admin = pref.admin, + "[composio:scopes] handler exit" + ); + to_json(crate::rpc::RpcOutcome::new(pref, vec![])) + }) +} + +fn handle_set_user_scopes(params: Map) -> ControllerFuture { + Box::pin(async move { + let toolkit = match read_required_non_empty(¶ms, "toolkit") { + Ok(v) => v, + Err(e) => { + tracing::warn!( + method = "composio.set_user_scopes", + error = %e, + "[composio:scopes] missing required `toolkit` param" + ); + return Err(e); + } + }; + let read: bool = read_required(¶ms, "read")?; + let write: bool = read_required(¶ms, "write")?; + let admin: bool = read_required(¶ms, "admin")?; + let pref = super::providers::UserScopePref { read, write, admin }; + tracing::debug!( + method = "composio.set_user_scopes", + toolkit = %toolkit, + read = pref.read, + write = pref.write, + admin = pref.admin, + "[composio:scopes] handler entry" + ); + let memory = match crate::openhuman::memory::global::client_if_ready() { + Some(m) => m, + None => { + tracing::error!( + method = "composio.set_user_scopes", + toolkit = %toolkit, + "[composio:scopes] memory client not initialised — cannot persist pref" + ); + return Err("memory client not initialised".to_string()); + } + }; + if let Err(e) = super::providers::user_scopes::save(&memory, &toolkit, pref).await { + tracing::error!( + method = "composio.set_user_scopes", + toolkit = %toolkit, + error = %e, + "[composio:scopes] save failed" + ); + return Err(e); + } + tracing::debug!( + method = "composio.set_user_scopes", + toolkit = %toolkit, + read = pref.read, + write = pref.write, + admin = pref.admin, + "[composio:scopes] handler exit" + ); + to_json(crate::rpc::RpcOutcome::new(pref, vec![])) + }) +} + // ── Param helpers ─────────────────────────────────────────────────── fn read_required(params: &Map, key: &str) -> Result { @@ -535,6 +692,8 @@ mod tests { "get_user_profile", "sync", "list_trigger_history", + "get_user_scopes", + "set_user_scopes", ]; for k in keys { let s = schemas(k); diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index 49d4350c9..32ace6bd4 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -27,6 +27,106 @@ use serde_json::{json, Value}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; use super::client::ComposioClient; +use super::providers::{ + catalog_for_toolkit, classify_unknown, find_curated, get_provider, load_user_scope_or_default, + post_process, toolkit_from_slug, ToolScope, UserScopePref, +}; + +/// Decision returned by [`evaluate_tool_visibility`]. +enum ToolDecision { + /// Action is curated for this toolkit and user scope allows it. + Allow, + /// Action exists in the curated list but the user's scope blocks + /// it. `scope` is the curated classification. + BlockedByScope { scope: ToolScope }, + /// Action is not in the toolkit's curated whitelist (and the + /// toolkit has one). Hidden / rejected. + NotCurated, + /// Toolkit has no curated catalog — pass through, but still gate by + /// the user scope using the [`classify_unknown`] heuristic. + PassthroughCheckScope { scope: ToolScope }, +} + +/// Decide whether a Composio action slug should be visible / executable +/// for the current user, given the registered provider's curated list +/// (if any) and the user's stored scope preference. +async fn evaluate_tool_visibility(slug: &str) -> ToolDecision { + let Some(toolkit) = toolkit_from_slug(slug) else { + // Unparseable slug — let the backend return its own error. + return ToolDecision::Allow; + }; + let pref = load_user_scope_or_default(&toolkit).await; + // Prefer a registered provider's curated list; fall back to the + // static toolkit→catalog map so toolkits without a native provider + // (e.g. github) still get whitelist enforcement. + let catalog = get_provider(&toolkit) + .and_then(|p| p.curated_tools()) + .or_else(|| catalog_for_toolkit(&toolkit)); + match catalog { + Some(catalog) => match find_curated(catalog, slug) { + Some(curated) if pref.allows(curated.scope) => ToolDecision::Allow, + Some(curated) => ToolDecision::BlockedByScope { + scope: curated.scope, + }, + None => ToolDecision::NotCurated, + }, + None => { + let scope = classify_unknown(slug); + if pref.allows(scope) { + ToolDecision::PassthroughCheckScope { scope } + } else { + ToolDecision::BlockedByScope { scope } + } + } + } +} + +/// Filter a freshly-fetched [`super::types::ComposioToolsResponse`] in +/// place: drop tools that aren't curated for their toolkit and tools +/// whose scope is disabled in the user's pref. +async fn filter_list_tools_response(resp: &mut super::types::ComposioToolsResponse) { + let before = resp.tools.len(); + // Compute keep/drop decisions sequentially (the await means we + // can't fold this into a single sync `retain` closure). Then zip + // each tool with its decision and collect the survivors — clearer + // than juggling a parallel index alongside `Vec::retain`. + let mut keep: Vec = Vec::with_capacity(before); + for t in &resp.tools { + let decision = evaluate_tool_visibility(&t.function.name).await; + keep.push(matches!( + decision, + ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. } + )); + } + let drained: Vec<_> = resp.tools.drain(..).collect(); + resp.tools = drained + .into_iter() + .zip(keep) + .filter_map(|(tool, keep_it)| if keep_it { Some(tool) } else { None }) + .collect(); + let after = resp.tools.len(); + if after != before { + tracing::debug!( + before, + after, + dropped = before - after, + "[composio][scopes] composio_list_tools filtered" + ); + } +} + +/// Format a user-facing error message for a scope-blocked execution. +fn scope_error_message(slug: &str, scope: ToolScope, pref: UserScopePref) -> String { + format!( + "composio_execute: action `{slug}` is classified `{}` and is disabled in your \ + current scope preferences (read={}, write={}, admin={}). Update the toolkit's \ + scope preference (composio_set_user_scopes) to enable this category.", + scope.as_str(), + pref.read, + pref.write, + pref.admin, + ) +} // ── composio_list_toolkits ────────────────────────────────────────── @@ -258,9 +358,12 @@ impl Tool for ComposioListToolsTool { }); tracing::debug!(?toolkits, "[composio] tool list_tools.execute"); match self.client.list_tools(toolkits.as_deref()).await { - Ok(resp) => Ok(ToolResult::success( - serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), - )), + Ok(mut resp) => { + filter_list_tools_response(&mut resp).await; + Ok(ToolResult::success( + serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), + )) + } Err(e) => Ok(ToolResult::error(format!( "composio_list_tools failed: {e}" ))), @@ -330,11 +433,41 @@ impl Tool for ComposioExecuteTool { } let arguments = args.get("arguments").cloned(); tracing::debug!(tool = %tool, "[composio] tool execute.execute"); + + // Enforce per-user scope preferences before delegating to backend. + match evaluate_tool_visibility(&tool).await { + ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. } => {} + ToolDecision::BlockedByScope { scope } => { + let toolkit = toolkit_from_slug(&tool).unwrap_or_default(); + let pref = load_user_scope_or_default(&toolkit).await; + let msg = scope_error_message(&tool, scope, pref); + tracing::info!( + tool = %tool, + toolkit = %toolkit, + scope = scope.as_str(), + "[composio][scopes] execute blocked by user scope pref" + ); + return Ok(ToolResult::error(msg)); + } + ToolDecision::NotCurated => { + let toolkit = toolkit_from_slug(&tool).unwrap_or_default(); + tracing::info!( + tool = %tool, + toolkit = %toolkit, + "[composio][scopes] execute blocked: action not in curated whitelist" + ); + return Ok(ToolResult::error(format!( + "composio_execute: action `{tool}` is not in the curated whitelist for \ + toolkit `{toolkit}`. Use composio_list_tools to see available actions." + ))); + } + } + let started = std::time::Instant::now(); let res = self.client.execute_tool(&tool, arguments.clone()).await; let elapsed_ms = started.elapsed().as_millis() as u64; match res { - Ok(resp) => { + Ok(mut resp) => { crate::core::event_bus::publish_global( crate::core::event_bus::DomainEvent::ComposioActionExecuted { tool: tool.clone(), @@ -344,6 +477,14 @@ impl Tool for ComposioExecuteTool { elapsed_ms, }, ); + // Per-toolkit post-processing of the upstream payload + // (e.g. gmail HTML → markdown). Only run on successful + // responses; errors are passed through verbatim. + if resp.successful { + if let Some(toolkit) = toolkit_from_slug(&tool) { + post_process::post_process(&toolkit, &tool, &mut resp.data); + } + } Ok(ToolResult::success( serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), ))