feat(tools): enforce skill capabilities and secret scopes
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user