feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
/**
* Error codes for the approval system
* Covers validation, timeout, cancellation, and provider errors
*/
export enum ApprovalErrorCode {
// Validation errors
APPROVAL_INVALID_REQUEST = 'approval_invalid_request',
APPROVAL_INVALID_RESPONSE = 'approval_invalid_response',
APPROVAL_INVALID_METADATA = 'approval_invalid_metadata',
APPROVAL_INVALID_SCHEMA = 'approval_invalid_schema',
// Timeout errors
APPROVAL_TIMEOUT = 'approval_timeout',
// Cancellation errors
APPROVAL_CANCELLED = 'approval_cancelled',
APPROVAL_CANCELLED_ALL = 'approval_cancelled_all',
// Provider errors
APPROVAL_PROVIDER_NOT_CONFIGURED = 'approval_provider_not_configured',
APPROVAL_PROVIDER_ERROR = 'approval_provider_error',
APPROVAL_NOT_FOUND = 'approval_not_found',
// Type-specific errors
APPROVAL_TOOL_CONFIRMATION_DENIED = 'approval_tool_confirmation_denied',
APPROVAL_ELICITATION_DENIED = 'approval_elicitation_denied',
APPROVAL_ELICITATION_VALIDATION_FAILED = 'approval_elicitation_validation_failed',
// Configuration errors
APPROVAL_CONFIG_INVALID = 'approval_config_invalid',
}

View File

@@ -0,0 +1,425 @@
import { DextoRuntimeError, ErrorScope, ErrorType } from '../errors/index.js';
import { ApprovalErrorCode } from './error-codes.js';
import type { ApprovalType, DenialReason } from './types.js';
/**
* Context for approval validation errors
*/
export interface ApprovalValidationContext {
approvalId?: string;
type?: ApprovalType;
field?: string;
reason?: string;
}
/**
* Context for approval timeout errors
*/
export interface ApprovalTimeoutContext {
approvalId: string;
type: ApprovalType;
timeout: number;
sessionId?: string;
}
/**
* Context for approval cancellation errors
*/
export interface ApprovalCancellationContext {
approvalId?: string;
type?: ApprovalType;
reason?: string;
}
/**
* Context for elicitation validation errors
*/
export interface ElicitationValidationContext {
approvalId: string;
serverName: string;
errors: string[];
}
/**
* Error factory for approval system errors
*/
export class ApprovalError {
/**
* Create an error for invalid approval request
*/
static invalidRequest(
reason: string,
context?: ApprovalValidationContext
): DextoRuntimeError<ApprovalValidationContext> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_INVALID_REQUEST,
ErrorScope.TOOLS, // Approvals are part of tool execution flow
ErrorType.USER,
`Invalid approval request: ${reason}`,
context,
['Check the approval request structure', 'Ensure all required fields are provided']
);
}
/**
* Create an error for invalid approval response
*/
static invalidResponse(
reason: string,
context?: ApprovalValidationContext
): DextoRuntimeError<ApprovalValidationContext> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_INVALID_RESPONSE,
ErrorScope.TOOLS,
ErrorType.USER,
`Invalid approval response: ${reason}`,
context,
[
'Check the approval response structure',
'Ensure approvalId matches the request',
'Verify status is valid',
]
);
}
/**
* Create an error for invalid metadata
*/
static invalidMetadata(
type: ApprovalType,
reason: string
): DextoRuntimeError<ApprovalValidationContext> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_INVALID_METADATA,
ErrorScope.TOOLS,
ErrorType.USER,
`Invalid metadata for ${type}: ${reason}`,
{ type, reason },
['Check the metadata structure for this approval type']
);
}
/**
* Create an error for invalid elicitation schema
*/
static invalidSchema(reason: string): DextoRuntimeError<ApprovalValidationContext> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_INVALID_SCHEMA,
ErrorScope.TOOLS,
ErrorType.USER,
`Invalid elicitation schema: ${reason}`,
{ reason },
['Ensure the schema is a valid JSON Schema', 'Check MCP server implementation']
);
}
/**
* Create an error for approval timeout
*/
static timeout(
approvalId: string,
type: ApprovalType,
timeout: number,
sessionId?: string
): DextoRuntimeError<ApprovalTimeoutContext> {
const context: ApprovalTimeoutContext = {
approvalId,
type,
timeout,
};
if (sessionId !== undefined) {
context.sessionId = sessionId;
}
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_TIMEOUT,
ErrorScope.TOOLS,
ErrorType.TIMEOUT,
`Approval request timed out after ${timeout}ms`,
context,
[
'Increase the timeout value',
'Respond to approval requests more quickly',
'Check if approval UI is functioning',
]
);
}
/**
* Create an error for cancelled approval
*/
static cancelled(
approvalId: string,
type: ApprovalType,
reason?: string
): DextoRuntimeError<ApprovalCancellationContext> {
const message = reason
? `Approval request cancelled: ${reason}`
: 'Approval request was cancelled';
const context: ApprovalCancellationContext = {
approvalId,
type,
};
if (reason !== undefined) {
context.reason = reason;
}
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_CANCELLED,
ErrorScope.TOOLS,
ErrorType.USER,
message,
context
);
}
/**
* Create an error for all approvals cancelled
*/
static cancelledAll(reason?: string): DextoRuntimeError<ApprovalCancellationContext> {
const message = reason
? `All approval requests cancelled: ${reason}`
: 'All approval requests were cancelled';
const context: ApprovalCancellationContext = {};
if (reason !== undefined) {
context.reason = reason;
}
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_CANCELLED_ALL,
ErrorScope.TOOLS,
ErrorType.USER,
message,
context
);
}
/**
* Create an error for approval provider not configured
*/
static providerNotConfigured(): DextoRuntimeError<Record<string, never>> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_PROVIDER_NOT_CONFIGURED,
ErrorScope.TOOLS,
ErrorType.SYSTEM,
'Approval provider not configured',
{},
[
'Configure an approval provider in your agent configuration',
'Check approval.mode in agent.yml',
]
);
}
/**
* Create an error for approval provider error
*/
static providerError(message: string, cause?: Error): DextoRuntimeError<{ cause?: string }> {
const context: { cause?: string } = {};
if (cause?.message !== undefined) {
context.cause = cause.message;
}
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_PROVIDER_ERROR,
ErrorScope.TOOLS,
ErrorType.SYSTEM,
`Approval provider error: ${message}`,
context,
['Check approval provider implementation', 'Review system logs for details']
);
}
/**
* Create an error for approval not found
*/
static notFound(approvalId: string): DextoRuntimeError<{ approvalId: string }> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_NOT_FOUND,
ErrorScope.TOOLS,
ErrorType.NOT_FOUND,
`Approval request not found: ${approvalId}`,
{ approvalId },
[
'Verify the approvalId is correct',
'Check if the approval has already been resolved or timed out',
]
);
}
/**
* Create an error for tool confirmation denied
*/
static toolConfirmationDenied(
toolName: string,
reason?: DenialReason,
customMessage?: string,
sessionId?: string
): DextoRuntimeError<{ toolName: string; reason?: DenialReason; sessionId?: string }> {
// Generate message based on reason
let message: string;
let suggestions: string[];
switch (reason) {
case 'user_denied':
message = customMessage ?? `Tool execution denied by user: ${toolName}`;
suggestions = ['Tool was denied by user'];
break;
case 'system_denied':
message = customMessage ?? `Tool execution denied by system policy: ${toolName}`;
suggestions = [
'Tool is in the alwaysDeny list',
'Check toolConfirmation.toolPolicies in agent configuration',
];
break;
case 'timeout':
message = customMessage ?? `Tool confirmation timed out: ${toolName}`;
suggestions = [
'Increase the timeout value',
'Respond to approval requests more quickly',
];
break;
default:
message = customMessage ?? `Tool execution denied: ${toolName}`;
suggestions = [
'Approve the tool in the confirmation dialog',
'Check tool permissions',
];
}
const context: { toolName: string; reason?: DenialReason; sessionId?: string } = {
toolName,
};
if (reason) context.reason = reason;
if (sessionId) context.sessionId = sessionId;
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_TOOL_CONFIRMATION_DENIED,
ErrorScope.TOOLS,
ErrorType.FORBIDDEN,
message,
context,
suggestions
);
}
/**
* Create an error for elicitation denied
*/
static elicitationDenied(
serverName: string,
reason?: DenialReason,
customMessage?: string,
sessionId?: string
): DextoRuntimeError<{ serverName: string; reason?: DenialReason; sessionId?: string }> {
// Generate message based on reason
let message: string;
let suggestions: string[];
switch (reason) {
case 'user_denied':
message =
customMessage ??
`Elicitation request denied by user from MCP server: ${serverName}`;
suggestions = [
'User clicked deny on the form',
'The agent cannot proceed without this input',
];
break;
case 'user_cancelled':
message =
customMessage ??
`Elicitation request cancelled by user from MCP server: ${serverName}`;
suggestions = [
'User cancelled the form',
'The agent cannot proceed without this input',
];
break;
case 'system_cancelled':
message =
customMessage ?? `Elicitation request cancelled from MCP server: ${serverName}`;
suggestions = ['Session may have ended', 'Try again'];
break;
case 'timeout':
message =
customMessage ?? `Elicitation request timed out from MCP server: ${serverName}`;
suggestions = [
'Increase the timeout value',
'Respond to elicitation requests more quickly',
];
break;
case 'elicitation_disabled':
message =
customMessage ??
`Elicitation is disabled. Cannot request input from MCP server: ${serverName}`;
suggestions = [
'Enable elicitation in your agent configuration',
'Set elicitation.enabled: true in agent.yml',
];
break;
case 'validation_failed':
message =
customMessage ??
`Elicitation form validation failed from MCP server: ${serverName}`;
suggestions = ['Check the form inputs match the schema requirements'];
break;
default:
message =
customMessage ?? `Elicitation request denied from MCP server: ${serverName}`;
suggestions = ['Complete the requested form', 'Check MCP server requirements'];
}
const context: { serverName: string; reason?: DenialReason; sessionId?: string } = {
serverName,
};
if (reason) context.reason = reason;
if (sessionId) context.sessionId = sessionId;
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_ELICITATION_DENIED,
ErrorScope.TOOLS,
ErrorType.FORBIDDEN,
message,
context,
suggestions
);
}
/**
* Create an error for elicitation validation failed
*/
static elicitationValidationFailed(
serverName: string,
errors: string[],
approvalId: string
): DextoRuntimeError<ElicitationValidationContext> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_ELICITATION_VALIDATION_FAILED,
ErrorScope.TOOLS,
ErrorType.USER,
`Elicitation form validation failed: ${errors.join(', ')}`,
{ approvalId, serverName, errors },
['Check the form inputs match the schema requirements', 'Review validation errors']
);
}
/**
* Create an error for invalid approval configuration
*/
static invalidConfig(reason: string): DextoRuntimeError<{ reason: string }> {
return new DextoRuntimeError(
ApprovalErrorCode.APPROVAL_CONFIG_INVALID,
ErrorScope.TOOLS,
ErrorType.USER,
`Invalid approval configuration: ${reason}`,
{ reason },
['Check approval configuration in agent.yml', 'Review approval.mode and related fields']
);
}
}

