diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index cdcafc27..9317bcae 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -52,6 +52,17 @@ export const PermissionRequest = ({ : JSON.stringify(toolCall.arguments)) : null; const filePermission = permission?.kind === "file" ? permission : null; + const externalAction = + permission?.kind === "composio" + ? { label: "Composio action", detail: `${permission.toolSlug} (${permission.toolkitSlug})` } + : permission?.kind === "mcp" + ? { + label: "MCP tool", + detail: permission.serverName + ? `${permission.toolName} on ${permission.serverName}` + : permission.toolName, + } + : null; const isResponded = response !== null; const isApproved = response === 'approve'; @@ -139,6 +150,16 @@ export const PermissionRequest = ({ )} + {showDetails && externalAction && ( +
+

+ {externalAction.label} +

+

+ {externalAction.detail} +

+
+ )} {showDetails && !command && !filePermission && toolCall.arguments && (

diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 35dbefa0..5f1e4402 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -700,6 +700,15 @@ interface IPermissionChecker { Tool-specific policy, command analysis, filesystem boundaries, and allowlists remain outside the loop. +The real checker bridge implements that policy from per-tool declarations in +the builtin catalog (`tools/types.ts`) and fails closed: any tool without an +explicit `"none"` declaration — undeclared builtins, `mcp:*` attachments on +user agents, unknown toolId families — requires permission. Composio and MCP +executions produce family-specific request payloads (the shared +`ToolPermissionMetadata` kinds); everything else falls back to a generic +`{kind: "tool"}` request. The audited set of gated builtins is pinned by a +catalog test. + When automatic permission is enabled, the injected classifier handles all permission-required calls from one model response in one batch: diff --git a/apps/x/packages/core/src/runtime/tools/catalog.test.ts b/apps/x/packages/core/src/runtime/tools/catalog.test.ts index 54981d0b..d5d5a37b 100644 --- a/apps/x/packages/core/src/runtime/tools/catalog.test.ts +++ b/apps/x/packages/core/src/runtime/tools/catalog.test.ts @@ -210,3 +210,35 @@ describe("BuiltinTools catalog key order", () => { expect(Object.keys(BuiltinTools)).toEqual(HISTORICAL_KEY_ORDER); }); }); + +describe("BuiltinTools permission audit", () => { + // Pins the set of gated builtins so policy changes are always intentional: + // adding a tool with anything other than "none" (or forgetting that a new + // side-effecting tool should be gated) must show up in this diff. The + // checker independently fails closed for undeclared tools. + it("gates exactly the audited set of builtins", () => { + const gated = Object.entries(BuiltinTools) + .filter(([, tool]) => tool.permission !== "none") + .map(([name, tool]) => [name, tool.permission]); + expect(Object.fromEntries(gated)).toEqual({ + "file-readText": "file-boundary", + "file-writeText": "file-boundary", + "file-editText": "file-boundary", + "file-list": "file-boundary", + "file-glob": "file-boundary", + "file-grep": "file-boundary", + "file-exists": "file-boundary", + "file-stat": "file-boundary", + "file-copy": "file-boundary", + "file-rename": "file-boundary", + "file-remove": "file-boundary", + "file-mkdir": "file-boundary", + parseFile: "file-boundary", + LLMParse: "file-boundary", + executeCommand: "command-allowlist", + addMcpServer: "prompt", + executeMcpTool: "mcp-execute", + "composio-execute-tool": "composio-execute", + }); + }); +}); diff --git a/apps/x/packages/core/src/runtime/tools/catalog.ts b/apps/x/packages/core/src/runtime/tools/catalog.ts index dd5b97ea..db452621 100644 --- a/apps/x/packages/core/src/runtime/tools/catalog.ts +++ b/apps/x/packages/core/src/runtime/tools/catalog.ts @@ -37,6 +37,7 @@ export { coalesceCodeRunEvents } from "./domains/code.js"; // handler. export const BuiltinTools: z.infer = { loadSkill: { + permission: "none", description: "Load a Rowboat skill definition into context by fetching its guidance string", inputSchema: z.object({ skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/runtime/assembly/skills/workflow-run-ops/skill.ts')"), @@ -95,6 +96,7 @@ export const BuiltinTools: z.infer = { ...notificationTools, [SPAWN_AGENT_TOOL_NAME]: { + permission: "none", description: SPAWN_AGENT_DESCRIPTION, inputSchema: SpawnAgentInput, // Legacy runs-runtime path only: the turn runtime intercepts diff --git a/apps/x/packages/core/src/runtime/tools/domains/agent-analysis.ts b/apps/x/packages/core/src/runtime/tools/domains/agent-analysis.ts index dbbd5a7f..c09795c0 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/agent-analysis.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/agent-analysis.ts @@ -10,6 +10,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const agentAnalysisTools: z.infer = { analyzeAgent: { + permission: "none", description: 'Read and analyze an agent file to understand its structure, tools, and configuration', inputSchema: z.object({ agentName: z.string().describe('Name of the agent file to analyze (with or without .json extension)'), diff --git a/apps/x/packages/core/src/runtime/tools/domains/app.ts b/apps/x/packages/core/src/runtime/tools/domains/app.ts index 595cc99f..4af10d1c 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/app.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/app.ts @@ -18,6 +18,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const appNavigationTools: z.infer = { 'app-navigation': { + permission: "none", description: 'Drive the Rowboat app UI: navigate to any view, read what a view contains (emails, background agents, chat history), open specific items (an email thread, a note, an agent, a past chat), filter/search the knowledge base, and manage saved views. Use it to SHOW the user things while telling them — navigation happens on their screen.', inputSchema: z.object({ action: z.enum(["open-note", "open-view", "open-app", "read-view", "open-item", "update-base-view", "get-base-state", "create-base"]).describe("The navigation action to perform"), @@ -315,6 +316,7 @@ const APP_READ_DATA_MAX_CHARS = 50_000; export const appDataTools: z.infer = { 'app-read-data': { + permission: "none", description: "Read a Rowboat App's data file — the JSON its background agent maintains and its frontend renders. THE way to answer questions an installed app already tracks (fresh, no API calls): find the app via app-navigation read-view apps, read its data file, answer from it. Omit `file` to list the files under the app's data/.", inputSchema: z.object({ appFolder: z.string().describe('The app folder slug under ~/.rowboat/apps.'), @@ -375,6 +377,7 @@ export const appDataTools: z.infer = { }, }, 'app-set-data': { + permission: "none", description: "Write a Rowboat App's data file — JSON its frontend reads via GET /_rowboat/data/. Deterministic: you supply the content, code handles the path, atomicity (temp→rename), and the app's dataContracts validation. This is how a background task refreshes an app's data — the agent RETURNS the data; never hand-write files under apps/.", inputSchema: z.object({ appFolder: z.string().describe('The app folder slug under ~/.rowboat/apps.'), diff --git a/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts b/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts index 8eb7fa50..e2dca1b3 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/background-tasks.ts @@ -69,6 +69,7 @@ export async function resolveCodeProject(dirPath: string): Promise< export const backgroundTaskTools: z.infer = { 'create-background-task': { + permission: "none", description: "Create a new background task on disk. This is the tool you call to materialize a bg-task — do NOT try to write `task.yaml` yourself with file-editText, and do NOT search the codebase for IPC channels like `bg-task:create`. The framework slugifies the name and lays out `bg-tasks//{task.yaml,index.md,runs/}`. After this returns, immediately call `run-background-task-agent` with the returned slug so the user sees content right away.", inputSchema: CreateBackgroundTaskInput, execute: async (input: z.infer) => { @@ -98,6 +99,7 @@ export const backgroundTaskTools: z.infer = { }, 'patch-background-task': { + permission: "none", description: "Update an existing background task — instructions, triggers, active, or model/provider. Use this when the user's new ask overlaps with an existing task (extend-don't-fork): rewrite the instructions in full to absorb the new ask rather than creating a duplicate sibling task. Look up existing tasks with `file-glob` on `bg-tasks/*/task.yaml` and `file-readText` on the candidates first.", inputSchema: PatchBackgroundTaskInput, execute: async (input: z.infer) => { @@ -120,6 +122,7 @@ export const backgroundTaskTools: z.infer = { }, 'run-background-task-agent': { + permission: "none", description: "Manually trigger a background task to run now. Equivalent to the user clicking the Run button in the Background Task detail view. Pass extra `context` to bias what the agent does this run (e.g. a backfill instruction) — does NOT modify the task's persistent instructions.", inputSchema: z.object({ slug: z.string().describe("The slug of the bg-task to run (e.g., 'morning-weather'). The slug is what `bg-task:create` returns."), diff --git a/apps/x/packages/core/src/runtime/tools/domains/browser.ts b/apps/x/packages/core/src/runtime/tools/domains/browser.ts index a32ab38e..68685b2e 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/browser.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/browser.ts @@ -13,6 +13,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const browserTools: z.infer = { 'load-browser-skill': { + permission: "none", description: 'Load a site-specific browser skill (from the browser-use/browser-harness domain-skills library) by id. Returns the full markdown content with selectors, gotchas, and recipes for the target site. Call this after browser-control responses surface a matching skill in suggestedSkills. Pass action="list" to see all available skills. Skills are fetched on first use and cached locally; pass action="refresh" to force an update from upstream.', inputSchema: z.object({ action: z.enum(['load', 'list', 'refresh']).optional().describe('load: fetch a skill by id (default). list: list all cached skills. refresh: re-fetch the library from upstream.'), @@ -78,6 +79,7 @@ export const browserTools: z.infer = { // ============================================================================, 'browser-control': { + permission: "none", description: 'Control the embedded browser pane. Read the current page, inspect indexed interactable elements, and navigate/click/type/press keys in the active browser tab.', inputSchema: BrowserControlInputSchema, isAvailable: async () => { diff --git a/apps/x/packages/core/src/runtime/tools/domains/code.ts b/apps/x/packages/core/src/runtime/tools/domains/code.ts index 4a34a2ca..5851d0cf 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/code.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/code.ts @@ -42,6 +42,7 @@ export function coalesceCodeRunEvents(events: CodeRunEventType[]): CodeRunEventT export const codeAgentRunTools: z.infer = { code_agent_run: { + permission: "none", description: 'Run a coding/software task with the selected on-device coding agent (Claude Code or Codex) inside a project folder. Streams the agent\'s tool calls, file diffs, and plan into the chat and surfaces permission requests inline. Use this for ALL code-mode work (writing/editing/reading code, running tests, debugging, exploring a repo). Reuses one persistent session per chat, so follow-up requests keep context.', inputSchema: z.object({ agent: z.enum(['claude', 'codex']).describe('Which coding agent to use: "claude" (Claude Code) or "codex". Set this to the active code-mode chip agent. Note: when the chip is set, the backend uses the chip agent regardless of this value — this only takes effect in the ask-human flow where no chip is set.'), @@ -188,6 +189,7 @@ export const codeAgentRunTools: z.infer = { export const codeTaskTools: z.infer = { 'launch-code-task': { + permission: "none", description: "Launch an autonomous coding session that implements a unit of work in the bg-task's pinned code repo. ONLY usable from a coding background task (one with a configured code project). The session runs full-auto in its own isolated git worktree/branch — it never touches the user's checkout — and runs asynchronously: this returns as soon as the session is created, so you can launch several (one per group of related items) in the same run. The tool writes and later updates a row under a `## Code Sessions` section in the task's index.md — do NOT edit that section yourself. Write an excellent, fully self-contained `prompt`: the coding agent has no other context and no human to ask. Group related items into one call; split unrelated items into separate calls.", inputSchema: z.object({ taskSlug: z.string().describe("The slug of THIS background task (it's in your run message, e.g. 'implement-meeting-items'). Used to find the pinned repo and to update index.md."), diff --git a/apps/x/packages/core/src/runtime/tools/domains/composio.ts b/apps/x/packages/core/src/runtime/tools/domains/composio.ts index f9ff020e..670c495a 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/composio.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/composio.ts @@ -11,6 +11,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const composioTools: z.infer = { 'composio-list-toolkits': { + permission: "none", description: 'List available Composio integrations (Gmail, Slack, GitHub, etc.) and their connection status. Use this to show the user what services they can connect to.', inputSchema: z.object({ category: z.enum(['all', 'communication', 'productivity', 'development', 'crm', 'social', 'storage', 'support', 'design', 'marketing', 'finance']).optional() @@ -37,6 +38,7 @@ export const composioTools: z.infer = { }, 'composio-search-tools': { + permission: "none", description: 'Search for Composio tools by use case across connected services. Returns tool slugs, descriptions, and input schemas so you can call composio-execute-tool with the right parameters. Example: search "send email" to find Gmail tools, "create issue" to find GitHub/Jira tools.', inputSchema: z.object({ query: z.string().describe('Natural language description of what you want to do (e.g., "send an email", "create a GitHub issue", "schedule a meeting")'), @@ -79,6 +81,7 @@ export const composioTools: z.infer = { }, 'composio-execute-tool': { + permission: "composio-execute", description: 'Execute a Composio tool by its slug. You MUST pass the arguments field with all required parameters from the search results inputSchema. Example: composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })', inputSchema: z.object({ toolSlug: z.string().describe('EXACT tool slug from search results (e.g., "GITHUB_ISSUES_LIST_FOR_REPO"). Copy it exactly — do not modify it.'), @@ -120,6 +123,7 @@ export const composioTools: z.infer = { }, 'composio-connect-toolkit': { + permission: "none", description: 'Connect a Composio service (Gmail, Slack, GitHub, etc.) via OAuth. Shows a connect card for the user to authenticate.', inputSchema: z.object({ toolkitSlug: z.string().describe('The toolkit slug to connect (e.g., "gmail", "github", "slack", "notion")'), diff --git a/apps/x/packages/core/src/runtime/tools/domains/files.ts b/apps/x/packages/core/src/runtime/tools/domains/files.ts index fe20516c..d18d8fe4 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/files.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/files.ts @@ -10,6 +10,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const fileTools: z.infer = { 'file-getRoot': { + permission: "none", description: 'Get the default root directory for relative file paths. Relative paths passed to file tools resolve against this directory.', inputSchema: z.object({}), execute: async () => { @@ -24,6 +25,7 @@ export const fileTools: z.infer = { }, 'file-exists': { + permission: "file-boundary", description: 'Check if a file or directory exists. Accepts absolute paths, ~/ paths, or paths relative to the default root.', inputSchema: z.object({ path: z.string().min(1).describe('File or directory path to check'), @@ -40,6 +42,7 @@ export const fileTools: z.infer = { }, 'file-stat': { + permission: "file-boundary", description: 'Get file or directory statistics (size, modification time, etc.)', inputSchema: z.object({ path: z.string().min(1).describe('File or directory path to stat'), @@ -56,6 +59,7 @@ export const fileTools: z.infer = { }, 'file-list': { + permission: "file-boundary", description: 'List directory contents. Can recursively explore directory structure with options.', inputSchema: z.object({ path: z.string().describe('Directory path to list. Use "." for the default root.'), @@ -93,6 +97,7 @@ export const fileTools: z.infer = { }, 'file-readText': { + permission: "file-boundary", description: 'Read a UTF-8 text file. Returns content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use `offset` and `limit` to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in ``, ``, ``, `` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers are display-only — do NOT include them when later writing or editing the file. Refuses binary files; use parseFile or LLMParse for documents, PDFs, images, and other non-text formats.', inputSchema: z.object({ path: z.string().min(1).describe('Text file path to read'), @@ -119,6 +124,7 @@ export const fileTools: z.infer = { }, 'file-writeText': { + permission: "file-boundary", description: 'Write or update UTF-8 text file contents. Automatically creates parent directories and supports atomic writes.', inputSchema: z.object({ path: z.string().min(1).describe('Text file path to write'), @@ -155,6 +161,7 @@ export const fileTools: z.infer = { }, 'file-editText': { + permission: "file-boundary", description: 'Make precise edits to a UTF-8 text file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss. Refuses binary files.', inputSchema: z.object({ path: z.string().min(1).describe('Text file path to edit'), @@ -182,6 +189,7 @@ export const fileTools: z.infer = { }, 'file-mkdir': { + permission: "file-boundary", description: 'Create a directory', inputSchema: z.object({ path: z.string().min(1).describe('Directory path to create'), @@ -199,6 +207,7 @@ export const fileTools: z.infer = { }, 'file-rename': { + permission: "file-boundary", description: 'Rename or move a file or directory', inputSchema: z.object({ from: z.string().min(1).describe('Source path'), @@ -217,6 +226,7 @@ export const fileTools: z.infer = { }, 'file-copy': { + permission: "file-boundary", description: 'Copy a file (directories not supported)', inputSchema: z.object({ from: z.string().min(1).describe('Source file path'), @@ -235,6 +245,7 @@ export const fileTools: z.infer = { }, 'file-remove': { + permission: "file-boundary", description: 'Remove a file or directory. Files are moved to the Rowboat trash by default for safety.', inputSchema: z.object({ path: z.string().min(1).describe('Path to remove'), @@ -256,6 +267,7 @@ export const fileTools: z.infer = { }, 'file-glob': { + permission: "file-boundary", description: 'Find files matching a glob pattern (e.g., "**/*.ts", "src/**/*.json"). Much faster than recursive readdir for finding files.', inputSchema: z.object({ pattern: z.string().describe('Glob pattern to match files'), @@ -271,6 +283,7 @@ export const fileTools: z.infer = { }, 'file-grep': { + permission: "file-boundary", description: 'Search text file contents using regex. Returns matching files and lines. Skips binary files.', inputSchema: z.object({ pattern: z.string().describe('Regex pattern to search for'), diff --git a/apps/x/packages/core/src/runtime/tools/domains/live-note.ts b/apps/x/packages/core/src/runtime/tools/domains/live-note.ts index b33e658f..30694441 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/live-note.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/live-note.ts @@ -8,6 +8,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const liveNoteTools: z.infer = { 'run-live-note-agent': { + permission: "none", description: "Manually trigger the live-note agent to run now on a note. Equivalent to the user clicking the Run button in the live-note sidebar, but you can pass extra `context` to bias what the agent does this run — most useful for backfills (e.g. seeding a newly-made-live note from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.", inputSchema: z.object({ filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md'). The note must already have a `live:` block in its frontmatter."), diff --git a/apps/x/packages/core/src/runtime/tools/domains/mcp.ts b/apps/x/packages/core/src/runtime/tools/domains/mcp.ts index 33d0e7a1..97185351 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/mcp.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/mcp.ts @@ -12,6 +12,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const mcpTools: z.infer = { addMcpServer: { + permission: "prompt", description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.', inputSchema: z.object({ serverName: z.string().describe('Name/alias for the MCP server'), @@ -48,6 +49,7 @@ export const mcpTools: z.infer = { }, listMcpServers: { + permission: "none", description: 'List all available MCP servers from the configuration', inputSchema: z.object({}), execute: async () => { @@ -67,6 +69,7 @@ export const mcpTools: z.infer = { }, listMcpTools: { + permission: "none", description: 'List all available tools from a specific MCP server', inputSchema: z.object({ serverName: z.string().describe('Name of the MCP server to query'), @@ -89,6 +92,7 @@ export const mcpTools: z.infer = { }, executeMcpTool: { + permission: "mcp-execute", description: 'Execute a specific tool from an MCP server. Use this to run MCP tools on behalf of the user. IMPORTANT: Always use listMcpTools first to get the tool\'s inputSchema, then match the required parameters exactly in the arguments field.', inputSchema: z.object({ serverName: z.string().describe('Name of the MCP server that provides the tool'), diff --git a/apps/x/packages/core/src/runtime/tools/domains/memory.ts b/apps/x/packages/core/src/runtime/tools/domains/memory.ts index b55c3c1f..15a0dff2 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/memory.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/memory.ts @@ -11,6 +11,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const memoryTools: z.infer = { 'save-to-memory': { + permission: "none", description: "Save a note about the user to the agent memory inbox. Use this when you observe something worth remembering — their preferences, communication patterns, relationship context, scheduling habits, or explicit instructions about how they want things done.", inputSchema: z.object({ note: z.string().describe("The observation or preference to remember. Be specific and concise."), diff --git a/apps/x/packages/core/src/runtime/tools/domains/models.ts b/apps/x/packages/core/src/runtime/tools/domains/models.ts index 05896140..f40e201c 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/models.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/models.ts @@ -11,6 +11,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const modelTools: z.infer = { 'list-models': { + permission: "none", description: "List model IDs available for model overrides (e.g. to set a capable model on a background task). Signed-in users get the Rowboat gateway's allowed models; BYOK users get their configured model. Call this BEFORE setting a bg-task `model` so you pick a valid, allowed ID (arbitrary IDs are rejected). Returns { defaultModel, models }.", inputSchema: z.object({}), execute: async () => { diff --git a/apps/x/packages/core/src/runtime/tools/domains/notifications.ts b/apps/x/packages/core/src/runtime/tools/domains/notifications.ts index 45e23013..85e3bb8a 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/notifications.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/notifications.ts @@ -14,6 +14,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const notificationTools: z.infer = { 'notify-user': { + permission: "none", description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.", inputSchema: z.object({ title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."), diff --git a/apps/x/packages/core/src/runtime/tools/domains/parsing.ts b/apps/x/packages/core/src/runtime/tools/domains/parsing.ts index 11007e44..a91170f0 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/parsing.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/parsing.ts @@ -44,6 +44,7 @@ const LLMPARSE_MIME_TYPES: Record = { export const parsingTools: z.infer = { 'parseFile': { + permission: "file-boundary", description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.', inputSchema: z.object({ path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'), @@ -146,6 +147,7 @@ export const parsingTools: z.infer = { }, 'LLMParse': { + permission: "file-boundary", description: 'Send a file to the configured LLM as a multimodal attachment and ask it to extract content as markdown. Best for scanned PDFs, images with text, complex layouts, or any format where local parsing falls short. Supports documents (PDF, Word, Excel, PowerPoint, CSV, TXT, HTML) and images (PNG, JPG, GIF, WebP, SVG, BMP, TIFF).', inputSchema: z.object({ path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'), diff --git a/apps/x/packages/core/src/runtime/tools/domains/shell.ts b/apps/x/packages/core/src/runtime/tools/domains/shell.ts index e1a7a523..39832dab 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/shell.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/shell.ts @@ -13,6 +13,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const shellTools: z.infer = { executeCommand: { + permission: "command-allowlist", description: 'Execute a shell command and return the output. Use this to run bash/shell commands.', inputSchema: z.object({ command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'), diff --git a/apps/x/packages/core/src/runtime/tools/domains/web.ts b/apps/x/packages/core/src/runtime/tools/domains/web.ts index e3d40829..22c10f0b 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/web.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/web.ts @@ -14,6 +14,7 @@ import { BuiltinToolsSchema } from "../types.js"; export const webSearchTools: z.infer = { 'web-search': { + permission: "none", description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.', inputSchema: z.object({ query: z.string().describe('The search query'), @@ -140,6 +141,7 @@ export const webSearchTools: z.infer = { export const fetchUrlTools: z.infer = { 'fetch-url': { + permission: "none", description: "Fetch an HTTP(S) URL and return the response body as text. Use this to pull data from web APIs or pages (e.g. a JSON endpoint) — especially in background tasks, which have no shell. GET by default; supports POST with a body. Returns { ok, status, statusText, body } (body truncated if very large). For JSON, parse the returned body.", inputSchema: z.object({ url: z.string().describe('The http(s) URL to fetch.'), diff --git a/apps/x/packages/core/src/runtime/tools/types.ts b/apps/x/packages/core/src/runtime/tools/types.ts index 082d4254..0f8cebb1 100644 --- a/apps/x/packages/core/src/runtime/tools/types.ts +++ b/apps/x/packages/core/src/runtime/tools/types.ts @@ -1,9 +1,28 @@ // The builtin-tool catalog schema: every entry is {description, inputSchema, -// execute, isAvailable?}. Shared typing for the domain modules and the -// merged catalog. +// execute, permission, isAvailable?}. Shared typing for the domain modules +// and the merged catalog. import { z, ZodType } from "zod"; +// Every builtin declares its permission policy; the declaration travels with +// the tool so a new tool cannot silently skip gating (the checker fails +// closed for anything undeclared or unknown). Policies: +// "none" — never requires permission (read-only or local, +// user-visible effects). +// "prompt" — always requires permission, generic request. +// "command-allowlist" — executeCommand's blocklist/allowlist analysis. +// "file-boundary" — workspace-boundary + file-grant analysis. +// "composio-execute" — Composio action execution (toolkit/tool request). +// "mcp-execute" — MCP tool execution (server/tool request). +export const BuiltinToolPermission = z.enum([ + "none", + "prompt", + "command-allowlist", + "file-boundary", + "composio-execute", + "mcp-execute", +]); + export const BuiltinToolsSchema = z.record(z.string(), z.object({ description: z.string(), inputSchema: z.custom(), @@ -11,5 +30,6 @@ export const BuiltinToolsSchema = z.record(z.string(), z.object({ input: z.any(), // (input, ctx?) => Promise output: z.promise(z.any()), }), + permission: BuiltinToolPermission, isAvailable: z.custom<() => Promise>().optional(), })); diff --git a/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.test.ts b/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.test.ts index efafc0d2..1fa4c72d 100644 --- a/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.test.ts +++ b/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { z } from "zod"; import type { getToolPermissionMetadata } from "../../assembly/permission-metadata.js"; +import type { BuiltinToolPermission } from "../../tools/types.js"; import { RealPermissionChecker } from "./real-permission-checker.js"; type MetadataFn = typeof getToolPermissionMetadata; @@ -7,8 +9,12 @@ type MetadataCall = { toolCall: Parameters[0]; attachment: Parameters[1]; }; +type Policy = z.infer; -function makeChecker(result: Awaited> | Error) { +function makeChecker( + result: Awaited> | Error, + policies: Record = { executeCommand: "command-allowlist" }, +) { const calls: MetadataCall[] = []; const checker = new RealPermissionChecker({ getMetadata: (async (toolCall, attachment) => { @@ -18,6 +24,7 @@ function makeChecker(result: Awaited> | Error) { } return result; }) as MetadataFn, + getPolicy: (name) => policies[name], }); return { checker, calls }; } @@ -53,7 +60,106 @@ describe("RealPermissionChecker", () => { expect(await checker.check(input)).toEqual({ required: false }); }); - it("never gates non-builtin tools", async () => { + it("allows builtins declared permission none without consulting metadata", async () => { + const { checker, calls } = makeChecker(new Error("must not be called"), { + "web-search": "none", + }); + expect( + await checker.check({ + ...input, + toolId: "builtin:web-search", + toolName: "web-search", + input: { query: "hi" }, + }), + ).toEqual({ required: false }); + expect(calls).toHaveLength(0); + }); + + it("always gates prompt-policy builtins with a generic request", async () => { + const { checker } = makeChecker(new Error("must not be called"), { + addMcpServer: "prompt", + }); + expect( + await checker.check({ + ...input, + toolId: "builtin:addMcpServer", + toolName: "addMcpServer", + input: { name: "kb" }, + }), + ).toEqual({ + required: true, + request: { + kind: "tool", + toolId: "builtin:addMcpServer", + toolName: "addMcpServer", + }, + }); + }); + + it("gates composio-execute with a toolkit/tool request", async () => { + const { checker } = makeChecker(new Error("must not be called"), { + "composio-execute-tool": "composio-execute", + }); + expect( + await checker.check({ + ...input, + toolId: "builtin:composio-execute-tool", + toolName: "composio-execute-tool", + input: { + toolkitSlug: "gmail", + toolSlug: "GMAIL_SEND_EMAIL", + arguments: { to: "a@b.c" }, + }, + }), + ).toEqual({ + required: true, + request: { + kind: "composio", + toolkitSlug: "gmail", + toolSlug: "GMAIL_SEND_EMAIL", + }, + }); + }); + + it("gates composio-execute with malformed input via the generic request", async () => { + const { checker } = makeChecker(new Error("must not be called"), { + "composio-execute-tool": "composio-execute", + }); + expect( + await checker.check({ + ...input, + toolId: "builtin:composio-execute-tool", + toolName: "composio-execute-tool", + input: { nonsense: true }, + }), + ).toEqual({ + required: true, + request: { + kind: "tool", + toolId: "builtin:composio-execute-tool", + toolName: "composio-execute-tool", + }, + }); + }); + + it("gates executeMcpTool with a server/tool request", async () => { + const { checker } = makeChecker(new Error("must not be called"), { + executeMcpTool: "mcp-execute", + }); + expect( + await checker.check({ + ...input, + toolId: "builtin:executeMcpTool", + toolName: "executeMcpTool", + input: { serverName: "kb", toolName: "search", arguments: {} }, + }), + ).toEqual({ + required: true, + request: { kind: "mcp", serverName: "kb", toolName: "search" }, + }); + }); + + it("gates mcp:* attachments on user agents", async () => { const { checker, calls } = makeChecker(new Error("must not be called")); expect( await checker.check({ @@ -61,10 +167,45 @@ describe("RealPermissionChecker", () => { toolId: "mcp:kb:search", toolName: "search", }), - ).toEqual({ required: false }); + ).toEqual({ + required: true, + request: { kind: "mcp", serverName: "kb", toolName: "search" }, + }); expect(calls).toHaveLength(0); }); + it("fails closed for undeclared builtins and unknown toolId families", async () => { + const { checker } = makeChecker(new Error("must not be called"), {}); + expect( + await checker.check({ + ...input, + toolId: "builtin:brand-new-tool", + toolName: "brand-new-tool", + }), + ).toEqual({ + required: true, + request: { + kind: "tool", + toolId: "builtin:brand-new-tool", + toolName: "brand-new-tool", + }, + }); + expect( + await checker.check({ + ...input, + toolId: "future:whatever", + toolName: "whatever", + }), + ).toEqual({ + required: true, + request: { + kind: "tool", + toolId: "future:whatever", + toolName: "whatever", + }, + }); + }); + it("propagates metadata errors so the loop fails closed", async () => { const { checker } = makeChecker(new Error("policy exploded")); await expect(checker.check(input)).rejects.toThrowError("policy exploded"); diff --git a/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.ts b/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.ts index 0383cf51..b3497da5 100644 --- a/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.ts +++ b/apps/x/packages/core/src/runtime/turns/bridges/real-permission-checker.ts @@ -1,5 +1,8 @@ +import { z } from "zod"; import type { JsonValue } from "@x/shared/dist/turns.js"; import { getToolPermissionMetadata } from "../../assembly/permission-metadata.js"; +import { BuiltinTools } from "../../tools/catalog.js"; +import type { BuiltinToolPermission } from "../../tools/types.js"; import type { IPermissionChecker, PermissionCheckAllowed, @@ -7,43 +10,119 @@ import type { PermissionCheckRequired, } from "../permission.js"; +type BuiltinPolicy = z.infer; + export interface RealPermissionCheckerDeps { getMetadata?: typeof getToolPermissionMetadata; + getPolicy?: (name: string) => BuiltinPolicy | undefined; } -// Bridges the existing deterministic permission rules: only builtins are -// gated (executeCommand via the command allowlist, file tools via workspace -// boundaries and file-access grants). Session-scoped grants are deferred, so -// the session grant inputs are always empty. A thrown metadata error -// propagates: the turn loop fails closed on checker errors. +const ComposioExecuteInput = z.object({ + toolkitSlug: z.string(), + toolSlug: z.string(), +}); + +const McpExecuteInput = z.object({ + serverName: z.string(), + toolName: z.string(), +}); + +// Bridges the deterministic permission rules. Policy is declared per tool in +// the builtin catalog (tools/types.ts) and the checker FAILS CLOSED: any +// tool without a "none" declaration — undeclared builtins, mcp:* attachments +// on user agents, future toolId families — requires permission. Session +// grants are deferred, so the session grant inputs are always empty. A +// thrown metadata error propagates: the turn loop fails closed on checker +// errors. export class RealPermissionChecker implements IPermissionChecker { private readonly getMetadata: typeof getToolPermissionMetadata; + private readonly getPolicy: (name: string) => BuiltinPolicy | undefined; constructor(deps: RealPermissionCheckerDeps = {}) { this.getMetadata = deps.getMetadata ?? getToolPermissionMetadata; + this.getPolicy = + deps.getPolicy ?? ((name) => BuiltinTools[name]?.permission); } async check( input: PermissionCheckInput, ): Promise { if (!input.toolId.startsWith("builtin:")) { - return { required: false }; + // mcp:: attachments on user agents, and any future + // toolId family: fail closed with a family-specific request. + if (input.toolId.startsWith("mcp:")) { + const serverName = input.toolId.split(":")[1]; + return { + required: true, + request: { + kind: "mcp", + ...(serverName ? { serverName } : {}), + toolName: input.toolName, + }, + }; + } + return { required: true, request: genericRequest(input) }; } + const name = input.toolId.slice("builtin:".length); - const metadata = await this.getMetadata( - { - type: "tool-call", - toolCallId: input.toolCallId, - toolName: input.toolName, - arguments: input.input, - }, - { type: "builtin", name }, - new Set(), // session-scoped command grants: deferred - [], // session-scoped file grants: deferred - ); - if (!metadata) { - return { required: false }; + const policy = this.getPolicy(name); + switch (policy) { + case "none": + return { required: false }; + case "prompt": + return { required: true, request: genericRequest(input) }; + case "composio-execute": { + const parsed = ComposioExecuteInput.safeParse(input.input); + return { + required: true, + request: parsed.success + ? { + kind: "composio", + toolkitSlug: parsed.data.toolkitSlug, + toolSlug: parsed.data.toolSlug, + } + : genericRequest(input), + }; + } + case "mcp-execute": { + const parsed = McpExecuteInput.safeParse(input.input); + return { + required: true, + request: parsed.success + ? { + kind: "mcp", + serverName: parsed.data.serverName, + toolName: parsed.data.toolName, + } + : genericRequest(input), + }; + } + case "command-allowlist": + case "file-boundary": { + const metadata = await this.getMetadata( + { + type: "tool-call", + toolCallId: input.toolCallId, + toolName: input.toolName, + arguments: input.input, + }, + { type: "builtin", name }, + new Set(), // session-scoped command grants: deferred + [], // session-scoped file grants: deferred + ); + if (!metadata) { + return { required: false }; + } + return { required: true, request: metadata as JsonValue }; + } + case undefined: + // Not in the catalog (or missing a declaration at runtime): + // fail closed rather than silently executing. + return { required: true, request: genericRequest(input) }; } - return { required: true, request: metadata as JsonValue }; } } + +function genericRequest(input: PermissionCheckInput): JsonValue { + return { kind: "tool", toolId: input.toolId, toolName: input.toolName }; +} diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 963c358f..406ada09 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -100,6 +100,24 @@ export const ToolPermissionMetadata = z.discriminatedUnion("kind", [ paths: z.array(z.string()), pathPrefix: z.string(), }), + // A Composio action execution (composio-execute-tool). + z.object({ + kind: z.literal("composio"), + toolkitSlug: z.string(), + toolSlug: z.string(), + }), + // An MCP tool execution (executeMcpTool or an mcp:* attachment). + z.object({ + kind: z.literal("mcp"), + serverName: z.string().optional(), + toolName: z.string(), + }), + // Generic fail-closed request: a tool with no more specific policy. + z.object({ + kind: z.literal("tool"), + toolId: z.string(), + toolName: z.string(), + }), ]); export const ToolPermissionRequestEvent = BaseRunEvent.extend({