diff --git a/src/tools/builtin/gcal.ts b/src/tools/builtin/gcal.ts index 39145f2..d85f3aa 100644 --- a/src/tools/builtin/gcal.ts +++ b/src/tools/builtin/gcal.ts @@ -118,6 +118,7 @@ export function createGcalTools(config: NonNullable): Tool[] { name: 'calendar.today', description: "List today's events from Google Calendar. Returns summary, time, location, attendees, and link for each event.", + requiredSecretScopes: ['gcal'], inputSchema: { type: 'object', properties: { @@ -162,6 +163,7 @@ export function createGcalTools(config: NonNullable): Tool[] { name: 'calendar.list', description: 'List events from Google Calendar in a date range. Returns summary, time, location, attendees, and link for each event.', + requiredSecretScopes: ['gcal'], inputSchema: { type: 'object', properties: { @@ -222,6 +224,7 @@ export function createGcalTools(config: NonNullable): Tool[] { name: 'calendar.search', description: 'Search Google Calendar events by text query. Returns summary, time, location, attendees, and link for each match.', + requiredSecretScopes: ['gcal'], inputSchema: { type: 'object', properties: { diff --git a/src/tools/builtin/gdocs.ts b/src/tools/builtin/gdocs.ts index d55867d..bfd9234 100644 --- a/src/tools/builtin/gdocs.ts +++ b/src/tools/builtin/gdocs.ts @@ -100,6 +100,7 @@ export function createGdocsTools(config: NonNullable): Tool[] { name: 'docs.list', description: 'List recent Google Docs documents. Returns id, name, modified time, owners, and link for each document.', + requiredSecretScopes: ['gdocs'], inputSchema: { type: 'object', properties: { @@ -151,6 +152,7 @@ export function createGdocsTools(config: NonNullable): Tool[] { name: 'docs.search', description: 'Search Google Docs by name. Returns id, name, modified time, owners, and link for each matching document.', + requiredSecretScopes: ['gdocs'], inputSchema: { type: 'object', properties: { @@ -208,6 +210,7 @@ export function createGdocsTools(config: NonNullable): Tool[] { name: 'docs.read', description: 'Read the content of a Google Doc as plain text. Use docs.list or docs.search first to get document IDs.', + requiredSecretScopes: ['gdocs'], inputSchema: { type: 'object', properties: { diff --git a/src/tools/builtin/gdrive.ts b/src/tools/builtin/gdrive.ts index 2aa5914..8b35251 100644 --- a/src/tools/builtin/gdrive.ts +++ b/src/tools/builtin/gdrive.ts @@ -130,6 +130,7 @@ export function createGdriveTools(config: NonNullable): Tool[] { name: 'drive.list', description: 'List recent files from Google Drive. Returns id, name, type, modified time, size, owners, and link.', + requiredSecretScopes: ['gdrive'], inputSchema: { type: 'object', properties: { @@ -199,6 +200,7 @@ export function createGdriveTools(config: NonNullable): Tool[] { name: 'drive.search', description: 'Search Google Drive files by name or content. Returns id, name, type, modified time, size, owners, and link.', + requiredSecretScopes: ['gdrive'], inputSchema: { type: 'object', properties: { @@ -270,6 +272,7 @@ export function createGdriveTools(config: NonNullable): Tool[] { name: 'drive.read', description: 'Read the content of a Google Drive file. Exports Google Workspace files (Docs, Sheets, Slides) as plain text/CSV. Downloads regular text files directly. Use drive.list or drive.search to get file IDs.', + requiredSecretScopes: ['gdrive'], inputSchema: { type: 'object', properties: { diff --git a/src/tools/builtin/gmail.ts b/src/tools/builtin/gmail.ts index 321d55b..e70ae55 100644 --- a/src/tools/builtin/gmail.ts +++ b/src/tools/builtin/gmail.ts @@ -152,6 +152,7 @@ export function createGmailTools(config: NonNullable): Tool[] { name: 'gmail.list', description: 'List recent emails from Gmail. Returns id, from, subject, date, and snippet for each message.', + requiredSecretScopes: ['gmail'], inputSchema: { type: 'object', properties: { @@ -207,6 +208,7 @@ export function createGmailTools(config: NonNullable): Tool[] { name: 'gmail.search', description: 'Search Gmail using Gmail query syntax (e.g. "from:user@example.com", "is:unread", "subject:hello"). Returns id, from, subject, date, and snippet for each match.', + requiredSecretScopes: ['gmail'], inputSchema: { type: 'object', properties: { @@ -262,6 +264,7 @@ export function createGmailTools(config: NonNullable): Tool[] { name: 'gmail.read', description: 'Read the full content of a Gmail message by ID. Returns headers (from, to, subject, date) and the full text body. Use gmail.list or gmail.search first to get message IDs.', + requiredSecretScopes: ['gmail'], inputSchema: { type: 'object', properties: { diff --git a/src/tools/builtin/gtasks.ts b/src/tools/builtin/gtasks.ts index 5266c38..738fec0 100644 --- a/src/tools/builtin/gtasks.ts +++ b/src/tools/builtin/gtasks.ts @@ -103,6 +103,7 @@ export function createGtasksTools(config: NonNullable): Tool[] { name: 'tasks.lists', description: 'List all Google Tasks task lists. Returns id, title, and last updated time for each list.', + requiredSecretScopes: ['gtasks'], inputSchema: { type: 'object', properties: { @@ -149,6 +150,7 @@ export function createGtasksTools(config: NonNullable): Tool[] { name: 'tasks.list', description: 'List tasks from a Google Tasks task list. Returns title, status (completed/needsAction), due date, and notes. Use tasks.lists first to get task list IDs.', + requiredSecretScopes: ['gtasks'], inputSchema: { type: 'object', properties: { diff --git a/src/tools/builtin/web-search.ts b/src/tools/builtin/web-search.ts index 6d56c81..286cf68 100644 --- a/src/tools/builtin/web-search.ts +++ b/src/tools/builtin/web-search.ts @@ -135,6 +135,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool { name: 'web.search', description: 'Search the web for current information. Returns titles, URLs, and snippets from web search results. Use this to find up-to-date information about any topic.', + requiredSecretScopes: ['web_search'], inputSchema: { type: 'object', properties: { diff --git a/src/tools/executor.test.ts b/src/tools/executor.test.ts index 90b69ac..b31b611 100644 --- a/src/tools/executor.test.ts +++ b/src/tools/executor.test.ts @@ -160,4 +160,106 @@ describe('ToolExecutor', () => { const result = await resultPromise; expect(result.success).toBe(true); }); + + it('enforces skill filesystem write allowlist', async () => { + const registry = new ToolRegistry(); + registry.register({ + name: 'file.write', + description: 'write', + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ success: true, output: 'ok' }), + }); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const allowed = await executor.execute( + 'file.write', + { path: '/tmp/flynn-skill-ok.txt', content: 'hello' }, + { + skillName: 'test-skill', + skillPermissions: { + execution_environment: 'host', + fs: { write: ['/tmp/**'] }, + }, + executionEnvironment: 'host', + autonomyLevel: 'autonomous', + }, + ); + expect(allowed.success).toBe(true); + + const denied = await executor.execute( + 'file.write', + { path: '/etc/passwd', content: 'nope' }, + { + skillName: 'test-skill', + skillPermissions: { + execution_environment: 'host', + fs: { write: ['/tmp/**'] }, + }, + executionEnvironment: 'host', + autonomyLevel: 'autonomous', + }, + ); + expect(denied.success).toBe(false); + expect(denied.error).toContain('path not allowed'); + }); + + it('enforces tool secret scopes for skill contexts', async () => { + const registry = new ToolRegistry(); + registry.register({ + name: 'gmail.list', + description: 'gmail', + requiredSecretScopes: ['gmail'], + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ success: true, output: 'ok' }), + }); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const result = await executor.execute('gmail.list', {}, { + skillName: 'no-secrets-skill', + skillPermissions: { secrets: [] }, + executionEnvironment: 'host', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('missing secret scopes'); + }); + + it('blocks high-risk tool calls with injection markers when untrusted content is present', async () => { + const registry = new ToolRegistry(); + registry.register({ + name: 'shell.exec', + description: 'shell', + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ success: true, output: 'ok' }), + }); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const result = await executor.execute('shell.exec', { command: 'rm -rf /' }, { + untrustedContent: true, + executionEnvironment: 'host', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('blocked'); + }); + + it('blocks passing secret-like args to network tools when untrusted content is present', async () => { + const registry = new ToolRegistry(); + registry.register({ + name: 'web.fetch', + description: 'fetch', + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ success: true, output: 'ok' }), + }); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const result = await executor.execute('web.fetch', { url: 'https://example.com', authorization: 'Bearer abcdef' }, { + untrustedContent: true, + executionEnvironment: 'host', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('refusing to pass'); + }); }); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 0ec57a9..88918ac 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -4,6 +4,9 @@ import type { HookEngine } from '../hooks/engine.js'; import type { ToolPolicyContext } from './policy.js'; import { resolveAutonomy } from '../hooks/autonomy.js'; import { auditLogger } from '../audit/index.js'; +import { randomUUID } from 'crypto'; +import { matchesAnyPattern, patternToRegex } from './policy.js'; +import { redactForAudit, containsSecretLikeKeys } from '../audit/redact.js'; export interface ToolExecutorConfig { defaultTimeoutMs?: number; @@ -24,17 +27,79 @@ export class ToolExecutor { } async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise { + const executionId = randomUUID(); + const executionEnvironment = context?.executionEnvironment; + const skillName = context?.skillName; + const tool = this.registry.getByApiName(toolName); if (!tool) { auditLogger?.toolDenied({ tool_name: toolName, reason: 'Tool not found', denial_type: 'not_found', + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, session_id: context?.sessionId, }); return { success: false, output: '', error: `Tool '${toolName}' not found` }; } + const argsRedaction = redactForAudit(args); + + // Secret scope enforcement + const requiredScopes = tool.requiredSecretScopes ?? []; + const allowedScopes = this.resolveAllowedSecretScopes(context); + if (requiredScopes.length > 0 && !this.hasAllScopes(allowedScopes, requiredScopes)) { + auditLogger?.toolDenied({ + tool_name: tool.name, + reason: `Tool requires secret scope(s): ${requiredScopes.join(', ')}`, + denial_type: 'policy', + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, + session_id: context?.sessionId, + }); + return { + success: false, + output: '', + error: `Tool '${tool.name}' denied: missing secret scopes (${requiredScopes.join(', ')})`, + }; + } + + // Capability enforcement: filesystem + network constraints + const capabilityViolation = this.checkCapabilityConstraints(tool.name, args, context); + if (capabilityViolation) { + auditLogger?.toolDenied({ + tool_name: tool.name, + reason: capabilityViolation, + denial_type: 'policy', + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, + session_id: context?.sessionId, + }); + return { success: false, output: '', error: `Tool '${tool.name}' denied: ${capabilityViolation}` }; + } + + // Prompt-injection guard: block obviously unsafe tool calls when untrusted content is present + const guard = this.evaluatePromptInjectionGuard(tool.name, args, context); + if (guard) { + auditLogger?.toolDenied({ + tool_name: tool.name, + reason: guard, + denial_type: 'policy', + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, + session_id: context?.sessionId, + }); + return { success: false, output: '', error: `Tool '${tool.name}' blocked: ${guard}` }; + } + // Policy check (defense in depth — tools should also be filtered at listing time) const policy = this.registry.getPolicy(); if (policy) { @@ -44,6 +109,10 @@ export class ToolExecutor { tool_name: toolName, reason: 'Tool not allowed by policy', denial_type: 'policy', + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, session_id: context?.sessionId, }); return { @@ -66,6 +135,10 @@ export class ToolExecutor { tool_name: toolName, reason: `Autonomy override: ${autonomyDecision.reason}`, denial_type: 'autonomy_override', + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, session_id: context?.sessionId, }); } @@ -75,6 +148,18 @@ export class ToolExecutor { toolName, args as Record, ); + + auditLogger?.toolApproval({ + tool_name: toolName, + approved: hookResult.approved, + reason: hookResult.reason, + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, + session_id: context?.sessionId, + }); + if (!hookResult.approved) { const denyReason = hookResult.reason ?? 'no reason'; const detailedReason = autonomyDecision.overridden @@ -84,6 +169,10 @@ export class ToolExecutor { tool_name: toolName, reason: detailedReason, denial_type: 'hook', + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, session_id: context?.sessionId, }); return { @@ -99,7 +188,11 @@ export class ToolExecutor { auditLogger?.toolStart({ tool_name: toolName, - tool_args: args, + tool_args: argsRedaction.value, + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions, session_id: context?.sessionId, channel: context?.channel, sender: context?.sender, @@ -121,10 +214,15 @@ export class ToolExecutor { result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]'; } + const resultRedaction = redactForAudit(result); auditLogger?.toolSuccess({ tool_name: toolName, - result: result, + result: resultRedaction.value as { success: boolean; output: string; error?: string }, duration_ms: duration, + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions + resultRedaction.redactions, session_id: context?.sessionId, }); @@ -133,18 +231,203 @@ export class ToolExecutor { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); + const errorRedaction = redactForAudit(errorMessage); + auditLogger?.toolError({ tool_name: toolName, - error: errorMessage, + error: String(errorRedaction.value), duration_ms: duration, session_id: context?.sessionId, + execution_id: executionId, + execution_environment: executionEnvironment, + skill_name: skillName, + redactions_applied: argsRedaction.redactions + errorRedaction.redactions, }); return { success: false, output: '', - error: errorMessage, + error: String(errorRedaction.value), }; } } + + private resolveAllowedSecretScopes(context?: ToolPolicyContext): string[] { + if (context?.allowedSecretScopes) { + return context.allowedSecretScopes; + } + if (context?.skillPermissions?.secrets) { + return context.skillPermissions.secrets; + } + if (context?.skillName) { + return []; + } + return ['*']; + } + + private hasAllScopes(allowed: string[], required: string[]): boolean { + if (allowed.includes('*')) { + return true; + } + return required.every((scope) => allowed.includes(scope)); + } + + private isHighRiskTool(toolName: string): boolean { + if (toolName.startsWith('browser.')) { + return true; + } + return [ + 'file.write', + 'file.edit', + 'file.patch', + 'shell.exec', + 'process.start', + 'process.kill', + ].includes(toolName); + } + + private checkCapabilityConstraints(toolName: string, args: unknown, context?: ToolPolicyContext): string | null { + const perms = context?.skillPermissions; + if (!perms) { + if (context?.skillName && this.isHighRiskTool(toolName)) { + return 'skill has no permissions manifest; high-risk tool denied by default'; + } + return null; + } + + // Sandbox enforcement for high-risk tools unless explicitly allowed. + if (this.isHighRiskTool(toolName)) { + const env = context?.executionEnvironment ?? 'host'; + const requested = perms.execution_environment ?? 'sandbox'; + if (context?.skillName && env === 'host' && requested !== 'host') { + return 'high-risk tool execution on host is not allowed for this skill (requires execution_environment=host)'; + } + } + + // FS path enforcement + const fs = perms.fs; + if (fs && toolName.startsWith('file.')) { + const mode: 'read' | 'write' = (toolName === 'file.read' || toolName === 'file.list') ? 'read' : 'write'; + const allowlist = mode === 'read' ? (fs.read ?? []) : (fs.write ?? []); + if (allowlist.length === 0) { + return `filesystem ${mode} access not permitted by skill permissions`; + } + + const paths = this.extractFilePaths(toolName, args); + for (const p of paths) { + if (!this.pathAllowed(p, allowlist)) { + return `path not allowed by skill permissions (${mode}): ${p}`; + } + } + } + + // Network host enforcement (best-effort) + if (perms.net && perms.net.length > 0 && toolName === 'web.fetch') { + const url = (args as { url?: unknown } | null)?.url; + if (typeof url === 'string') { + try { + const parsed = new URL(url); + const host = parsed.hostname; + const port = parsed.port + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === 'https:' + ? 443 + : parsed.protocol === 'http:' + ? 80 + : undefined; + + const allowed = perms.net.some((rule) => { + if (!matchesAnyPattern(host, [rule.host])) { + return false; + } + if (!rule.ports || rule.ports.length === 0) { + return true; + } + if (!port || !Number.isFinite(port)) { + return false; + } + return rule.ports.includes(port); + }); + + if (!allowed) { + return `network access denied by skill permissions: ${host}${port ? `:${port}` : ''}`; + } + } catch { + return 'invalid url for web.fetch'; + } + } + } + + return null; + } + + private extractFilePaths(toolName: string, args: unknown): string[] { + const out: string[] = []; + const record = (args ?? null) as Record | null; + if (!record || typeof record !== 'object') { + return out; + } + + if (toolName === 'file.patch') { + const patches = record.patches; + if (Array.isArray(patches)) { + for (const patch of patches) { + if (patch && typeof patch === 'object') { + const p = (patch as Record).path; + if (typeof p === 'string') { + out.push(p); + } + } + } + } + return out; + } + + const p = record.path; + if (typeof p === 'string') { + out.push(p); + } + return out; + } + + private pathAllowed(pathValue: string, allowlist: string[]): boolean { + return allowlist.some((pattern) => patternToRegex(pattern).test(pathValue)); + } + + private evaluatePromptInjectionGuard(toolName: string, args: unknown, context?: ToolPolicyContext): string | null { + if (!context?.untrustedContent) { + return null; + } + + // When untrusted content is present, forbid passing secrets directly via tool args. + if ((toolName === 'web.fetch' || toolName === 'web.search') && containsSecretLikeKeys(args)) { + return 'refusing to pass secret-like fields to a network tool while untrusted content is present'; + } + + const serialized = JSON.stringify(args ?? {}); + const lower = serialized.toLowerCase(); + + const markers = [ + 'ignore previous', + 'ignore all previous', + 'system prompt', + 'exfiltrate', + 'send to', + 'upload', + 'curl ', + 'wget ', + 'powershell', + 'rm -rf', + 'chmod ', + 'ssh ', + 'scp ', + 'BEGIN PRIVATE KEY'.toLowerCase(), + ]; + + if (this.isHighRiskTool(toolName) && markers.some((m) => lower.includes(m))) { + return 'blocked high-risk tool call due to prompt-injection markers in arguments'; + } + + return null; + } } diff --git a/src/tools/policy.test.ts b/src/tools/policy.test.ts index d3e61f0..5528baa 100644 --- a/src/tools/policy.test.ts +++ b/src/tools/policy.test.ts @@ -493,6 +493,36 @@ describe('ToolPolicy', () => { }); }); + describe('skill capability restrictions', () => { + it('intersects tool policy with skill tool_groups', () => { + const policy = new ToolPolicy(defaultConfig({ profile: 'full' })); + const allowed = policy.resolveAllowedNames(ALL_TOOL_NAMES, { + skillName: 'web-only-skill', + skillPermissions: { tool_groups: ['group:web'] }, + }); + + expect(allowed.has('web.fetch')).toBe(true); + expect(allowed.has('web.search')).toBe(true); + expect(allowed.has('shell.exec')).toBe(false); + expect(allowed.has('file.write')).toBe(false); + }); + + it('uses explicit permissions.tools when present (overrides tool_groups)', () => { + const policy = new ToolPolicy(defaultConfig({ profile: 'full' })); + const allowed = policy.resolveAllowedNames(ALL_TOOL_NAMES, { + skillName: 'explicit-tool-skill', + skillPermissions: { + tool_groups: ['group:fs'], + tools: ['web.fetch'], + }, + }); + + expect(allowed.has('web.fetch')).toBe(true); + expect(allowed.has('file.read')).toBe(false); + expect(allowed.has('file.write')).toBe(false); + }); + }); + describe('edge cases', () => { it('handles empty tool list', () => { const policy = new ToolPolicy(defaultConfig()); diff --git a/src/tools/policy.ts b/src/tools/policy.ts index 71dd7e0..f60a0fc 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -1,5 +1,6 @@ import type { AutonomyLevel, ToolsConfig, ToolProfile } from '../config/schema.js'; import type { Tool } from './types.js'; +import type { SkillPermissions } from '../skills/types.js'; // ── Profile definitions ───────────────────────────────────────────── @@ -142,6 +143,41 @@ export interface ToolPolicyContext { tier?: string; /** Autonomy level for tool execution (affects confirmation requirements). */ autonomyLevel?: AutonomyLevel; + + /** Optional active skill name (for capability scoping and audit). */ + skillName?: string; + /** Optional active skill permissions (capability declarations). */ + skillPermissions?: SkillPermissions; + + /** Execution environment for high-risk operations. */ + executionEnvironment?: 'host' | 'sandbox'; + + /** Secret scopes allowed for this context (used by executor). */ + allowedSecretScopes?: string[]; + + /** True when untrusted content has been introduced in this run. */ + untrustedContent?: boolean; +} + +function resolveSkillAllowedNames(allToolNames: string[], permissions?: SkillPermissions): Set | null { + if (!permissions) { + return null; + } + + const explicitTools = Array.isArray(permissions.tools) ? permissions.tools : undefined; + const toolGroups = Array.isArray(permissions.tool_groups) ? permissions.tool_groups : undefined; + + const patterns = (explicitTools && explicitTools.length > 0) + ? expandGroups(explicitTools) + : (toolGroups && toolGroups.length > 0) + ? expandGroups(toolGroups) + : []; + + if (patterns.length === 0) { + return null; + } + + return new Set(allToolNames.filter((name) => matchesAnyPattern(name, patterns))); } // ── ToolPolicy engine ─────────────────────────────────────────────── @@ -225,6 +261,17 @@ export class ToolPolicy { allowed = intersect(allowed, providerAllowed); } + // Step 6: Apply skill capability restrictions (intersection) + // Safe-by-default: if a skill is active but has no permissions manifest, deny all tools. + if (context?.skillName && !context.skillPermissions) { + allowed = new Set(); + } else { + const skillAllowed = resolveSkillAllowedNames(allToolNames, context?.skillPermissions); + if (skillAllowed) { + allowed = intersect(allowed, skillAllowed); + } + } + return allowed; } diff --git a/src/tools/types.ts b/src/tools/types.ts index 8bcb2aa..76924d8 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -8,6 +8,8 @@ export interface Tool { name: string; description: string; inputSchema: ToolInputSchema; + /** Secret scopes required to execute this tool (optional). */ + requiredSecretScopes?: string[]; execute(args: unknown): Promise; }