View File

@@ -0,0 +1,22 @@
import { randomUUID } from 'crypto';
import type { ApprovalRequest, ApprovalRequestDetails } from './types.js';
/**
* Factory function to create an approval request with generated ID and timestamp.
*
* This is a generic helper used by ApprovalManager to create properly
* formatted approval requests from simplified details.
*
* @param details - Simplified approval request details without ID and timestamp
* @returns A complete ApprovalRequest with generated UUID and current timestamp
*/
export function createApprovalRequest(details: ApprovalRequestDetails): ApprovalRequest {
return {
approvalId: randomUUID(),
type: details.type,
sessionId: details.sessionId,
timeout: details.timeout,
timestamp: new Date(),
metadata: details.metadata,
} as ApprovalRequest;
}

View File

@@ -0,0 +1,72 @@
// ============================================================================
// USER APPROVAL SYSTEM - Public API
// ============================================================================
// Types
export type {
ApprovalHandler,
ApprovalRequest,
ApprovalResponse,
ApprovalRequestDetails,
ElicitationMetadata,
ElicitationRequest,
ElicitationResponse,
ElicitationResponseData,
CustomApprovalMetadata,
CustomApprovalRequest,
CustomApprovalResponse,
CustomApprovalResponseData,
BaseApprovalRequest,
BaseApprovalResponse,
} from './types.js';
// Internal types - not exported to avoid naming conflicts with tools module
// ToolConfirmationMetadata, ToolConfirmationRequest, ToolConfirmationResponse, ToolConfirmationResponseData
export { ApprovalType, ApprovalStatus, DenialReason } from './types.js';
// Schemas
export {
ApprovalTypeSchema,
ApprovalStatusSchema,
DenialReasonSchema,
ToolConfirmationMetadataSchema,
ElicitationMetadataSchema,
CustomApprovalMetadataSchema,
BaseApprovalRequestSchema,
ToolConfirmationRequestSchema,
ElicitationRequestSchema,
CustomApprovalRequestSchema,
ApprovalRequestSchema,
ToolConfirmationResponseDataSchema,
ElicitationResponseDataSchema,
CustomApprovalResponseDataSchema,
BaseApprovalResponseSchema,
ToolConfirmationResponseSchema,
ElicitationResponseSchema,
CustomApprovalResponseSchema,
ApprovalResponseSchema,
ApprovalRequestDetailsSchema,
} from './schemas.js';
export type {
ValidatedApprovalRequest,
ValidatedApprovalResponse,
ValidatedToolConfirmationRequest,
ValidatedElicitationRequest,
ValidatedCustomApprovalRequest,
} from './schemas.js';
// Error codes and errors
export { ApprovalErrorCode } from './error-codes.js';
export { ApprovalError } from './errors.js';
export type {
ApprovalValidationContext,
ApprovalTimeoutContext,
ApprovalCancellationContext,
ElicitationValidationContext,
} from './errors.js';
// Manager
export { ApprovalManager } from './manager.js';
export type { ApprovalManagerConfig } from './manager.js';

View File

