feat(tools): enforce skill capabilities and secret scopes

This commit is contained in:
William Valentin
2026-02-15 10:16:51 -08:00
parent 9900f41057
commit 3451df41b9
11 changed files with 483 additions and 4 deletions
+3
View File
@@ -118,6 +118,7 @@ export function createGcalTools(config: NonNullable<GcalConfig>): Tool[] {
name: 'calendar.today', name: 'calendar.today',
description: description:
"List today's events from Google Calendar. Returns summary, time, location, attendees, and link for each event.", "List today's events from Google Calendar. Returns summary, time, location, attendees, and link for each event.",
requiredSecretScopes: ['gcal'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -162,6 +163,7 @@ export function createGcalTools(config: NonNullable<GcalConfig>): Tool[] {
name: 'calendar.list', name: 'calendar.list',
description: description:
'List events from Google Calendar in a date range. Returns summary, time, location, attendees, and link for each event.', 'List events from Google Calendar in a date range. Returns summary, time, location, attendees, and link for each event.',
requiredSecretScopes: ['gcal'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -222,6 +224,7 @@ export function createGcalTools(config: NonNullable<GcalConfig>): Tool[] {
name: 'calendar.search', name: 'calendar.search',
description: description:
'Search Google Calendar events by text query. Returns summary, time, location, attendees, and link for each match.', 'Search Google Calendar events by text query. Returns summary, time, location, attendees, and link for each match.',
requiredSecretScopes: ['gcal'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
+3
View File
@@ -100,6 +100,7 @@ export function createGdocsTools(config: NonNullable<GdocsConfig>): Tool[] {
name: 'docs.list', name: 'docs.list',
description: description:
'List recent Google Docs documents. Returns id, name, modified time, owners, and link for each document.', 'List recent Google Docs documents. Returns id, name, modified time, owners, and link for each document.',
requiredSecretScopes: ['gdocs'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -151,6 +152,7 @@ export function createGdocsTools(config: NonNullable<GdocsConfig>): Tool[] {
name: 'docs.search', name: 'docs.search',
description: description:
'Search Google Docs by name. Returns id, name, modified time, owners, and link for each matching document.', 'Search Google Docs by name. Returns id, name, modified time, owners, and link for each matching document.',
requiredSecretScopes: ['gdocs'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -208,6 +210,7 @@ export function createGdocsTools(config: NonNullable<GdocsConfig>): Tool[] {
name: 'docs.read', name: 'docs.read',
description: description:
'Read the content of a Google Doc as plain text. Use docs.list or docs.search first to get document IDs.', 'Read the content of a Google Doc as plain text. Use docs.list or docs.search first to get document IDs.',
requiredSecretScopes: ['gdocs'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
+3
View File
@@ -130,6 +130,7 @@ export function createGdriveTools(config: NonNullable<GdriveConfig>): Tool[] {
name: 'drive.list', name: 'drive.list',
description: description:
'List recent files from Google Drive. Returns id, name, type, modified time, size, owners, and link.', 'List recent files from Google Drive. Returns id, name, type, modified time, size, owners, and link.',
requiredSecretScopes: ['gdrive'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -199,6 +200,7 @@ export function createGdriveTools(config: NonNullable<GdriveConfig>): Tool[] {
name: 'drive.search', name: 'drive.search',
description: description:
'Search Google Drive files by name or content. Returns id, name, type, modified time, size, owners, and link.', 'Search Google Drive files by name or content. Returns id, name, type, modified time, size, owners, and link.',
requiredSecretScopes: ['gdrive'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -270,6 +272,7 @@ export function createGdriveTools(config: NonNullable<GdriveConfig>): Tool[] {
name: 'drive.read', name: 'drive.read',
description: 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.', '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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
+3
View File
@@ -152,6 +152,7 @@ export function createGmailTools(config: NonNullable<GmailConfig>): Tool[] {
name: 'gmail.list', name: 'gmail.list',
description: description:
'List recent emails from Gmail. Returns id, from, subject, date, and snippet for each message.', 'List recent emails from Gmail. Returns id, from, subject, date, and snippet for each message.',
requiredSecretScopes: ['gmail'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -207,6 +208,7 @@ export function createGmailTools(config: NonNullable<GmailConfig>): Tool[] {
name: 'gmail.search', name: 'gmail.search',
description: 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.', '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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -262,6 +264,7 @@ export function createGmailTools(config: NonNullable<GmailConfig>): Tool[] {
name: 'gmail.read', name: 'gmail.read',
description: 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.', '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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
+2
View File
@@ -103,6 +103,7 @@ export function createGtasksTools(config: NonNullable<GtasksConfig>): Tool[] {
name: 'tasks.lists', name: 'tasks.lists',
description: description:
'List all Google Tasks task lists. Returns id, title, and last updated time for each list.', 'List all Google Tasks task lists. Returns id, title, and last updated time for each list.',
requiredSecretScopes: ['gtasks'],
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -149,6 +150,7 @@ export function createGtasksTools(config: NonNullable<GtasksConfig>): Tool[] {
name: 'tasks.list', name: 'tasks.list',
description: 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.', '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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
+1
View File
@@ -135,6 +135,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
name: 'web.search', name: 'web.search',
description: 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.', '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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
+102
View File
@@ -160,4 +160,106 @@ describe('ToolExecutor', () => {
const result = await resultPromise; const result = await resultPromise;
expect(result.success).toBe(true); 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');
});
}); });
+287 -4
View File
@@ -4,6 +4,9 @@ import type { HookEngine } from '../hooks/engine.js';
import type { ToolPolicyContext } from './policy.js'; import type { ToolPolicyContext } from './policy.js';
import { resolveAutonomy } from '../hooks/autonomy.js'; import { resolveAutonomy } from '../hooks/autonomy.js';
import { auditLogger } from '../audit/index.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 { export interface ToolExecutorConfig {
defaultTimeoutMs?: number; defaultTimeoutMs?: number;
@@ -24,17 +27,79 @@ export class ToolExecutor {
} }
async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise<ToolResult> { async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise<ToolResult> {
const executionId = randomUUID();
const executionEnvironment = context?.executionEnvironment;
const skillName = context?.skillName;
const tool = this.registry.getByApiName(toolName); const tool = this.registry.getByApiName(toolName);
if (!tool) { if (!tool) {
auditLogger?.toolDenied({ auditLogger?.toolDenied({
tool_name: toolName, tool_name: toolName,
reason: 'Tool not found', reason: 'Tool not found',
denial_type: 'not_found', denial_type: 'not_found',
execution_id: executionId,
execution_environment: executionEnvironment,
skill_name: skillName,
session_id: context?.sessionId, session_id: context?.sessionId,
}); });
return { success: false, output: '', error: `Tool '${toolName}' not found` }; 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) // Policy check (defense in depth — tools should also be filtered at listing time)
const policy = this.registry.getPolicy(); const policy = this.registry.getPolicy();
if (policy) { if (policy) {
@@ -44,6 +109,10 @@ export class ToolExecutor {
tool_name: toolName, tool_name: toolName,
reason: 'Tool not allowed by policy', reason: 'Tool not allowed by policy',
denial_type: 'policy', denial_type: 'policy',
execution_id: executionId,
execution_environment: executionEnvironment,
skill_name: skillName,
redactions_applied: argsRedaction.redactions,
session_id: context?.sessionId, session_id: context?.sessionId,
}); });
return { return {
@@ -66,6 +135,10 @@ export class ToolExecutor {
tool_name: toolName, tool_name: toolName,
reason: `Autonomy override: ${autonomyDecision.reason}`, reason: `Autonomy override: ${autonomyDecision.reason}`,
denial_type: 'autonomy_override', denial_type: 'autonomy_override',
execution_id: executionId,
execution_environment: executionEnvironment,
skill_name: skillName,
redactions_applied: argsRedaction.redactions,
session_id: context?.sessionId, session_id: context?.sessionId,
}); });
} }
@@ -75,6 +148,18 @@ export class ToolExecutor {
toolName, toolName,
args as Record<string, unknown>, args as Record<string, unknown>,
); );
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) { if (!hookResult.approved) {
const denyReason = hookResult.reason ?? 'no reason'; const denyReason = hookResult.reason ?? 'no reason';
const detailedReason = autonomyDecision.overridden const detailedReason = autonomyDecision.overridden
@@ -84,6 +169,10 @@ export class ToolExecutor {
tool_name: toolName, tool_name: toolName,
reason: detailedReason, reason: detailedReason,
denial_type: 'hook', denial_type: 'hook',
execution_id: executionId,
execution_environment: executionEnvironment,
skill_name: skillName,
redactions_applied: argsRedaction.redactions,
session_id: context?.sessionId, session_id: context?.sessionId,
}); });
return { return {
@@ -99,7 +188,11 @@ export class ToolExecutor {
auditLogger?.toolStart({ auditLogger?.toolStart({
tool_name: toolName, 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, session_id: context?.sessionId,
channel: context?.channel, channel: context?.channel,
sender: context?.sender, sender: context?.sender,
@@ -121,10 +214,15 @@ export class ToolExecutor {
result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]'; result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]';
} }
const resultRedaction = redactForAudit(result);
auditLogger?.toolSuccess({ auditLogger?.toolSuccess({
tool_name: toolName, tool_name: toolName,
result: result, result: resultRedaction.value as { success: boolean; output: string; error?: string },
duration_ms: duration, duration_ms: duration,
execution_id: executionId,
execution_environment: executionEnvironment,
skill_name: skillName,
redactions_applied: argsRedaction.redactions + resultRedaction.redactions,
session_id: context?.sessionId, session_id: context?.sessionId,
}); });
@@ -133,18 +231,203 @@ export class ToolExecutor {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
const errorRedaction = redactForAudit(errorMessage);
auditLogger?.toolError({ auditLogger?.toolError({
tool_name: toolName, tool_name: toolName,
error: errorMessage, error: String(errorRedaction.value),
duration_ms: duration, duration_ms: duration,
session_id: context?.sessionId, session_id: context?.sessionId,
execution_id: executionId,
execution_environment: executionEnvironment,
skill_name: skillName,
redactions_applied: argsRedaction.redactions + errorRedaction.redactions,
}); });
return { return {
success: false, success: false,
output: '', 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<string, unknown> | 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<string, unknown>).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;
}
} }
+30
View File
@@ -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', () => { describe('edge cases', () => {
it('handles empty tool list', () => { it('handles empty tool list', () => {
const policy = new ToolPolicy(defaultConfig()); const policy = new ToolPolicy(defaultConfig());
+47
View File
@@ -1,5 +1,6 @@
import type { AutonomyLevel, ToolsConfig, ToolProfile } from '../config/schema.js'; import type { AutonomyLevel, ToolsConfig, ToolProfile } from '../config/schema.js';
import type { Tool } from './types.js'; import type { Tool } from './types.js';
import type { SkillPermissions } from '../skills/types.js';
// ── Profile definitions ───────────────────────────────────────────── // ── Profile definitions ─────────────────────────────────────────────
@@ -142,6 +143,41 @@ export interface ToolPolicyContext {
tier?: string; tier?: string;
/** Autonomy level for tool execution (affects confirmation requirements). */ /** Autonomy level for tool execution (affects confirmation requirements). */
autonomyLevel?: AutonomyLevel; 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<string> | 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 ─────────────────────────────────────────────── // ── ToolPolicy engine ───────────────────────────────────────────────
@@ -225,6 +261,17 @@ export class ToolPolicy {
allowed = intersect(allowed, providerAllowed); 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; return allowed;
} }
+2
View File
@@ -8,6 +8,8 @@ export interface Tool {
name: string; name: string;
description: string; description: string;
inputSchema: ToolInputSchema; inputSchema: ToolInputSchema;
/** Secret scopes required to execute this tool (optional). */
requiredSecretScopes?: string[];
execute(args: unknown): Promise<ToolResult>; execute(args: unknown): Promise<ToolResult>;
} }