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:
31
dexto/packages/core/src/approval/error-codes.ts
Normal file
31
dexto/packages/core/src/approval/error-codes.ts
Normal 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',
|
||||
}
|
||||
425
dexto/packages/core/src/approval/errors.ts
Normal file
425
dexto/packages/core/src/approval/errors.ts
Normal 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']
|
||||
);
|
||||
}
|
||||
}
|
||||
22
dexto/packages/core/src/approval/factory.ts
Normal file
22
dexto/packages/core/src/approval/factory.ts
Normal 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;
|
||||
}
|
||||
72
dexto/packages/core/src/approval/index.ts
Normal file
72
dexto/packages/core/src/approval/index.ts
Normal 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';
|
||||
957
dexto/packages/core/src/approval/manager.test.ts
Normal file
957
dexto/packages/core/src/approval/manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
661
dexto/packages/core/src/approval/manager.ts
Normal file
661
dexto/packages/core/src/approval/manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
386
dexto/packages/core/src/approval/schemas.ts
Normal file
386
dexto/packages/core/src/approval/schemas.ts
Normal 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>;
|
||||
352
dexto/packages/core/src/approval/types.ts
Normal file
352
dexto/packages/core/src/approval/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user