@@ -0,0 +1,957 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ApprovalManager } from './manager.js';
import { ApprovalStatus, DenialReason } from './types.js';
import { AgentEventBus } from '../events/index.js';
import { DextoRuntimeError } from '../errors/index.js';
import { ApprovalErrorCode } from './error-codes.js';
import { createMockLogger } from '../logger/v2/test-utils.js';
describe('ApprovalManager', () => {
let agentEventBus: AgentEventBus;
const mockLogger = createMockLogger();
beforeEach(() => {
agentEventBus = new AgentEventBus();
});
describe('Configuration - Separate tool and elicitation control', () => {
it('should allow auto-approve for tools while elicitation is enabled', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
// Tool confirmation should be auto-approved
const toolResponse = await manager.requestToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: { foo: 'bar' },
});
expect(toolResponse.status).toBe(ApprovalStatus.APPROVED);
});
it('should reject elicitation when disabled, even if tools are auto-approved', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: false,
timeout: 120000,
},
},
mockLogger
);
// Elicitation should throw error when disabled
await expect(
manager.requestElicitation({
schema: {
type: 'object' as const,
properties: {
name: { type: 'string' as const },
},
},
prompt: 'Enter your name',
serverName: 'Test Server',
})
).rejects.toThrow(DextoRuntimeError);
await expect(
manager.requestElicitation({
schema: {
type: 'object' as const,
properties: {
name: { type: 'string' as const },
},
},
prompt: 'Enter your name',
serverName: 'Test Server',
})
).rejects.toThrow(/Elicitation is disabled/);
});
it('should auto-deny tools while elicitation is enabled', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-deny',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
// Tool confirmation should be auto-denied
const toolResponse = await manager.requestToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: { foo: 'bar' },
});
expect(toolResponse.status).toBe(ApprovalStatus.DENIED);
});
it('should use separate timeouts for tools and elicitation', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 60000,
},
elicitation: {
enabled: true,
timeout: 180000,
},
},
mockLogger
);
const config = manager.getConfig();
expect(config.toolConfirmation.timeout).toBe(60000);
expect(config.elicitation.timeout).toBe(180000);
});
});
describe('Approval routing by type', () => {
it('should route tool confirmations to tool provider', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
const response = await manager.requestToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: {},
});
expect(response.status).toBe(ApprovalStatus.APPROVED);
});
it('should route command confirmations to tool provider', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
const response = await manager.requestCommandConfirmation({
toolName: 'bash_exec',
command: 'rm -rf /',
originalCommand: 'rm -rf /',
});
expect(response.status).toBe(ApprovalStatus.APPROVED);
});
it('should route elicitation to elicitation provider when enabled', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-deny', // Different mode for tools
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
// Elicitation should not be auto-denied (uses manual handler)
// We'll timeout immediately to avoid hanging tests
await expect(
manager.requestElicitation({
schema: {
type: 'object' as const,
properties: {
name: { type: 'string' as const },
},
},
prompt: 'Enter your name',
serverName: 'Test Server',
timeout: 1, // 1ms timeout to fail fast
})
).rejects.toThrow(); // Should timeout, not be auto-denied
});
});
describe('Pending approvals tracking', () => {
it('should track pending approvals across both providers', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
// Initially no pending approvals
expect(manager.getPendingApprovals()).toEqual([]);
// Auto-approve mode would not create pending approvals
// Event-based mode would, but we don't want hanging requests in tests
});
it('should cancel approvals in both providers', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
// Should not throw when cancelling (even if approval doesn't exist)
expect(() => manager.cancelApproval('test-id')).not.toThrow();
expect(() => manager.cancelAllApprovals()).not.toThrow();
});
});
describe('Error handling', () => {
it('should throw clear error when elicitation is disabled', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: false,
timeout: 120000,
},
},
mockLogger
);
await expect(
manager.getElicitationData({
schema: {
type: 'object' as const,
properties: {
name: { type: 'string' as const },
},
},
prompt: 'Enter your name',
serverName: 'Test Server',
})
).rejects.toThrow(/Elicitation is disabled/);
});
it('should provide helpful error message about enabling elicitation', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: false,
timeout: 120000,
},
},
mockLogger
);
try {
await manager.requestElicitation({
schema: {
type: 'object' as const,
properties: {
name: { type: 'string' as const },
},
},
prompt: 'Enter your name',
serverName: 'Test Server',
});
expect.fail('Should have thrown error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as Error).message).toContain('Enable elicitation');
expect((error as Error).message).toContain('agent configuration');
}
});
});
describe('Timeout Configuration', () => {
it('should allow undefined timeout (infinite wait) for tool confirmation', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
// No timeout specified - should wait indefinitely
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
const config = manager.getConfig();
expect(config.toolConfirmation.timeout).toBeUndefined();
});
it('should allow undefined timeout (infinite wait) for elicitation', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 60000,
},
elicitation: {
enabled: true,
// No timeout specified - should wait indefinitely
},
},
mockLogger
);
const config = manager.getConfig();
expect(config.elicitation.timeout).toBeUndefined();
});
it('should allow both timeouts to be undefined (infinite wait for all approvals)', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
// No timeout
},
elicitation: {
enabled: true,
// No timeout
},
},
mockLogger
);
const config = manager.getConfig();
expect(config.toolConfirmation.timeout).toBeUndefined();
expect(config.elicitation.timeout).toBeUndefined();
});
it('should use per-request timeout override when provided', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve', // Auto-approve so we can test immediately
timeout: 60000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
// The per-request timeout should override the config timeout
// This is tested implicitly through the factory flow
const response = await manager.requestToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: { foo: 'bar' },
timeout: 30000, // Per-request override
});
expect(response.status).toBe(ApprovalStatus.APPROVED);
});
it('should not timeout when timeout is undefined in auto-approve mode', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-approve',
// No timeout - should not cause any issues with auto-approve
},
elicitation: {
enabled: false,
},
},
mockLogger
);
const response = await manager.requestToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: {},
});
expect(response.status).toBe(ApprovalStatus.APPROVED);
});
it('should not timeout when timeout is undefined in auto-deny mode', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-deny',
// No timeout - should not cause any issues with auto-deny
},
elicitation: {
enabled: false,
},
},
mockLogger
);
const response = await manager.requestToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: {},
});
expect(response.status).toBe(ApprovalStatus.DENIED);
expect(response.reason).toBe(DenialReason.SYSTEM_DENIED);
});
});
describe('Backward compatibility', () => {
it('should work with manual mode for both tools and elicitation', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
expect(manager.getConfig()).toEqual({
toolConfirmation: {
mode: 'manual',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
});
});
it('should respect explicitly set elicitation enabled value', () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
expect(manager.getConfig().elicitation.enabled).toBe(true);
});
});
describe('Denial Reasons', () => {
it('should include system_denied reason in auto-deny mode', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-deny',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
const response = await manager.requestToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: {},
});
expect(response.status).toBe(ApprovalStatus.DENIED);
expect(response.reason).toBe(DenialReason.SYSTEM_DENIED);
expect(response.message).toContain('system policy');
});
it('should throw error with specific reason when tool is denied', async () => {
const manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'auto-deny',
timeout: 120000,
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
try {
await manager.checkToolConfirmation({
toolName: 'test_tool',
toolCallId: 'test-call-id',
args: {},
});
expect.fail('Should have thrown error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(
ApprovalErrorCode.APPROVAL_TOOL_CONFIRMATION_DENIED
);
expect((error as DextoRuntimeError).message).toContain('system policy');
expect((error as any).context.reason).toBe(DenialReason.SYSTEM_DENIED);
}
});
it('should handle user_denied reason in error message', async () => {
const _manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 1, // Quick timeout for test
},
elicitation: {
enabled: true,
timeout: 120000,
},
},
mockLogger
);
// Simulate user denying via event
setTimeout(() => {
agentEventBus.emit('approval:response', {
approvalId: expect.any(String),
status: ApprovalStatus.DENIED,
reason: DenialReason.USER_DENIED,
message: 'User clicked deny',
} as any);
}, 50);
// This will be challenging to test properly without mocking more,
// so let's just ensure the type system accepts it
expect(DenialReason.USER_DENIED).toBe('user_denied');
expect(DenialReason.TIMEOUT).toBe('timeout');
});
it('should include reason in response schema', () => {
// Verify the type system allows reason and message
const response: { reason?: DenialReason; message?: string } = {
reason: DenialReason.USER_DENIED,
message: 'You denied this request',
};
expect(response.reason).toBe(DenialReason.USER_DENIED);
expect(response.message).toBe('You denied this request');
});
it('should support all denial reason types', () => {
const reasons: DenialReason[] = [
DenialReason.USER_DENIED,
DenialReason.SYSTEM_DENIED,
DenialReason.TIMEOUT,
DenialReason.USER_CANCELLED,
DenialReason.SYSTEM_CANCELLED,
DenialReason.VALIDATION_FAILED,
DenialReason.ELICITATION_DISABLED,
];
expect(reasons.length).toBe(7);
reasons.forEach((reason) => {
expect(typeof reason).toBe('string');
});
});
});
describe('Bash Pattern Approval', () => {
let manager: ApprovalManager;
beforeEach(() => {
manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 120000,
},
elicitation: {
enabled: false,
},
},
mockLogger
);
});
describe('addBashPattern', () => {
it('should add a pattern to the approved list', () => {
manager.addBashPattern('git *');
expect(manager.getBashPatterns().has('git *')).toBe(true);
});
it('should add multiple patterns', () => {
manager.addBashPattern('git *');
manager.addBashPattern('npm *');
manager.addBashPattern('ls *');
const patterns = manager.getBashPatterns();
expect(patterns.size).toBe(3);
expect(patterns.has('git *')).toBe(true);
expect(patterns.has('npm *')).toBe(true);
expect(patterns.has('ls *')).toBe(true);
});
it('should not duplicate patterns', () => {
manager.addBashPattern('git *');
manager.addBashPattern('git *');
expect(manager.getBashPatterns().size).toBe(1);
});
});
describe('matchesBashPattern (pattern-to-pattern covering)', () => {
// Note: matchesBashPattern expects pattern keys (e.g., "git push *"),
// not raw commands. ToolManager generates pattern keys from commands.
it('should match exact pattern against exact stored pattern', () => {
manager.addBashPattern('git status *');
expect(manager.matchesBashPattern('git status *')).toBe(true);
expect(manager.matchesBashPattern('git push *')).toBe(false);
});
it('should cover narrower pattern with broader pattern', () => {
// "git *" is broader and should cover "git push *", "git status *", etc.
manager.addBashPattern('git *');
expect(manager.matchesBashPattern('git *')).toBe(true);
expect(manager.matchesBashPattern('git push *')).toBe(true);
expect(manager.matchesBashPattern('git status *')).toBe(true);
expect(manager.matchesBashPattern('npm *')).toBe(false);
});
it('should not let narrower pattern cover broader pattern', () => {
// "git push *" should NOT cover "git *"
manager.addBashPattern('git push *');
expect(manager.matchesBashPattern('git push *')).toBe(true);
expect(manager.matchesBashPattern('git *')).toBe(false);
expect(manager.matchesBashPattern('git status *')).toBe(false);
});
it('should match against multiple patterns', () => {
manager.addBashPattern('git *');
manager.addBashPattern('npm install *');
expect(manager.matchesBashPattern('git status *')).toBe(true);
expect(manager.matchesBashPattern('npm install *')).toBe(true);
// npm * is not covered, only npm install * specifically
expect(manager.matchesBashPattern('npm run *')).toBe(false);
});
it('should return false when no patterns are set', () => {
expect(manager.matchesBashPattern('git status *')).toBe(false);
});
it('should not cross-match unrelated commands', () => {
manager.addBashPattern('npm *');
// "npx" starts with "np" but is not "npm " + something
expect(manager.matchesBashPattern('npx *')).toBe(false);
});
it('should handle multi-level subcommands', () => {
manager.addBashPattern('docker compose *');
expect(manager.matchesBashPattern('docker compose *')).toBe(true);
expect(manager.matchesBashPattern('docker compose up *')).toBe(true);
expect(manager.matchesBashPattern('docker *')).toBe(false);
});
});
describe('clearBashPatterns', () => {
it('should clear all patterns', () => {
manager.addBashPattern('git *');
manager.addBashPattern('npm *');
expect(manager.getBashPatterns().size).toBe(2);
manager.clearBashPatterns();
expect(manager.getBashPatterns().size).toBe(0);
});
it('should allow adding patterns after clearing', () => {
manager.addBashPattern('git *');
manager.clearBashPatterns();
manager.addBashPattern('npm *');
expect(manager.getBashPatterns().size).toBe(1);
expect(manager.getBashPatterns().has('npm *')).toBe(true);
});
});
describe('getBashPatterns', () => {
it('should return empty set initially', () => {
expect(manager.getBashPatterns().size).toBe(0);
});
it('should return a copy that reflects current patterns', () => {
manager.addBashPattern('git *');
const patterns = manager.getBashPatterns();
expect(patterns.has('git *')).toBe(true);
// Note: ReadonlySet is a TypeScript type constraint, not runtime protection
// The returned set IS the internal set, so modifying it would affect the manager
// This is acceptable for our use case (debugging/display)
});
});
});
describe('Directory Access Approval', () => {
let manager: ApprovalManager;
beforeEach(() => {
manager = new ApprovalManager(
{
toolConfirmation: {
mode: 'manual',
timeout: 120000,
},
elicitation: {
enabled: false,
},
},
mockLogger
);
});
describe('initializeWorkingDirectory', () => {
it('should add working directory as session-approved', () => {
manager.initializeWorkingDirectory('/home/user/project');
expect(manager.isDirectorySessionApproved('/home/user/project/src/file.ts')).toBe(
true
);
});
it('should normalize the path before adding', () => {
manager.initializeWorkingDirectory('/home/user/../user/project');
expect(manager.isDirectorySessionApproved('/home/user/project/file.ts')).toBe(true);
});
});
describe('addApprovedDirectory', () => {
it('should add directory with session type by default', () => {
manager.addApprovedDirectory('/external/project');
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
});
it('should add directory with explicit session type', () => {
manager.addApprovedDirectory('/external/project', 'session');
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
});
it('should add directory with once type', () => {
manager.addApprovedDirectory('/external/project', 'once');
// 'once' type should NOT be session-approved (requires prompt each time)
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(false);
// But should be generally approved for execution
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
});
it('should not downgrade from session to once', () => {
manager.addApprovedDirectory('/external/project', 'session');
manager.addApprovedDirectory('/external/project', 'once');
// Should still be session-approved
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
});
it('should upgrade from once to session', () => {
manager.addApprovedDirectory('/external/project', 'once');
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(false);
manager.addApprovedDirectory('/external/project', 'session');
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
});
it('should normalize paths before adding', () => {
manager.addApprovedDirectory('/external/../external/project');
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
});
});
describe('isDirectorySessionApproved', () => {
it('should return true for files within session-approved directory', () => {
manager.addApprovedDirectory('/external/project', 'session');
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(true);
expect(
manager.isDirectorySessionApproved('/external/project/src/deep/file.ts')
).toBe(true);
});
it('should return false for files within once-approved directory', () => {
manager.addApprovedDirectory('/external/project', 'once');
expect(manager.isDirectorySessionApproved('/external/project/file.ts')).toBe(false);
});
it('should return false for files outside approved directories', () => {
manager.addApprovedDirectory('/external/project', 'session');
expect(manager.isDirectorySessionApproved('/other/file.ts')).toBe(false);
});
it('should handle path containment correctly', () => {
manager.addApprovedDirectory('/external', 'session');
// Approving /external should cover /external/sub/file.ts
expect(manager.isDirectorySessionApproved('/external/sub/file.ts')).toBe(true);
// But not /external-other/file.ts (different directory)
expect(manager.isDirectorySessionApproved('/external-other/file.ts')).toBe(false);
});
it('should return true when working directory is initialized', () => {
manager.initializeWorkingDirectory('/home/user/project');
expect(manager.isDirectorySessionApproved('/home/user/project/any/file.ts')).toBe(
true
);
});
});
describe('isDirectoryApproved', () => {
it('should return true for files within session-approved directory', () => {
manager.addApprovedDirectory('/external/project', 'session');
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
});
it('should return true for files within once-approved directory', () => {
manager.addApprovedDirectory('/external/project', 'once');
expect(manager.isDirectoryApproved('/external/project/file.ts')).toBe(true);
});
it('should return false for files outside approved directories', () => {
manager.addApprovedDirectory('/external/project', 'session');
expect(manager.isDirectoryApproved('/other/file.ts')).toBe(false);
});
it('should handle multiple approved directories', () => {
manager.addApprovedDirectory('/external/project1', 'session');
manager.addApprovedDirectory('/external/project2', 'once');
expect(manager.isDirectoryApproved('/external/project1/file.ts')).toBe(true);
expect(manager.isDirectoryApproved('/external/project2/file.ts')).toBe(true);
expect(manager.isDirectoryApproved('/external/project3/file.ts')).toBe(false);
});
it('should handle nested directory approvals', () => {
manager.addApprovedDirectory('/external', 'session');
// Approving /external should cover all subdirectories
expect(manager.isDirectoryApproved('/external/sub/deep/file.ts')).toBe(true);
});
});
describe('getApprovedDirectories', () => {
it('should return empty map initially', () => {
expect(manager.getApprovedDirectories().size).toBe(0);
});
it('should return map with type information', () => {
manager.addApprovedDirectory('/external/project1', 'session');
manager.addApprovedDirectory('/external/project2', 'once');
const dirs = manager.getApprovedDirectories();
expect(dirs.size).toBe(2);
// Check that paths are normalized (absolute)
const keys = Array.from(dirs.keys());
expect(keys.some((k) => k.includes('project1'))).toBe(true);
expect(keys.some((k) => k.includes('project2'))).toBe(true);
});
it('should include working directory after initialization', () => {
manager.initializeWorkingDirectory('/home/user/project');
const dirs = manager.getApprovedDirectories();
expect(dirs.size).toBe(1);
// Check that working directory is session type
const entries = Array.from(dirs.entries());
expect(entries[0]![1]).toBe('session');
});
});
describe('Session vs Once Prompting Behavior', () => {
// These tests verify the expected prompting flow
it('working directory should not require prompt (session-approved)', () => {
manager.initializeWorkingDirectory('/home/user/project');
// isDirectorySessionApproved returns true → no directory prompt needed
expect(manager.isDirectorySessionApproved('/home/user/project/src/file.ts')).toBe(
true
);
});
it('external dir after session approval should not require prompt', () => {
manager.addApprovedDirectory('/external', 'session');
// isDirectorySessionApproved returns true → no directory prompt needed
expect(manager.isDirectorySessionApproved('/external/file.ts')).toBe(true);
});
it('external dir after once approval should require prompt each time', () => {
manager.addApprovedDirectory('/external', 'once');
// isDirectorySessionApproved returns false → directory prompt needed
expect(manager.isDirectorySessionApproved('/external/file.ts')).toBe(false);
// But isDirectoryApproved returns true → execution allowed
expect(manager.isDirectoryApproved('/external/file.ts')).toBe(true);
});
it('unapproved external dir should require prompt', () => {
// No directories approved
expect(manager.isDirectorySessionApproved('/external/file.ts')).toBe(false);
expect(manager.isDirectoryApproved('/external/file.ts')).toBe(false);
});
});
});
});

View File

@@ -0,0 +1,661 @@
import path from 'node:path';
import type {
ApprovalHandler,
ApprovalRequest,
ApprovalResponse,
ApprovalRequestDetails,
ToolConfirmationMetadata,
CommandConfirmationMetadata,
ElicitationMetadata,
DirectoryAccessMetadata,
} from './types.js';
import { ApprovalType, ApprovalStatus, DenialReason } from './types.js';
import { createApprovalRequest } from './factory.js';
import type { IDextoLogger } from '../logger/v2/types.js';
import { DextoLogComponent } from '../logger/v2/types.js';
import { ApprovalError } from './errors.js';
import { patternCovers } from '../tools/bash-pattern-utils.js';
/**
* Configuration for the approval manager
*/
export interface ApprovalManagerConfig {
toolConfirmation: {
mode: 'manual' | 'auto-approve' | 'auto-deny';
timeout?: number; // Optional - no timeout if not specified
};
elicitation: {
enabled: boolean;
timeout?: number; // Optional - no timeout if not specified
};
}
/**
* ApprovalManager orchestrates all user approval flows in Dexto.
*
* It provides a unified interface for requesting user approvals across different
* types (tool confirmation, MCP elicitation, custom approvals) and manages the
* underlying approval provider based on configuration.
*
* Key responsibilities:
* - Create and submit approval requests
* - Route approvals to appropriate providers
* - Provide convenience methods for specific approval types
* - Handle approval responses and errors
* - Support multiple approval modes (manual, auto-approve, auto-deny)
*
* @example
* ```typescript
* const manager = new ApprovalManager(
* { toolConfirmation: { mode: 'manual', timeout: 60000 }, elicitation: { enabled: true, timeout: 60000 } },
* logger
* );
*
* // Request tool confirmation
* const response = await manager.requestToolConfirmation({
* toolName: 'git_commit',
* args: { message: 'feat: add feature' },
* sessionId: 'session-123'
* });
*
* if (response.status === 'approved') {
* // Execute tool
* }
* ```
*/
export class ApprovalManager {
private handler: ApprovalHandler | undefined;
private config: ApprovalManagerConfig;
private logger: IDextoLogger;
/**
* Bash command patterns approved for the current session.
* Patterns use simple glob syntax (e.g., "git *", "npm install *").
* Cleared when session ends.
*/
private bashPatterns: Set<string> = new Set();
/**
* Directories approved for file access for the current session.
* Stores normalized absolute paths mapped to their approval type:
* - 'session': No directory prompt, follows tool config (working dir + user session-approved)
* - 'once': Prompts each time, but tool can execute
* Cleared when session ends.
*/
private approvedDirectories: Map<string, 'session' | 'once'> = new Map();
constructor(config: ApprovalManagerConfig, logger: IDextoLogger) {
this.config = config;
this.logger = logger.createChild(DextoLogComponent.APPROVAL);
this.logger.debug(
`ApprovalManager initialized with toolConfirmation.mode: ${config.toolConfirmation.mode}, elicitation.enabled: ${config.elicitation.enabled}`
);
}
// ==================== Bash Pattern Methods ====================
/**
* Add a bash command pattern to the approved list for this session.
* Patterns use simple glob syntax with * as wildcard.
*
* @example
* ```typescript
* manager.addBashPattern("git *"); // Approves all git commands
* manager.addBashPattern("npm install *"); // Approves npm install with any package
* ```
*/
addBashPattern(pattern: string): void {
this.bashPatterns.add(pattern);
this.logger.debug(`Added bash pattern: "${pattern}"`);
}
/**
* Check if a bash pattern key is covered by any approved pattern.
* Uses pattern-to-pattern covering for broader pattern support.
*
* @param patternKey The pattern key generated from the command (e.g., "git push *")
* @returns true if the pattern key is covered by an approved pattern
*/
matchesBashPattern(patternKey: string): boolean {
for (const storedPattern of this.bashPatterns) {
if (patternCovers(storedPattern, patternKey)) {
this.logger.debug(
`Pattern key "${patternKey}" is covered by approved pattern "${storedPattern}"`
);
return true;
}
}
return false;
}
/**
* Clear all approved bash patterns.
* Should be called when session ends.
*/
clearBashPatterns(): void {
const count = this.bashPatterns.size;
this.bashPatterns.clear();
if (count > 0) {
this.logger.debug(`Cleared ${count} bash patterns`);
}
}
/**
* Get the current set of approved bash patterns (for debugging/display).
*/
getBashPatterns(): ReadonlySet<string> {
return this.bashPatterns;
}
// ==================== Directory Access Methods ====================
/**
* Initialize the working directory as a session-approved directory.
* This should be called once during setup to ensure the working directory
* never triggers directory access prompts.
*
* @param workingDir The working directory path
*/
initializeWorkingDirectory(workingDir: string): void {
const normalized = path.resolve(workingDir);
this.approvedDirectories.set(normalized, 'session');
this.logger.debug(`Initialized working directory as session-approved: "${normalized}"`);
}
/**
* Add a directory to the approved list for this session.
* Files within this directory (including subdirectories) will be allowed.
*
* @param directory Absolute path to the directory to approve
* @param type The approval type:
* - 'session': No directory prompt on future accesses, follows tool config
* - 'once': Will prompt again on future accesses, but tool can execute this time
* @example
* ```typescript
* manager.addApprovedDirectory("/external/project", 'session');
* // Now /external/project/src/file.ts is accessible without directory prompt
*
* manager.addApprovedDirectory("/tmp/files", 'once');
* // Tool can access, but will prompt again next time
* ```
*/
addApprovedDirectory(directory: string, type: 'session' | 'once' = 'session'): void {
const normalized = path.resolve(directory);
const existing = this.approvedDirectories.get(normalized);
// Don't downgrade from 'session' to 'once'
if (existing === 'session') {
this.logger.debug(
`Directory "${normalized}" already approved as 'session', not downgrading to '${type}'`
);
return;
}
this.approvedDirectories.set(normalized, type);
this.logger.debug(`Added approved directory: "${normalized}" (type: ${type})`);
}
/**
* Check if a file path is within any session-approved directory.
* This is used for PROMPTING decisions - only 'session' type directories count.
* Working directory and user session-approved directories return true.
*
* @param filePath The file path to check (can be relative or absolute)
* @returns true if the path is within a session-approved directory
*/
isDirectorySessionApproved(filePath: string): boolean {
const normalized = path.resolve(filePath);
for (const [approvedDir, type] of this.approvedDirectories) {
// Only check 'session' type directories for prompting decisions
if (type !== 'session') continue;
const relative = path.relative(approvedDir, normalized);
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
this.logger.debug(
`Path "${normalized}" is within session-approved directory "${approvedDir}"`
);
return true;
}
}
return false;
}
/**
* Check if a file path is within any approved directory (session OR once).
* This is used for EXECUTION decisions - both 'session' and 'once' types count.
* PathValidator uses this to determine if a tool can access the path.
*
* @param filePath The file path to check (can be relative or absolute)
* @returns true if the path is within any approved directory
*/
isDirectoryApproved(filePath: string): boolean {
const normalized = path.resolve(filePath);
for (const [approvedDir] of this.approvedDirectories) {
const relative = path.relative(approvedDir, normalized);
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
this.logger.debug(
`Path "${normalized}" is within approved directory "${approvedDir}"`
);
return true;
}
}
return false;
}
/**
* Clear all approved directories.
* Should be called when session ends.
*/
clearApprovedDirectories(): void {
const count = this.approvedDirectories.size;
this.approvedDirectories.clear();
if (count > 0) {
this.logger.debug(`Cleared ${count} approved directories`);
}
}
/**
* Get the current map of approved directories with their types (for debugging/display).
*/
getApprovedDirectories(): ReadonlyMap<string, 'session' | 'once'> {
return this.approvedDirectories;
}
/**
* Get just the directory paths that are approved (for debugging/display).
*/
getApprovedDirectoryPaths(): string[] {
return Array.from(this.approvedDirectories.keys());
}
/**
* Clear all session-scoped approvals (bash patterns and directories).
* Convenience method for clearing all session state at once.
*/
clearSessionApprovals(): void {
this.clearBashPatterns();
this.clearApprovedDirectories();
this.logger.debug('Cleared all session approvals');
}
/**
* Request directory access approval.
* Convenience method for directory access requests.
*
* @example
* ```typescript
* const response = await manager.requestDirectoryAccess({
* path: '/external/project/src/file.ts',
* parentDir: '/external/project',
* operation: 'write',
* toolName: 'write_file',
* sessionId: 'session-123'
* });
* ```
*/
async requestDirectoryAccess(
metadata: DirectoryAccessMetadata & { sessionId?: string; timeout?: number }
): Promise<ApprovalResponse> {
const { sessionId, timeout, ...directoryMetadata } = metadata;
const details: ApprovalRequestDetails = {
type: ApprovalType.DIRECTORY_ACCESS,
// Use provided timeout, fallback to config timeout, or undefined (no timeout)
timeout: timeout !== undefined ? timeout : this.config.toolConfirmation.timeout,
metadata: directoryMetadata,
};
if (sessionId !== undefined) {
details.sessionId = sessionId;
}
return this.requestApproval(details);
}
/**
* Request a generic approval
*/
async requestApproval(details: ApprovalRequestDetails): Promise<ApprovalResponse> {
const request = createApprovalRequest(details);
// Check elicitation config if this is an elicitation request
if (request.type === ApprovalType.ELICITATION && !this.config.elicitation.enabled) {
throw ApprovalError.invalidConfig(
'Elicitation is disabled. Enable elicitation in your agent configuration to use the ask_user tool or MCP server elicitations.'
);
}
// Handle all approval types uniformly
return this.handleApproval(request);
}
/**
* Handle approval requests (tool confirmation, elicitation, command confirmation, directory access, custom)
* @private
*/
private async handleApproval(request: ApprovalRequest): Promise<ApprovalResponse> {
// Elicitation always uses manual mode (requires handler)
if (request.type === ApprovalType.ELICITATION) {
const handler = this.ensureHandler();
this.logger.info(
`Elicitation requested, approvalId: ${request.approvalId}, sessionId: ${request.sessionId ?? 'global'}`
);
return handler(request);
}
// Tool/command/directory-access/custom confirmations respect the configured mode
const mode = this.config.toolConfirmation.mode;
// Auto-approve mode
if (mode === 'auto-approve') {
this.logger.info(
`Auto-approve approval '${request.type}', approvalId: ${request.approvalId}`
);
const response: ApprovalResponse = {
approvalId: request.approvalId,
status: ApprovalStatus.APPROVED,
};
if (request.sessionId !== undefined) {
response.sessionId = request.sessionId;
}
return response;
}
// Auto-deny mode
if (mode === 'auto-deny') {
this.logger.info(
`Auto-deny approval '${request.type}', approvalId: ${request.approvalId}`
);
const response: ApprovalResponse = {
approvalId: request.approvalId,
status: ApprovalStatus.DENIED,
reason: DenialReason.SYSTEM_DENIED,
message: `Approval automatically denied by system policy (auto-deny mode)`,
};
if (request.sessionId !== undefined) {
response.sessionId = request.sessionId;
}
return response;
}
// Manual mode - delegate to handler
const handler = this.ensureHandler();
this.logger.info(
`Manual approval '${request.type}' requested, approvalId: ${request.approvalId}, sessionId: ${request.sessionId ?? 'global'}`
);
return handler(request);
}
/**
* Request tool confirmation approval
* Convenience method for tool execution confirmation
*
* TODO: Make sessionId required once all callers are updated to pass it
* Tool confirmations always happen in session context during LLM execution
*/
async requestToolConfirmation(
metadata: ToolConfirmationMetadata & { sessionId?: string; timeout?: number }
): Promise<ApprovalResponse> {
const { sessionId, timeout, ...toolMetadata } = metadata;
const details: ApprovalRequestDetails = {
type: ApprovalType.TOOL_CONFIRMATION,
// Use provided timeout, fallback to config timeout, or undefined (no timeout)
timeout: timeout !== undefined ? timeout : this.config.toolConfirmation.timeout,
metadata: toolMetadata,
};
if (sessionId !== undefined) {
details.sessionId = sessionId;
}
return this.requestApproval(details);
}
/**
* Request command confirmation approval
* Convenience method for dangerous command execution within an already-approved tool
*
* This is different from tool confirmation - it's for per-command approval
* of dangerous operations (like rm, git push) within tools that are already approved.
*
* TODO: Make sessionId required once all callers are updated to pass it
* Command confirmations always happen during tool execution which has session context
*
* @example
* ```typescript
* // bash_exec tool is approved, but dangerous commands still require approval
* const response = await manager.requestCommandConfirmation({
* toolName: 'bash_exec',
* command: 'rm -rf /important',
* originalCommand: 'rm -rf /important',
* sessionId: 'session-123'
* });
* ```
*/
async requestCommandConfirmation(
metadata: CommandConfirmationMetadata & { sessionId?: string; timeout?: number }
): Promise<ApprovalResponse> {
const { sessionId, timeout, ...commandMetadata } = metadata;
const details: ApprovalRequestDetails = {
type: ApprovalType.COMMAND_CONFIRMATION,
// Use provided timeout, fallback to config timeout, or undefined (no timeout)
timeout: timeout !== undefined ? timeout : this.config.toolConfirmation.timeout,
metadata: commandMetadata,
};
if (sessionId !== undefined) {
details.sessionId = sessionId;
}
return this.requestApproval(details);
}
/**
* Request elicitation from MCP server
* Convenience method for MCP elicitation requests
*
* Note: sessionId is optional because MCP servers are shared across sessions
* and the MCP protocol doesn't include session context in elicitation requests.
*/
async requestElicitation(
metadata: ElicitationMetadata & { sessionId?: string; timeout?: number }
): Promise<ApprovalResponse> {
const { sessionId, timeout, ...elicitationMetadata } = metadata;
const details: ApprovalRequestDetails = {
type: ApprovalType.ELICITATION,
// Use provided timeout, fallback to config timeout, or undefined (no timeout)
timeout: timeout !== undefined ? timeout : this.config.elicitation.timeout,
metadata: elicitationMetadata,
};
if (sessionId !== undefined) {
details.sessionId = sessionId;
}
return this.requestApproval(details);
}
/**
* Check if tool confirmation was approved
* Throws appropriate error if denied
*/
async checkToolConfirmation(
metadata: ToolConfirmationMetadata & { sessionId?: string; timeout?: number }
): Promise<boolean> {
const response = await this.requestToolConfirmation(metadata);
if (response.status === ApprovalStatus.APPROVED) {
return true;
} else if (response.status === ApprovalStatus.DENIED) {
throw ApprovalError.toolConfirmationDenied(
metadata.toolName,
response.reason,
response.message,
metadata.sessionId
);
} else {
throw ApprovalError.cancelled(
response.approvalId,
ApprovalType.TOOL_CONFIRMATION,
response.message ?? response.reason
);
}
}
/**
* Get elicitation form data
* Throws appropriate error if denied or cancelled
*/
async getElicitationData(
metadata: ElicitationMetadata & { sessionId?: string; timeout?: number }
): Promise<Record<string, unknown>> {
const response = await this.requestElicitation(metadata);
if (response.status === ApprovalStatus.APPROVED) {
// Extract formData from response (handler always provides formData for elicitation)
if (
response.data &&
typeof response.data === 'object' &&
'formData' in response.data &&
typeof (response.data as { formData: unknown }).formData === 'object' &&
(response.data as { formData: unknown }).formData !== null
) {
return (response.data as { formData: Record<string, unknown> }).formData;
}
// Fallback to empty form if data is missing (edge case)
return {};
} else if (response.status === ApprovalStatus.DENIED) {
throw ApprovalError.elicitationDenied(
metadata.serverName,
response.reason,
response.message,
metadata.sessionId
);
} else {
throw ApprovalError.cancelled(
response.approvalId,
ApprovalType.ELICITATION,
response.message ?? response.reason
);
}
}
/**
* Cancel a specific approval request
*/
cancelApproval(approvalId: string): void {
this.handler?.cancel?.(approvalId);
}
/**
* Cancel all pending approval requests
*/
cancelAllApprovals(): void {
this.handler?.cancelAll?.();
}
/**
* Get list of pending approval IDs
*/
getPendingApprovals(): string[] {
return this.handler?.getPending?.() ?? [];
}
/**
* Get full pending approval requests
*/
getPendingApprovalRequests(): ApprovalRequest[] {
return this.handler?.getPendingRequests?.() ?? [];
}
/**
* Auto-approve pending requests that match a predicate.
* Used when a pattern is remembered to auto-approve other parallel requests
* that would now match the same pattern.
*
* @param predicate Function that returns true for requests that should be auto-approved
* @param responseData Optional data to include in the auto-approval response
* @returns Number of requests that were auto-approved
*/
autoApprovePendingRequests(
predicate: (request: ApprovalRequest) => boolean,
responseData?: Record<string, unknown>
): number {
const count = this.handler?.autoApprovePending?.(predicate, responseData) ?? 0;
if (count > 0) {
this.logger.info(`Auto-approved ${count} pending request(s) due to matching pattern`);
}
return count;
}
/**
* Get current configuration
*/
getConfig(): ApprovalManagerConfig {
return { ...this.config };
}
/**
* Set the approval handler for manual approval mode.
*
* The handler will be called for:
* - Tool confirmation requests when toolConfirmation.mode is 'manual'
* - All elicitation requests (when elicitation is enabled, regardless of toolConfirmation.mode)
*
* A handler must be set before processing requests if:
* - toolConfirmation.mode is 'manual', or
* - elicitation is enabled (elicitation.enabled is true)
*
* @param handler The approval handler function, or null to clear
*/
setHandler(handler: ApprovalHandler | null): void {
if (handler === null) {
this.handler = undefined;
} else {
this.handler = handler;
}
this.logger.debug(`Approval handler ${handler ? 'registered' : 'cleared'}`);
}
/**
* Clear the current approval handler
*/
clearHandler(): void {
this.handler = undefined;
this.logger.debug('Approval handler cleared');
}
/**
* Check if an approval handler is registered
*/
public hasHandler(): boolean {
return this.handler !== undefined;
}
/**
* Get the approval handler, throwing if not set
* @private
*/
private ensureHandler(): ApprovalHandler {
if (!this.handler) {
// TODO: add an example for usage here for users
throw ApprovalError.invalidConfig(
'An approval handler is required but not configured.\n' +
'Handlers are required for:\n' +
' • manual tool confirmation mode\n' +
' • all elicitation requests (when elicitation is enabled)\n' +
'Either:\n' +
' • set toolConfirmation.mode to "auto-approve" or "auto-deny", or\n' +
' • disable elicitation (set elicitation.enabled: false), or\n' +
' • call agent.setApprovalHandler(...) before processing requests.'
);
}
return this.handler;
}
}

View File

@@ -0,0 +1,386 @@
// ============================================================================
// USER APPROVAL SCHEMAS - Zod validation schemas for approval requests/responses
// ============================================================================
import { z } from 'zod';
import type { JSONSchema7 } from 'json-schema';
import { ApprovalType, ApprovalStatus, DenialReason } from './types.js';
import type { ToolDisplayData } from '../tools/display-types.js';
import { isValidDisplayData } from '../tools/display-types.js';
// Zod schema that validates as object but types as JSONSchema7
const JsonSchema7Schema = z.record(z.unknown()) as z.ZodType<JSONSchema7>;
/**
* Schema for approval types
*/
export const ApprovalTypeSchema = z.nativeEnum(ApprovalType);
/**
* Schema for approval status
*/
export const ApprovalStatusSchema = z.nativeEnum(ApprovalStatus);
/**
* Schema for denial/cancellation reasons
*/
export const DenialReasonSchema = z.nativeEnum(DenialReason);
// Custom Zod schema for ToolDisplayData validation
const ToolDisplayDataSchema = z.custom<ToolDisplayData>((val) => isValidDisplayData(val), {
message: 'Invalid ToolDisplayData',
});
/**
* Tool confirmation metadata schema
*/
export const ToolConfirmationMetadataSchema = z
.object({
toolName: z.string().describe('Name of the tool to confirm'),
toolCallId: z.string().describe('Unique tool call ID for tracking parallel tool calls'),
args: z.record(z.unknown()).describe('Arguments for the tool'),
description: z.string().optional().describe('Description of the tool'),
displayPreview: ToolDisplayDataSchema.optional().describe(
'Preview display data for approval UI (e.g., diff preview)'
),
suggestedPatterns: z
.array(z.string())
.optional()
.describe(
'Suggested patterns for session approval (for bash commands). ' +
'E.g., ["git push *", "git *"] for command "git push origin main"'
),
})
.strict()
.describe('Tool confirmation metadata');
/**
* Command confirmation metadata schema
* TODO: Consider combining this with regular tools schemas for consistency
*/
export const CommandConfirmationMetadataSchema = z
.object({
toolName: z.string().describe('Name of the tool executing the command'),
command: z.string().describe('The normalized command to execute'),
originalCommand: z
.string()
.optional()
.describe('The original command before normalization'),
})
.strict()
.describe('Command confirmation metadata');
/**
* Elicitation metadata schema
*/
export const ElicitationMetadataSchema = z
.object({
schema: JsonSchema7Schema.describe('JSON Schema for the form'),
prompt: z.string().describe('Prompt to show the user'),
serverName: z.string().describe('MCP server requesting input'),
context: z.record(z.unknown()).optional().describe('Additional context'),
})
.strict()
.describe('Elicitation metadata');
/**
* Custom approval metadata schema - flexible
*/
export const CustomApprovalMetadataSchema = z.record(z.unknown()).describe('Custom metadata');
/**
* Directory access metadata schema
* Used when a tool tries to access files outside the working directory
*/
export const DirectoryAccessMetadataSchema = z
.object({
path: z.string().describe('Full path being accessed'),
parentDir: z.string().describe('Parent directory (what gets approved for session)'),
operation: z.enum(['read', 'write', 'edit']).describe('Type of file operation'),
toolName: z.string().describe('Name of the tool requesting access'),
})
.strict()
.describe('Directory access metadata');
/**
* Base approval request schema
*/
export const BaseApprovalRequestSchema = z
.object({
approvalId: z.string().uuid().describe('Unique approval identifier'),
type: ApprovalTypeSchema.describe('Type of approval'),
sessionId: z.string().optional().describe('Session identifier'),
timeout: z
.number()
.int()
.positive()
.optional()
.describe('Timeout in milliseconds (optional - no timeout if not specified)'),
timestamp: z.date().describe('When the request was created'),
})
.describe('Base approval request');
/**
* Tool confirmation request schema
*/
export const ToolConfirmationRequestSchema = BaseApprovalRequestSchema.extend({
type: z.literal(ApprovalType.TOOL_CONFIRMATION),
metadata: ToolConfirmationMetadataSchema,
}).strict();
/**
* Command confirmation request schema
*/
export const CommandConfirmationRequestSchema = BaseApprovalRequestSchema.extend({
type: z.literal(ApprovalType.COMMAND_CONFIRMATION),
metadata: CommandConfirmationMetadataSchema,
}).strict();
/**
* Elicitation request schema
*/
export const ElicitationRequestSchema = BaseApprovalRequestSchema.extend({
type: z.literal(ApprovalType.ELICITATION),
metadata: ElicitationMetadataSchema,
}).strict();
/**
* Custom approval request schema
*/
export const CustomApprovalRequestSchema = BaseApprovalRequestSchema.extend({
type: z.literal(ApprovalType.CUSTOM),
metadata: CustomApprovalMetadataSchema,
}).strict();
/**
* Directory access request schema
*/
export const DirectoryAccessRequestSchema = BaseApprovalRequestSchema.extend({
type: z.literal(ApprovalType.DIRECTORY_ACCESS),
metadata: DirectoryAccessMetadataSchema,
}).strict();
/**
* Discriminated union for all approval requests
*/
export const ApprovalRequestSchema = z.discriminatedUnion('type', [
ToolConfirmationRequestSchema,
CommandConfirmationRequestSchema,
ElicitationRequestSchema,
CustomApprovalRequestSchema,
DirectoryAccessRequestSchema,
]);
/**
* Tool confirmation response data schema
*/
export const ToolConfirmationResponseDataSchema = z
.object({
rememberChoice: z
.boolean()
.optional()
.describe('Remember this tool for the session (approves ALL uses of this tool)'),
rememberPattern: z
.string()
.optional()
.describe(
'Remember a command pattern for bash commands (e.g., "git *"). ' +
'Only applicable for bash_exec tool approvals.'
),
})
.strict()
.describe('Tool confirmation response data');
/**
* Command confirmation response data schema
*/
export const CommandConfirmationResponseDataSchema = z
.object({
// Command confirmations don't have remember choice - they're per-command
// Could add command pattern remembering in future (e.g., "remember git push *")
})
.strict()
.describe('Command confirmation response data');
/**
* Elicitation response data schema
*/
export const ElicitationResponseDataSchema = z
.object({
formData: z.record(z.unknown()).describe('Form data matching schema'),
})
.strict()
.describe('Elicitation response data');
/**
* Custom approval response data schema
*/
export const CustomApprovalResponseDataSchema = z
.record(z.unknown())
.describe('Custom response data');
/**
* Directory access response data schema
*/
export const DirectoryAccessResponseDataSchema = z
.object({
rememberDirectory: z
.boolean()
.optional()
.describe('Remember this directory for the session (allows all file access within it)'),
})
.strict()
.describe('Directory access response data');
/**
* Base approval response schema
*/
export const BaseApprovalResponseSchema = z
.object({
approvalId: z.string().uuid().describe('Must match request approvalId'),
status: ApprovalStatusSchema.describe('Approval status'),
sessionId: z.string().optional().describe('Session identifier'),
reason: DenialReasonSchema.optional().describe(
'Reason for denial/cancellation (only present when status is denied or cancelled)'
),
message: z
.string()
.optional()
.describe('Human-readable message explaining the denial/cancellation'),
timeoutMs: z
.number()
.int()
.positive()
.optional()
.describe('Timeout duration in milliseconds (present for timeout events)'),
})
.describe('Base approval response');
/**
* Tool confirmation response schema
*/
export const ToolConfirmationResponseSchema = BaseApprovalResponseSchema.extend({
data: ToolConfirmationResponseDataSchema.optional(),
}).strict();
/**
* Command confirmation response schema
*/
export const CommandConfirmationResponseSchema = BaseApprovalResponseSchema.extend({
data: CommandConfirmationResponseDataSchema.optional(),
}).strict();
/**
* Elicitation response schema
*/
export const ElicitationResponseSchema = BaseApprovalResponseSchema.extend({
data: ElicitationResponseDataSchema.optional(),
}).strict();
/**
* Custom approval response schema
*/
export const CustomApprovalResponseSchema = BaseApprovalResponseSchema.extend({
data: CustomApprovalResponseDataSchema.optional(),
}).strict();
/**
* Directory access response schema
*/
export const DirectoryAccessResponseSchema = BaseApprovalResponseSchema.extend({
data: DirectoryAccessResponseDataSchema.optional(),
}).strict();
/**
* Union of all approval responses
*/
export const ApprovalResponseSchema = z.union([
ToolConfirmationResponseSchema,
CommandConfirmationResponseSchema,
ElicitationResponseSchema,
CustomApprovalResponseSchema,
DirectoryAccessResponseSchema,
]);
/**
* Approval request details schema for creating requests
*/
export const ApprovalRequestDetailsSchema = z
.object({
type: ApprovalTypeSchema,
sessionId: z.string().optional(),
timeout: z
.number()
.int()
.positive()
.optional()
.describe('Timeout in milliseconds (optional - no timeout if not specified)'),
metadata: z.union([
ToolConfirmationMetadataSchema,
CommandConfirmationMetadataSchema,
ElicitationMetadataSchema,
CustomApprovalMetadataSchema,
DirectoryAccessMetadataSchema,
]),
})
.superRefine((data, ctx) => {
// Validate metadata matches type
if (data.type === ApprovalType.TOOL_CONFIRMATION) {
const result = ToolConfirmationMetadataSchema.safeParse(data.metadata);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Metadata must match ToolConfirmationMetadataSchema for TOOL_CONFIRMATION type',
path: ['metadata'],
});
}
} else if (data.type === ApprovalType.COMMAND_CONFIRMATION) {
const result = CommandConfirmationMetadataSchema.safeParse(data.metadata);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Metadata must match CommandConfirmationMetadataSchema for COMMAND_CONFIRMATION type',
path: ['metadata'],
});
}
} else if (data.type === ApprovalType.ELICITATION) {
const result = ElicitationMetadataSchema.safeParse(data.metadata);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Metadata must match ElicitationMetadataSchema for ELICITATION type',
path: ['metadata'],
});
}
} else if (data.type === ApprovalType.DIRECTORY_ACCESS) {
const result = DirectoryAccessMetadataSchema.safeParse(data.metadata);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Metadata must match DirectoryAccessMetadataSchema for DIRECTORY_ACCESS type',
path: ['metadata'],
});
}
} else if (data.type === ApprovalType.CUSTOM) {
const result = CustomApprovalMetadataSchema.safeParse(data.metadata);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Metadata must match CustomApprovalMetadataSchema for CUSTOM type',
path: ['metadata'],
});
}
}
});
/**
* Type inference for validated schemas
*/
export type ValidatedApprovalRequest = z.output<typeof ApprovalRequestSchema>;
export type ValidatedApprovalResponse = z.output<typeof ApprovalResponseSchema>;
export type ValidatedToolConfirmationRequest = z.output<typeof ToolConfirmationRequestSchema>;
export type ValidatedElicitationRequest = z.output<typeof ElicitationRequestSchema>;
export type ValidatedCustomApprovalRequest = z.output<typeof CustomApprovalRequestSchema>;

View File

@@ -0,0 +1,352 @@
// ============================================================================
// USER APPROVAL TYPES - Generalized approval and user input system
// ============================================================================
import type { z } from 'zod';
import type {
ToolConfirmationMetadataSchema,
CommandConfirmationMetadataSchema,
ElicitationMetadataSchema,
CustomApprovalMetadataSchema,
DirectoryAccessMetadataSchema,
BaseApprovalRequestSchema,
ToolConfirmationRequestSchema,
CommandConfirmationRequestSchema,
ElicitationRequestSchema,
CustomApprovalRequestSchema,
DirectoryAccessRequestSchema,
ApprovalRequestSchema,
ApprovalRequestDetailsSchema,
ToolConfirmationResponseDataSchema,
CommandConfirmationResponseDataSchema,
ElicitationResponseDataSchema,
CustomApprovalResponseDataSchema,
DirectoryAccessResponseDataSchema,
BaseApprovalResponseSchema,
ToolConfirmationResponseSchema,
CommandConfirmationResponseSchema,
ElicitationResponseSchema,
CustomApprovalResponseSchema,
DirectoryAccessResponseSchema,
ApprovalResponseSchema,
} from './schemas.js';
/**
* Types of approval requests supported by the system
*/
export enum ApprovalType {
/**
* Binary approval for tool execution
* Metadata contains: toolName, args, description
*/
TOOL_CONFIRMATION = 'tool_confirmation',
/**
* Binary approval for dangerous commands within an already-approved tool
* Metadata contains: toolName, command, originalCommand
* (sessionId is provided at the request level, not in metadata)
*/
COMMAND_CONFIRMATION = 'command_confirmation',
/**
* Schema-based form input from MCP servers
* Metadata contains: schema, prompt, serverName, context
*/
ELICITATION = 'elicitation',
/**
* Approval for accessing files outside the working directory
* Metadata contains: path, parentDir, operation, toolName
*/
DIRECTORY_ACCESS = 'directory_access',
/**
* Custom approval types for extensibility
* Metadata format defined by consumer
*/
CUSTOM = 'custom',
}
/**
* Status of an approval response
*/
export enum ApprovalStatus {
APPROVED = 'approved',
DENIED = 'denied',
CANCELLED = 'cancelled',
}
/**
* Reason for denial or cancellation
* Provides context about why an approval was not granted
*/
export enum DenialReason {
/** User explicitly clicked deny/reject */
USER_DENIED = 'user_denied',
/** System denied due to policy (auto-deny mode, alwaysDeny list) */
SYSTEM_DENIED = 'system_denied',
/** Request timed out waiting for user response */
TIMEOUT = 'timeout',
/** User cancelled the request */
USER_CANCELLED = 'user_cancelled',
/** System cancelled (session ended, agent stopped) */
SYSTEM_CANCELLED = 'system_cancelled',
/** Validation failed (form validation, schema mismatch) */
VALIDATION_FAILED = 'validation_failed',
/** Elicitation disabled in configuration */
ELICITATION_DISABLED = 'elicitation_disabled',
}
// ============================================================================
// Metadata Types - Derived from Zod schemas
// ============================================================================
/**
* Tool confirmation specific metadata
* Derived from ToolConfirmationMetadataSchema
*/
export type ToolConfirmationMetadata = z.output<typeof ToolConfirmationMetadataSchema>;
/**
* Command confirmation specific metadata
* Derived from CommandConfirmationMetadataSchema
*/
export type CommandConfirmationMetadata = z.output<typeof CommandConfirmationMetadataSchema>;
/**
* Elicitation specific metadata (MCP)
* Derived from ElicitationMetadataSchema
*/
export type ElicitationMetadata = z.output<typeof ElicitationMetadataSchema>;
/**
* Custom approval metadata - flexible structure
* Derived from CustomApprovalMetadataSchema
*/
export type CustomApprovalMetadata = z.output<typeof CustomApprovalMetadataSchema>;
/**
* Directory access metadata
* Derived from DirectoryAccessMetadataSchema
*/
export type DirectoryAccessMetadata = z.output<typeof DirectoryAccessMetadataSchema>;
// ============================================================================
// Request Types - Derived from Zod schemas
// ============================================================================
/**
* Base approval request that all approvals extend
* Derived from BaseApprovalRequestSchema
*/
export type BaseApprovalRequest<_TMetadata = unknown> = z.output<typeof BaseApprovalRequestSchema>;
/**
* Tool confirmation request
* Derived from ToolConfirmationRequestSchema
*/
export type ToolConfirmationRequest = z.output<typeof ToolConfirmationRequestSchema>;
/**
* Command confirmation request
* Derived from CommandConfirmationRequestSchema
*/
export type CommandConfirmationRequest = z.output<typeof CommandConfirmationRequestSchema>;
/**
* Elicitation request from MCP server
* Derived from ElicitationRequestSchema
*/
export type ElicitationRequest = z.output<typeof ElicitationRequestSchema>;
/**
* Custom approval request
* Derived from CustomApprovalRequestSchema
*/
export type CustomApprovalRequest = z.output<typeof CustomApprovalRequestSchema>;
/**
* Directory access request
* Derived from DirectoryAccessRequestSchema
*/
export type DirectoryAccessRequest = z.output<typeof DirectoryAccessRequestSchema>;
/**
* Union of all approval request types
* Derived from ApprovalRequestSchema
*/
export type ApprovalRequest = z.output<typeof ApprovalRequestSchema>;
// ============================================================================
// Response Data Types - Derived from Zod schemas
// ============================================================================
/**
* Tool confirmation response data
* Derived from ToolConfirmationResponseDataSchema
*/
export type ToolConfirmationResponseData = z.output<typeof ToolConfirmationResponseDataSchema>;
/**
* Command confirmation response data
* Derived from CommandConfirmationResponseDataSchema
*/
export type CommandConfirmationResponseData = z.output<
typeof CommandConfirmationResponseDataSchema
>;
/**
* Elicitation response data - validated form inputs
* Derived from ElicitationResponseDataSchema
*/
export type ElicitationResponseData = z.output<typeof ElicitationResponseDataSchema>;
/**
* Custom approval response data
* Derived from CustomApprovalResponseDataSchema
*/
export type CustomApprovalResponseData = z.output<typeof CustomApprovalResponseDataSchema>;
/**
* Directory access response data
* Derived from DirectoryAccessResponseDataSchema
*/
export type DirectoryAccessResponseData = z.output<typeof DirectoryAccessResponseDataSchema>;
// ============================================================================
// Response Types - Derived from Zod schemas
// ============================================================================
/**
* Base approval response
* Derived from BaseApprovalResponseSchema
*/
export type BaseApprovalResponse<_TData = unknown> = z.output<typeof BaseApprovalResponseSchema>;
/**
* Tool confirmation response
* Derived from ToolConfirmationResponseSchema
*/
export type ToolConfirmationResponse = z.output<typeof ToolConfirmationResponseSchema>;
/**
* Command confirmation response
* Derived from CommandConfirmationResponseSchema
*/
export type CommandConfirmationResponse = z.output<typeof CommandConfirmationResponseSchema>;
/**
* Elicitation response
* Derived from ElicitationResponseSchema
*/
export type ElicitationResponse = z.output<typeof ElicitationResponseSchema>;
/**
* Custom approval response
* Derived from CustomApprovalResponseSchema
*/
export type CustomApprovalResponse = z.output<typeof CustomApprovalResponseSchema>;
/**
* Directory access response
* Derived from DirectoryAccessResponseSchema
*/
export type DirectoryAccessResponse = z.output<typeof DirectoryAccessResponseSchema>;
/**
* Union of all approval response types
* Derived from ApprovalResponseSchema
*/
export type ApprovalResponse = z.output<typeof ApprovalResponseSchema>;
// ============================================================================
// Helper Types
// ============================================================================
/**
* Details for creating an approval request
* Derived from ApprovalRequestDetailsSchema
*/
export type ApprovalRequestDetails = z.output<typeof ApprovalRequestDetailsSchema>;
/**
* Handler interface for processing approval requests.
*
* This is the core abstraction for approval handling in Dexto. When tool confirmation
* mode is 'manual', a handler must be provided to process approval requests.
*
* The handler is a callable interface that:
* - Processes approval requests and returns responses
* - Manages pending approval state (for cancellation)
* - Provides lifecycle management methods
*
* @example
* ```typescript
* const handler: ApprovalHandler = Object.assign(
* async (request: ApprovalRequest) => {
* console.log(`Approve tool: ${request.metadata.toolName}?`);
* // In real implementation, wait for user input
* return {
* approvalId: request.approvalId,
* status: ApprovalStatus.APPROVED,
* sessionId: request.sessionId,
* };
* },
* {
* cancel: (id: string) => { },
* cancelAll: () => { },
* getPending: () => [] as string[],
* }
* );
* ```
*/
export interface ApprovalHandler {
/**
* Process an approval request
* @param request The approval request to handle
* @returns Promise resolving to the approval response
*/
(request: ApprovalRequest): Promise<ApprovalResponse>;
/**
* Cancel a specific pending approval request (optional)
* @param approvalId The ID of the approval to cancel
* @remarks Not all handlers support cancellation (e.g., auto-approve handlers)
*/
cancel?(approvalId: string): void;
/**
* Cancel all pending approval requests (optional)
* @remarks Not all handlers support cancellation (e.g., auto-approve handlers)
*/
cancelAll?(): void;
/**
* Get list of pending approval request IDs (optional)
* @returns Array of approval IDs currently pending
* @remarks Not all handlers track pending requests (e.g., auto-approve handlers)
*/
getPending?(): string[];
/**
* Get full pending approval requests (optional)
* @returns Array of pending approval requests
* @remarks Not all handlers track pending requests (e.g., auto-approve handlers)
*/
getPendingRequests?(): ApprovalRequest[];
/**
* Auto-approve pending requests that match a predicate (optional)
* Used when a pattern is remembered to auto-approve other parallel requests
* that would now match the same pattern.
*
* @param predicate Function that returns true for requests that should be auto-approved
* @param responseData Optional data to include in the auto-approval response
* @returns Number of requests that were auto-approved
* @remarks Not all handlers support this (e.g., auto-approve handlers don't need it)
*/
autoApprovePending?(
predicate: (request: ApprovalRequest) => boolean,
responseData?: Record<string, unknown>
): number;
}