diff --git a/dist/gateway/anthropic.js b/dist/gateway/anthropic.js new file mode 100644 index 0000000..16cf686 --- /dev/null +++ b/dist/gateway/anthropic.js @@ -0,0 +1,84 @@ +/** + * Anthropic Provider Implementation + */ +export class AnthropicProvider { + constructor(apiKey, endpoint = 'https://api.anthropic.com', timeout = 30000) { + this.apiKey = apiKey; + this.endpoint = endpoint; + this.timeout = timeout; + this.name = 'anthropic'; + this.type = 'llm'; + } + async complete(request) { + const anthropicRequest = this.transformRequest(request); + const response = await fetch(`${this.endpoint}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify(anthropicRequest), + signal: AbortSignal.timeout(this.timeout) + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Anthropic API error: ${response.status} - ${error}`); + } + const data = await response.json(); + return this.transformResponse(data); + } + async isAvailable() { + try { + // Anthropic doesn't have a models endpoint, so just check if we can make a minimal request + const response = await fetch(`${this.endpoint}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'test' }], + max_tokens: 1 + }), + signal: AbortSignal.timeout(5000) + }); + return response.ok || response.status === 400; // 400 is ok, means auth worked + } + catch { + return false; + } + } + transformRequest(request) { + // Extract system message if present + const messages = request.messages.filter(m => m.role !== 'system'); + const systemMessage = request.messages.find(m => m.role === 'system'); + return { + model: request.model, + messages: messages.map(msg => ({ + role: msg.role === 'assistant' ? 'assistant' : 'user', + content: msg.content + })), + system: systemMessage?.content, + temperature: request.temperature ?? 0.7, + max_tokens: request.max_tokens ?? 4096, + stop_sequences: request.stop, + stream: request.stream ?? false + }; + } + transformResponse(data) { + const content = data.content?.[0]; + return { + content: content?.text || '', + model: data.model, + usage: data.usage ? { + prompt_tokens: data.usage.input_tokens, + completion_tokens: data.usage.output_tokens, + total_tokens: data.usage.input_tokens + data.usage.output_tokens + } : undefined, + finish_reason: data.stop_reason + }; + } +} diff --git a/dist/gateway/gateway.js b/dist/gateway/gateway.js new file mode 100644 index 0000000..e86cfef --- /dev/null +++ b/dist/gateway/gateway.js @@ -0,0 +1,216 @@ +/** + * Model Gateway - Provider orchestration with fallback and strategy + */ +import { ProviderRegistry, RateLimiter, DefaultCredentialResolver } from './provider.js'; +import { OpenAIProvider } from './openai.js'; +import { AnthropicProvider } from './anthropic.js'; +export class ModelGateway { + constructor(providers, credentialResolver) { + this.rateLimiters = new Map(); + this.usageStats = new Map(); + this.registry = new ProviderRegistry(); + this.credentialResolver = credentialResolver || new DefaultCredentialResolver(); + // Initialize providers + this.initializeProviders(providers); + } + async initializeProviders(providers) { + for (const providerConfig of providers) { + try { + const provider = await this.createProvider(providerConfig); + this.registry.register(providerConfig.id, provider); + // Setup rate limiting + if (providerConfig.limits) { + this.rateLimiters.set(providerConfig.id, new RateLimiter(providerConfig.limits)); + } + // Initialize usage stats + this.usageStats.set(providerConfig.id, { + requests: 0, + tokens: 0, + errors: 0 + }); + } + catch (error) { + console.error(`Failed to initialize provider ${providerConfig.id}:`, error); + } + } + } + async createProvider(config) { + // Resolve credentials + let apiKey; + if (config.credentials) { + if ('type' in config.credentials) { + // CredentialReference + const ref = config.credentials; + if (ref.type === 'env') { + apiKey = this.credentialResolver.getEnv(ref.ref); + } + else if (ref.type === 'secrets') { + apiKey = await this.credentialResolver.getSecret(ref.ref); + } + } + else { + // CredentialBlock - resolve each key + for (const [key, value] of Object.entries(config.credentials)) { + const credRef = value; + if ('type' in credRef && 'ref' in credRef) { + if (credRef.type === 'env') { + apiKey = this.credentialResolver.getEnv(credRef.ref); + } + else if (credRef.type === 'secrets') { + apiKey = await this.credentialResolver.getSecret(credRef.ref); + } + break; + } + } + } + } + if (!apiKey) { + throw new Error(`No credentials found for provider ${config.id}`); + } + // Create provider instance based on type + const endpoint = config.config?.endpoint; + const timeout = config.config?.timeout; + switch (config.type) { + case 'llm': + // Detect provider type from id or name + const providerName = config.name.toLowerCase(); + if (providerName.includes('openai') || config.id.includes('openai')) { + return new OpenAIProvider(apiKey, endpoint, timeout); + } + else if (providerName.includes('anthropic') || config.id.includes('anthropic')) { + return new AnthropicProvider(apiKey, endpoint, timeout); + } + else { + throw new Error(`Unknown LLM provider: ${config.name}`); + } + default: + throw new Error(`Unsupported provider type: ${config.type}`); + } + } + /** + * Complete a request using the specified model configuration + */ + async complete(modelConfig, messages, params) { + // Simple string model (backward compatibility) + if (typeof modelConfig === 'string') { + const [providerId, modelName] = modelConfig.split('/'); + return this.completeWithProvider(providerId, modelName, messages, params); + } + // Advanced model config with fallback + const strategy = modelConfig.strategy || 'failover'; + const providers = [modelConfig.primary, ...(modelConfig.fallback || [])]; + return this.completeWithStrategy(strategy, providers, messages, params); + } + async completeWithStrategy(strategy, providers, messages, params) { + switch (strategy) { + case 'failover': + return this.failoverStrategy(providers, messages, params); + case 'cost_optimized': + return this.costOptimizedStrategy(providers, messages, params); + case 'latency_optimized': + return this.latencyOptimizedStrategy(providers, messages, params); + case 'round_robin': + return this.roundRobinStrategy(providers, messages, params); + default: + return this.failoverStrategy(providers, messages, params); + } + } + async failoverStrategy(providers, messages, params) { + let lastError; + for (const providerConfig of providers) { + try { + const providerId = this.extractProviderId(providerConfig.provider); + return await this.completeWithProvider(providerId, providerConfig.name, messages, { ...params, ...providerConfig.params }); + } + catch (error) { + lastError = error; + console.warn(`Provider ${providerConfig.provider} failed, trying next...`); + } + } + throw new Error(`All providers failed. Last error: ${lastError?.message}`); + } + async costOptimizedStrategy(providers, messages, params) { + const sorted = [...providers].sort((a, b) => { + return this.estimateCost(a.name) - this.estimateCost(b.name); + }); + return this.failoverStrategy(sorted, messages, params); + } + async latencyOptimizedStrategy(providers, messages, params) { + // TODO: Track latency stats and sort by historical performance + return this.failoverStrategy(providers, messages, params); + } + async roundRobinStrategy(providers, messages, params) { + const totalRequests = Array.from(this.usageStats.values()) + .reduce((sum, stats) => sum + stats.requests, 0); + const index = totalRequests % providers.length; + const providerConfig = providers[index]; + const providerId = this.extractProviderId(providerConfig.provider); + return this.completeWithProvider(providerId, providerConfig.name, messages, { ...params, ...providerConfig.params }); + } + async completeWithProvider(providerId, modelName, messages, params) { + const provider = this.registry.get(providerId); + if (!provider) { + throw new Error(`Provider not found: ${providerId}`); + } + // Apply rate limiting + const limiter = this.rateLimiters.get(providerId); + if (limiter) { + await limiter.acquire(); + } + // Track usage + const stats = this.usageStats.get(providerId); + stats.requests++; + try { + const request = { + messages, + model: modelName, + ...params + }; + const response = await provider.complete(request); + // Track token usage + if (response.usage) { + stats.tokens += response.usage.total_tokens; + } + return response; + } + catch (error) { + stats.errors++; + throw error; + } + } + extractProviderId(providerRef) { + const parts = providerRef.split('.'); + return parts[parts.length - 1]; + } + estimateCost(modelName) { + const name = modelName.toLowerCase(); + if (name.includes('gpt-4')) + return 100; + if (name.includes('gpt-3.5')) + return 10; + if (name.includes('claude-3-opus')) + return 100; + if (name.includes('claude-3-sonnet')) + return 50; + if (name.includes('claude-3-haiku')) + return 10; + return 50; + } + /** + * Get usage statistics + */ + getUsageStats() { + return Object.fromEntries(this.usageStats); + } + /** + * Check provider availability + */ + async checkProviders() { + const results = {}; + for (const providerId of this.registry.list()) { + const provider = this.registry.get(providerId); + results[providerId] = await provider.isAvailable(); + } + return results; + } +} diff --git a/dist/gateway/index.js b/dist/gateway/index.js new file mode 100644 index 0000000..70cc819 --- /dev/null +++ b/dist/gateway/index.js @@ -0,0 +1,8 @@ +/** + * Model Gateway Module + * Universal abstraction for LLM providers + */ +export * from './provider.js'; +export * from './openai.js'; +export * from './anthropic.js'; +export * from './gateway.js'; diff --git a/dist/gateway/openai.js b/dist/gateway/openai.js new file mode 100644 index 0000000..29d849e --- /dev/null +++ b/dist/gateway/openai.js @@ -0,0 +1,70 @@ +/** + * OpenAI Provider Implementation + */ +export class OpenAIProvider { + constructor(apiKey, endpoint = 'https://api.openai.com/v1', timeout = 30000) { + this.apiKey = apiKey; + this.endpoint = endpoint; + this.timeout = timeout; + this.name = 'openai'; + this.type = 'llm'; + } + async complete(request) { + const openaiRequest = this.transformRequest(request); + const response = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify(openaiRequest), + signal: AbortSignal.timeout(this.timeout) + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${error}`); + } + const data = await response.json(); + return this.transformResponse(data); + } + async isAvailable() { + try { + const response = await fetch(`${this.endpoint}/models`, { + headers: { + 'Authorization': `Bearer ${this.apiKey}` + }, + signal: AbortSignal.timeout(5000) + }); + return response.ok; + } + catch { + return false; + } + } + transformRequest(request) { + return { + model: request.model, + messages: request.messages.map(msg => ({ + role: msg.role, + content: msg.content + })), + temperature: request.temperature ?? 0.7, + max_tokens: request.max_tokens, + stop: request.stop, + stream: request.stream ?? false + }; + } + transformResponse(data) { + const choice = data.choices?.[0]; + return { + content: choice?.message?.content || '', + model: data.model, + usage: data.usage ? { + prompt_tokens: data.usage.prompt_tokens, + completion_tokens: data.usage.completion_tokens, + total_tokens: data.usage.total_tokens + } : undefined, + finish_reason: choice?.finish_reason + }; + } +} diff --git a/dist/gateway/provider.js b/dist/gateway/provider.js new file mode 100644 index 0000000..3dff779 --- /dev/null +++ b/dist/gateway/provider.js @@ -0,0 +1,67 @@ +/** + * Model Provider Abstraction + * + * Universal interface for LLM providers (OpenAI, Anthropic, Google, local models, etc.) + */ +// Default credential resolver using process.env +export class DefaultCredentialResolver { + getEnv(varName) { + return process.env[varName]; + } + async getSecret(secretName) { + // Fallback to environment - override for secrets manager integration + return process.env[secretName]; + } +} +// Provider registry +export class ProviderRegistry { + constructor() { + this.providers = new Map(); + } + register(name, provider) { + this.providers.set(name, provider); + } + get(name) { + return this.providers.get(name); + } + has(name) { + return this.providers.has(name); + } + list() { + return Array.from(this.providers.keys()); + } +} +// Rate limiter (simple token bucket implementation) +export class RateLimiter { + constructor(limits, maxTokens = 60) { + this.limits = limits; + this.maxTokens = maxTokens; + this.tokens = maxTokens; + this.lastRefill = Date.now(); + } + async acquire() { + this.refill(); + if (this.tokens < 1) { + const waitTime = this.getWaitTime(); + await new Promise(resolve => setTimeout(resolve, waitTime)); + this.refill(); + } + this.tokens--; + } + refill() { + const now = Date.now(); + const elapsed = now - this.lastRefill; + // Refill tokens based on requests_per_minute + if (this.limits.requests_per_minute) { + const tokensToAdd = (elapsed / 60000) * this.limits.requests_per_minute; + this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd); + this.lastRefill = now; + } + } + getWaitTime() { + if (this.limits.requests_per_minute) { + return (60000 / this.limits.requests_per_minute); + } + return 1000; // Default 1 second + } +} diff --git a/dist/runtime.js b/dist/runtime.js index a514c4e..dff8e4f 100644 --- a/dist/runtime.js +++ b/dist/runtime.js @@ -1,20 +1,63 @@ import { Lexer } from 'core/dist/lexer.js'; import { A22Parser } from 'core/dist/parser.js'; +import { Validator, Transpiler } from 'core/dist/index.js'; import * as fs from 'fs'; +import { ModelGateway } from './gateway/index.js'; +import { PolicyEnforcer } from './security/index.js'; +import { AuditLogger, NoOpAuditLogger } from './security/index.js'; export class Runtime { - constructor() { + constructor(config = {}) { + this.config = config; this.blocks = new Map(); + this.policies = new Map(); + // Initialize audit logger + if (config.enableAudit && config.auditConfig) { + this.auditLogger = new AuditLogger(config.auditConfig); + } + else { + this.auditLogger = new NoOpAuditLogger(); + } } - load(filePath) { + async load(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const lexer = new Lexer(content); const tokens = lexer.tokenize(); const parser = new A22Parser(tokens); const program = parser.parse(); + // Validate AST + const validator = new Validator(); + const errors = validator.validate(program); + if (errors.length > 0) { + throw new Error(`Validation errors:\n${errors.join('\n')}`); + } + // Transform to IR + const transpiler = new Transpiler(); + this.ir = transpiler.toIR(program); + // Store blocks for backward compatibility for (const block of program.blocks) { const id = `${block.type}.${block.identifier}`; this.blocks.set(id, block); } + // Initialize gateway if providers are defined + if (this.ir.providers && this.ir.providers.length > 0) { + this.gateway = new ModelGateway(this.ir.providers); + this.auditLogger.log({ + event: 'gateway.initialized', + success: true, + metadata: { providers: this.ir.providers.map(p => p.id) } + }); + } + // Initialize policies + if (this.ir.policies) { + for (const policy of this.ir.policies) { + this.policies.set(policy.id, new PolicyEnforcer(policy)); + } + } + console.log(`[Runtime] Loaded ${filePath} successfully`); + console.log(`[Runtime] Agents: ${this.ir.agents.length}, Tools: ${this.ir.tools.length}, Workflows: ${this.ir.flows.length}`); + if (this.gateway) { + console.log(`[Runtime] Providers: ${this.ir.providers?.length || 0}`); + } } async emit(eventName, payload) { console.log(`[Runtime] Event '${eventName}' emitted`); @@ -50,8 +93,118 @@ export class Runtime { console.error(`[Runtime] Workflow '${name}' not found.`); return; } - const { WorkflowEngine } = await import('./workflow.js'); - const engine = new WorkflowEngine(this); - await engine.execute(workflowBlock, input); + this.auditLogger.log({ + event: 'workflow.start', + workflow: name, + success: true + }); + try { + const { WorkflowEngine } = await import('./workflow.js'); + const engine = new WorkflowEngine(this); + await engine.execute(workflowBlock, input); + this.auditLogger.logWorkflowExecution(name, true); + } + catch (error) { + this.auditLogger.logWorkflowExecution(name, false, error.message); + throw error; + } + } + /** + * Execute an agent with the model gateway + */ + async executeAgent(agentId, messages, params) { + const agent = this.ir?.agents.find(a => a.id === agentId); + if (!agent) { + throw new Error(`Agent not found: ${agentId}`); + } + if (!this.gateway) { + throw new Error('Model gateway not initialized. No providers configured.'); + } + // Check policy if specified + if (agent.policy) { + const policyId = typeof agent.policy === 'string' + ? agent.policy.split('.').pop() + : agent.policy.id; + const policy = this.policies.get(policyId); + if (policy) { + this.auditLogger.log({ + event: 'agent.policy_check', + agent: agentId, + success: true, + metadata: { policy: policyId } + }); + // TODO: Enforce policy checks (tool/capability access, resource limits) + } + } + // Prepend system prompt if specified + const fullMessages = [...messages]; + if (agent.system_prompt) { + fullMessages.unshift({ + role: 'system', + content: agent.system_prompt + }); + } + this.auditLogger.log({ + event: 'agent.execute', + agent: agentId, + success: true + }); + try { + // Use gateway to complete + // Convert ModelConfig to string format if needed + let modelConfig = agent.model; + if ('provider' in agent.model && 'name' in agent.model && !('primary' in agent.model)) { + // Simple ModelConfig - convert to string + const mc = agent.model; + modelConfig = `${mc.provider}/${mc.name}`; + } + const response = await this.gateway.complete(modelConfig, fullMessages, params); + this.auditLogger.log({ + event: 'agent.complete', + agent: agentId, + success: true, + metadata: { tokens: response.usage?.total_tokens } + }); + return response; + } + catch (error) { + this.auditLogger.log({ + event: 'agent.error', + agent: agentId, + success: false, + error: error.message + }); + throw error; + } + } + /** + * Get the model gateway + */ + getGateway() { + return this.gateway; + } + /** + * Get a policy enforcer + */ + getPolicy(policyId) { + return this.policies.get(policyId); + } + /** + * Get the IR + */ + getIR() { + return this.ir; + } + /** + * Get the audit logger + */ + getAuditLogger() { + return this.auditLogger; + } + /** + * Cleanup resources + */ + destroy() { + this.auditLogger.close(); } } diff --git a/dist/security/audit.js b/dist/security/audit.js new file mode 100644 index 0000000..a435d5a --- /dev/null +++ b/dist/security/audit.js @@ -0,0 +1,188 @@ +/** + * Audit Logging + */ +import * as fs from 'fs'; +export class AuditLogger { + constructor(config) { + this.config = config; + this.initializeLogger(); + } + initializeLogger() { + if (!this.config.enabled) + return; + // Parse destination + const destination = this.config.destination || 'file://./audit.log'; + if (destination.startsWith('file://')) { + const filePath = destination.replace('file://', ''); + this.logStream = fs.createWriteStream(filePath, { flags: 'a' }); + } + // In the future, support syslog://, http://, etc. + } + /** + * Log an audit event + */ + log(event) { + if (!this.config.enabled) + return; + // Check if this event type should be logged + if (this.config.log_events && !this.config.log_events.includes(event.event || '')) { + return; + } + const auditEvent = { + timestamp: new Date().toISOString(), + event: event.event || 'unknown', + success: event.success ?? true, + agent: event.agent, + tool: event.tool, + workflow: event.workflow, + user: event.user, + error: event.error, + metadata: event.metadata + }; + // Include payload if configured + if (this.config.include_payloads && event.payload) { + auditEvent.payload = event.payload; + } + this.writeLog(auditEvent); + } + writeLog(event) { + const format = this.config.format || 'json'; + let logLine; + switch (format) { + case 'json': + logLine = JSON.stringify(event); + break; + case 'text': + logLine = this.formatAsText(event); + break; + case 'cef': + logLine = this.formatAsCEF(event); + break; + default: + logLine = JSON.stringify(event); + } + if (this.logStream) { + this.logStream.write(logLine + '\n'); + } + else { + console.log('[AUDIT]', logLine); + } + } + formatAsText(event) { + const parts = [ + event.timestamp, + event.event, + event.success ? 'SUCCESS' : 'FAILURE' + ]; + if (event.agent) + parts.push(`agent=${event.agent}`); + if (event.tool) + parts.push(`tool=${event.tool}`); + if (event.workflow) + parts.push(`workflow=${event.workflow}`); + if (event.user) + parts.push(`user=${event.user}`); + if (event.error) + parts.push(`error="${event.error}"`); + return parts.join(' | '); + } + formatAsCEF(event) { + // Common Event Format (CEF) + // CEF:Version|Device Vendor|Device Product|Device Version|Signature ID|Name|Severity|Extension + const extension = []; + if (event.agent) + extension.push(`agent=${event.agent}`); + if (event.tool) + extension.push(`tool=${event.tool}`); + if (event.workflow) + extension.push(`workflow=${event.workflow}`); + if (event.user) + extension.push(`suser=${event.user}`); + const severity = event.success ? '3' : '7'; // 3=Low, 7=High + return [ + 'CEF:1', + 'A22', + 'Runtime', + '1.0', + event.event, + event.event, + severity, + extension.join(' ') + ].join('|'); + } + /** + * Log tool execution + */ + logToolCall(toolId, agent, success, error) { + this.log({ + event: 'tool.call', + tool: toolId, + agent, + success, + error + }); + } + /** + * Log permission denial + */ + logPermissionDenied(resource, action, agent) { + this.log({ + event: 'permission.denied', + agent, + success: false, + metadata: { resource, action } + }); + } + /** + * Log credential access + */ + logCredentialAccess(provider, agent) { + this.log({ + event: 'credential.access', + agent, + success: true, + metadata: { provider } + }); + } + /** + * Log policy violation + */ + logPolicyViolation(policyId, agent, violation) { + this.log({ + event: 'policy.violation', + agent, + success: false, + metadata: { policyId, violation } + }); + } + /** + * Log workflow execution + */ + logWorkflowExecution(workflowId, success, error) { + this.log({ + event: 'workflow.execution', + workflow: workflowId, + success, + error + }); + } + /** + * Close the logger + */ + close() { + if (this.logStream) { + this.logStream.end(); + } + } +} +/** + * Create a no-op audit logger for when auditing is disabled + */ +export class NoOpAuditLogger extends AuditLogger { + constructor() { + super({ enabled: false }); + } + log() { + // No-op + } +} diff --git a/dist/security/index.js b/dist/security/index.js new file mode 100644 index 0000000..8a17d4a --- /dev/null +++ b/dist/security/index.js @@ -0,0 +1,7 @@ +/** + * Security Module + * Policy enforcement, sandboxing, and audit logging + */ +export * from './policy.js'; +export * from './sandbox.js'; +export * from './audit.js'; diff --git a/dist/security/policy.js b/dist/security/policy.js new file mode 100644 index 0000000..bef9bb2 --- /dev/null +++ b/dist/security/policy.js @@ -0,0 +1,145 @@ +/** + * Policy Enforcement Engine + */ +export class PolicyError extends Error { + constructor(message) { + super(message); + this.name = 'PolicyError'; + } +} +export class PolicyEnforcer { + constructor(policy) { + this.policy = policy; + } + /** + * Check if a tool is allowed + */ + checkToolAccess(toolId) { + // Check deny list first (deny takes precedence) + if (this.policy.deny?.tools?.includes(toolId)) { + throw new PolicyError(`Tool "${toolId}" is explicitly denied by policy`); + } + // Check allow list if it exists + if (this.policy.allow?.tools) { + if (!this.policy.allow.tools.includes(toolId)) { + throw new PolicyError(`Tool "${toolId}" is not in the allowed tools list`); + } + } + } + /** + * Check if a workflow is allowed + */ + checkWorkflowAccess(workflowId) { + // Check deny list first + if (this.policy.deny?.workflows?.includes(workflowId)) { + throw new PolicyError(`Workflow "${workflowId}" is explicitly denied by policy`); + } + // Check allow list if it exists + if (this.policy.allow?.workflows) { + if (!this.policy.allow.workflows.includes(workflowId)) { + throw new PolicyError(`Workflow "${workflowId}" is not in the allowed workflows list`); + } + } + } + /** + * Check if data access is allowed + */ + checkDataAccess(dataId) { + // Check deny list first + if (this.policy.deny?.data?.includes(dataId)) { + throw new PolicyError(`Data "${dataId}" is explicitly denied by policy`); + } + // Check allow list if it exists + if (this.policy.allow?.data) { + if (!this.policy.allow.data.includes(dataId)) { + throw new PolicyError(`Data "${dataId}" is not in the allowed data list`); + } + } + } + /** + * Check if a capability is allowed + */ + checkCapabilityAccess(capabilityId) { + // Check allow list if it exists + if (this.policy.allow?.capabilities) { + if (!this.policy.allow.capabilities.includes(capabilityId)) { + throw new PolicyError(`Capability "${capabilityId}" is not in the allowed capabilities list`); + } + } + } + /** + * Get resource limits + */ + getLimits() { + return this.policy.limits; + } + /** + * Check if memory limit is exceeded + */ + checkMemoryLimit(usedMemoryMb) { + const limit = this.policy.limits?.max_memory_mb; + if (limit && usedMemoryMb > limit) { + throw new PolicyError(`Memory limit exceeded: ${usedMemoryMb}MB > ${limit}MB`); + } + } + /** + * Check if execution time limit is exceeded + */ + checkExecutionTimeLimit(executionTimeMs) { + const limit = this.policy.limits?.max_execution_time; + if (limit && executionTimeMs > limit) { + throw new PolicyError(`Execution time limit exceeded: ${executionTimeMs}ms > ${limit}ms`); + } + } + /** + * Check if tool calls limit is exceeded + */ + checkToolCallsLimit(toolCallCount) { + const limit = this.policy.limits?.max_tool_calls; + if (limit && toolCallCount > limit) { + throw new PolicyError(`Tool calls limit exceeded: ${toolCallCount} > ${limit}`); + } + } + /** + * Check if workflow depth limit is exceeded + */ + checkWorkflowDepthLimit(depth) { + const limit = this.policy.limits?.max_workflow_depth; + if (limit && depth > limit) { + throw new PolicyError(`Workflow depth limit exceeded: ${depth} > ${limit}`); + } + } +} +/** + * Permission checker for capability requirements + */ +export class PermissionChecker { + constructor(grantedPermissions) { + this.grantedPermissions = grantedPermissions; + } + /** + * Check if a permission is granted + */ + hasPermission(required) { + return this.grantedPermissions.some(granted => granted.resource === required.resource && + (granted.action === required.action || granted.action === 'admin')); + } + /** + * Check if all required permissions are granted + */ + hasAllPermissions(required) { + return required.every(perm => this.hasPermission(perm)); + } + /** + * Check permissions and throw if not granted + */ + checkPermissions(required) { + const missing = required.filter(perm => !this.hasPermission(perm)); + if (missing.length > 0) { + const missingStr = missing + .map(p => `${p.resource}:${p.action}`) + .join(', '); + throw new PolicyError(`Missing required permissions: ${missingStr}`); + } + } +} diff --git a/dist/security/sandbox.js b/dist/security/sandbox.js new file mode 100644 index 0000000..97298a3 --- /dev/null +++ b/dist/security/sandbox.js @@ -0,0 +1,180 @@ +/** + * Tool Sandbox - Secure tool execution with validation and resource limits + */ +export class ValidationError extends Error { + constructor(message) { + super(message); + this.name = 'ValidationError'; + } +} +export class SandboxError extends Error { + constructor(message) { + super(message); + this.name = 'SandboxError'; + } +} +/** + * Input validator + */ +export class InputValidator { + constructor(rules) { + this.rules = rules; + } + /** + * Validate input against rules + */ + validate(input) { + if (!this.rules) + return; + for (const [fieldName, value] of Object.entries(input)) { + const fieldRules = this.rules[fieldName]; + if (!fieldRules) + continue; + this.validateField(fieldName, value, fieldRules); + } + } + validateField(fieldName, value, rules) { + // String validations + if (typeof value === 'string') { + if (rules.max_length && value.length > rules.max_length) { + throw new ValidationError(`Field "${fieldName}" exceeds max length: ${value.length} > ${rules.max_length}`); + } + if (rules.min_length && value.length < rules.min_length) { + throw new ValidationError(`Field "${fieldName}" below min length: ${value.length} < ${rules.min_length}`); + } + if (rules.pattern) { + const regex = new RegExp(rules.pattern); + if (!regex.test(value)) { + throw new ValidationError(`Field "${fieldName}" does not match pattern: ${rules.pattern}`); + } + } + if (rules.deny_patterns) { + for (const pattern of rules.deny_patterns) { + const regex = new RegExp(pattern); + if (regex.test(value)) { + throw new ValidationError(`Field "${fieldName}" matches denied pattern: ${pattern}`); + } + } + } + } + // Number validations + if (typeof value === 'number') { + if (rules.min !== undefined && value < rules.min) { + throw new ValidationError(`Field "${fieldName}" below minimum: ${value} < ${rules.min}`); + } + if (rules.max !== undefined && value > rules.max) { + throw new ValidationError(`Field "${fieldName}" exceeds maximum: ${value} > ${rules.max}`); + } + } + } +} +/** + * Output validator + */ +export class OutputValidator { + constructor(config) { + this.config = config; + } + /** + * Validate output + */ + validate(output) { + if (!this.config) + return; + // Check output size + if (this.config.max_size_kb) { + const outputStr = JSON.stringify(output); + const sizeKb = new Blob([outputStr]).size / 1024; + if (sizeKb > this.config.max_size_kb) { + throw new ValidationError(`Output size exceeds limit: ${sizeKb.toFixed(2)}KB > ${this.config.max_size_kb}KB`); + } + } + // Schema validation would go here if config.schema is set + // For now, just pass through + } +} +/** + * Sandboxed tool executor + */ +export class ToolSandbox { + constructor(securityConfig) { + this.securityConfig = securityConfig; + this.inputValidator = new InputValidator(securityConfig?.validate); + this.outputValidator = new OutputValidator(securityConfig?.output); + } + /** + * Execute a tool function in a sandbox + */ + async execute(toolFn, input) { + // 1. Validate input + this.inputValidator.validate(input); + // 2. Apply sandbox configuration + const config = this.securityConfig?.sandbox; + if (!config) { + // No sandbox config, execute directly + const output = await toolFn(input); + this.outputValidator.validate(output); + return output; + } + // 3. Execute with timeout + const timeout = config.timeout_ms || 30000; + const output = await this.executeWithTimeout(toolFn, input, timeout); + // 4. Validate output + this.outputValidator.validate(output); + return output; + } + async executeWithTimeout(fn, input, timeoutMs) { + return Promise.race([ + fn(input), + new Promise((_, reject) => setTimeout(() => reject(new SandboxError(`Tool execution timeout after ${timeoutMs}ms`)), timeoutMs)) + ]); + } + /** + * Check network access + */ + checkNetworkAccess(host) { + const config = this.securityConfig?.sandbox; + if (!config) + return; + if (!config.network_allowed) { + throw new SandboxError('Network access is not allowed by sandbox policy'); + } + if (config.network_hosts && config.network_hosts.length > 0) { + const allowed = config.network_hosts.some((allowedHost) => { + // Simple host matching (could be enhanced with wildcard support) + return host === allowedHost || host.endsWith(`.${allowedHost}`); + }); + if (!allowed) { + throw new SandboxError(`Network access to "${host}" is not allowed. Allowed hosts: ${config.network_hosts.join(', ')}`); + } + } + } + /** + * Check filesystem access + */ + checkFilesystemAccess(path, write = false) { + const config = this.securityConfig?.sandbox; + if (!config) + return; + if (!config.filesystem_allowed) { + throw new SandboxError('Filesystem access is not allowed by sandbox policy'); + } + if (write && config.filesystem_mode === 'readonly') { + throw new SandboxError('Filesystem is in readonly mode, write access denied'); + } + if (config.filesystem_paths && config.filesystem_paths.length > 0) { + const allowed = config.filesystem_paths.some((allowedPath) => { + return path.startsWith(allowedPath); + }); + if (!allowed) { + throw new SandboxError(`Filesystem access to "${path}" is not allowed. Allowed paths: ${config.filesystem_paths.join(', ')}`); + } + } + } + /** + * Get sandbox configuration + */ + getConfig() { + return this.securityConfig?.sandbox; + } +} diff --git a/dist/workflow.js b/dist/workflow.js index fb784f4..7568e11 100644 --- a/dist/workflow.js +++ b/dist/workflow.js @@ -1,9 +1,14 @@ +import { ToolSandbox } from './security/index.js'; export class WorkflowEngine { - constructor(context) { - this.context = context; + constructor(runtime) { + this.runtime = runtime; } async execute(workflowBlock, input) { console.log(`[Workflow] Starting ${workflowBlock.identifier}`); + const ir = this.runtime.getIR(); + if (!ir) { + throw new Error('Runtime IR not initialized'); + } // Find 'steps' block const stepsBlock = workflowBlock.children.find(c => c.type === 'steps'); if (!stepsBlock) { @@ -11,80 +16,155 @@ export class WorkflowEngine { return; } const scope = { input }; - // Sequential execution for now (spec allows topological, but sequential is simpler for start) - // Steps are attributes in the steps block (steps { step1 = ... }) - // Wait, attributes are key=expr. - // A22 workflow: - // steps { - // embed = tool "embedder" { ... } - // } - // "embed" is key. Value is a Call Expression... wait. - // My parser: `tool "embedder" { ... }` is a nested BLOCK, not an expression. - // But `key = value` expects an expression. - // The Spec says: `embed = tool "embedder" { ... }` - // This parses as Attribute if `tool ...` is an expression. - // But `tool "embedder"` is a block definition syntax. - // - // My Parser `parseAttribute`: key = Expression. - // `parseExpression` supports Literal, List, Map, Reference. - // It does NOT support Block definitions as values. - // CRITICAL SPEC ISSUE OR PARSER LIMITATION: - // If the syntax is `embed = tool "name" { }`, then `tool "name" { }` must be an expression. - // OR the syntax is `step "embed" { use = tool.name ... }`. - // Let's re-read the spec example: - // embed = tool "embedder" { text = input.text } - // This implies `tool "embedder" { ... }` matches `Expression`. - // To support this, I need to update Parser to allow a "Block-like Expression" or constructor. - // OR distinct logic: `embed` is a label for the block? - // `tool "embedder" "embed" { ... }` ? No. - // Current Parser `parseExpression`: - // if identifier -> Reference. - // If I see `tool`, it's an identifier. - // If I see `"embedder"`, it's a string. - // If I see `{`, it's map/block. - // A22 v0.1 Spec implies inline block instantiation. - // I should probably simplify my parser or the spec for this "Local First" iteration. - // Simplification: treating it as a reference if possible, or support `call` expression. - // For now, I will assume the parser parses `tool "embedder" { ... }` as a Block if it was top level. - // But inside `steps { ... }`? - // `steps` is a block. - // Inside `steps`, we see `embed = ...`. That's an attribute. - // The value `tool "embedder" { ... }` causes syntax error in my current parser because `tool` is identifier, then `"embedder"` is string. `parseExpression` sees identifier `tool`, then tries `parseReference`. It sees `"embedder"` (String) next, which acts as a valid property access? No, reference expects dot + identifier. - // WORKAROUND: - // Modify `steps` to use Blocks instead of Assignments for now? - // `step "embed" { use = tool.embedder }` - // OR Update Parser to handle `Identifier String Block` as an Expression (Constructor). - // Let's go with updating Parser to support `ConstructorExpression`. - // `tool "name" { ... }` -> Expression. - // I will stick to what the parser can do or simple fixes. - // Parsing `key = type "id" { ... }` as an expression. - // I need to update `parseExpression` in `core/src/parser.ts`. - // Steps are attributes in the steps block (steps { step1 = tool... }) - for (const attr of stepsBlock.attributes) { - const stepName = attr.key; - const expr = attr.value; - if (expr.kind === 'BlockExpression') { - // e.g. tool "console" { message = "pong" } - if (expr.type === 'tool') { - // Execute tool - const toolName = expr.identifier || ""; - const inputs = {}; - // Evaluate inputs from block attributes - for (const inputAttr of expr.body.attributes) { - inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + const startTime = Date.now(); + try { + // Steps are attributes in the steps block (steps { step1 = tool... }) + for (const attr of stepsBlock.attributes) { + const stepName = attr.key; + const expr = attr.value; + if (expr.kind === 'BlockExpression') { + const blockExpr = expr; + switch (blockExpr.type) { + case 'tool': + scope[stepName] = await this.executeTool(blockExpr, scope, ir); + break; + case 'agent': + scope[stepName] = await this.executeAgent(blockExpr, scope); + break; + case 'capability': + scope[stepName] = await this.executeCapability(blockExpr, scope); + break; + default: + console.warn(`[WorkflowStep] ${stepName}: Unknown step type '${blockExpr.type}'`); } - console.log(`[WorkflowStep] ${stepName}: Executing tool '${toolName}' with inputs`, inputs); - // Add result to scope (mock) - scope[stepName] = { result: "success" }; } } + console.log(`[Workflow] Completed ${workflowBlock.identifier} in ${Date.now() - startTime}ms`); + return scope; } - return null; + catch (error) { + console.error(`[Workflow] Failed ${workflowBlock.identifier}:`, error.message); + throw error; + } + } + async executeTool(blockExpr, scope, ir) { + const toolName = blockExpr.identifier || ''; + // Find tool definition + const toolDef = ir.tools.find((t) => t.id === toolName); + if (!toolDef) { + throw new Error(`Tool not found: ${toolName}`); + } + // Evaluate inputs from block attributes + const inputs = {}; + for (const inputAttr of blockExpr.body.attributes) { + inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + } + console.log(`[WorkflowStep] Executing tool '${toolName}' with inputs`, inputs); + // Apply security sandbox if configured + const sandbox = new ToolSandbox(toolDef.security); + // Check policy enforcement + const auditLogger = this.runtime.getAuditLogger(); + try { + const result = await sandbox.execute(async (input) => { + if (toolDef.handler) { + return await this.callToolHandler(toolDef.handler, input); + } + // No handler - return input as passthrough + return { success: true, data: input }; + }, inputs); + auditLogger.logToolCall(toolName, 'workflow', true); + return result; + } + catch (error) { + auditLogger.logToolCall(toolName, 'workflow', false, error.message); + throw error; + } + } + async executeAgent(blockExpr, scope) { + const agentId = blockExpr.identifier || ''; + // Evaluate inputs from block attributes + const inputs = {}; + for (const inputAttr of blockExpr.body.attributes) { + inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + } + console.log(`[WorkflowStep] Executing agent '${agentId}' with inputs`, inputs); + // Build messages from inputs + const messages = []; + if (inputs.message) { + messages.push({ role: 'user', content: inputs.message }); + } + else if (inputs.messages) { + messages.push(...inputs.messages); + } + // Execute agent via runtime + const response = await this.runtime.executeAgent(agentId, messages, inputs.params); + return { + content: response.content, + usage: response.usage + }; + } + async executeCapability(blockExpr, scope) { + const capabilityId = blockExpr.identifier || ''; + // Evaluate inputs + const inputs = {}; + for (const inputAttr of blockExpr.body.attributes) { + inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + } + console.log(`[WorkflowStep] Executing capability '${capabilityId}' with inputs`, inputs); + // Capability invocation not yet implemented + return { success: true, capability: capabilityId }; + } + async callToolHandler(handler, input) { + // Parse handler string + // Format: external("http://...") + const match = handler.match(/external\("(.+)"\)/); + if (match) { + const url = match[1]; + console.log(`[Tool] Calling external handler: ${url}`); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + }); + if (!response.ok) { + throw new Error(`Tool handler returned ${response.status}`); + } + return await response.json(); + } + catch (error) { + throw new Error(`Tool handler failed: ${error.message}`); + } + } + // Unknown handler format + throw new Error(`Unknown tool handler format: ${handler}`); } evaluateExpression(expr, scope) { - if (expr.kind === 'Literal') + if (expr.kind === 'Literal') { return expr.value; - // implement refs later + } + if (expr.kind === 'Reference') { + const ref = expr; + // Resolve reference from scope + // e.g., input.text -> scope.input.text + let value = scope; + for (const part of ref.path) { + value = value?.[part]; + } + return value; + } + if (expr.kind === 'List') { + const list = expr; + return list.elements.map(e => this.evaluateExpression(e, scope)); + } + if (expr.kind === 'Map') { + const map = expr; + const result = {}; + for (const prop of map.properties) { + result[prop.key] = this.evaluateExpression(prop.value, scope); + } + return result; + } return null; } } diff --git a/src/gateway/anthropic.ts b/src/gateway/anthropic.ts new file mode 100644 index 0000000..54da8c4 --- /dev/null +++ b/src/gateway/anthropic.ts @@ -0,0 +1,96 @@ +/** + * Anthropic Provider Implementation + */ + +import { IModelProvider, ProviderRequest, ProviderResponse, Message } from './provider.js'; + +export class AnthropicProvider implements IModelProvider { + readonly name = 'anthropic'; + readonly type = 'llm' as const; + + constructor( + private apiKey: string, + private endpoint: string = 'https://api.anthropic.com', + private timeout: number = 30000 + ) {} + + async complete(request: ProviderRequest): Promise { + const anthropicRequest = this.transformRequest(request); + + const response = await fetch(`${this.endpoint}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify(anthropicRequest), + signal: AbortSignal.timeout(this.timeout) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Anthropic API error: ${response.status} - ${error}`); + } + + const data = await response.json(); + return this.transformResponse(data); + } + + async isAvailable(): Promise { + try { + // Anthropic doesn't have a models endpoint, so just check if we can make a minimal request + const response = await fetch(`${this.endpoint}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'test' }], + max_tokens: 1 + }), + signal: AbortSignal.timeout(5000) + }); + return response.ok || response.status === 400; // 400 is ok, means auth worked + } catch { + return false; + } + } + + private transformRequest(request: ProviderRequest): any { + // Extract system message if present + const messages = request.messages.filter(m => m.role !== 'system'); + const systemMessage = request.messages.find(m => m.role === 'system'); + + return { + model: request.model, + messages: messages.map(msg => ({ + role: msg.role === 'assistant' ? 'assistant' : 'user', + content: msg.content + })), + system: systemMessage?.content, + temperature: request.temperature ?? 0.7, + max_tokens: request.max_tokens ?? 4096, + stop_sequences: request.stop, + stream: request.stream ?? false + }; + } + + private transformResponse(data: any): ProviderResponse { + const content = data.content?.[0]; + + return { + content: content?.text || '', + model: data.model, + usage: data.usage ? { + prompt_tokens: data.usage.input_tokens, + completion_tokens: data.usage.output_tokens, + total_tokens: data.usage.input_tokens + data.usage.output_tokens + } : undefined, + finish_reason: data.stop_reason + }; + } +} diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts new file mode 100644 index 0000000..9d7ba6d --- /dev/null +++ b/src/gateway/gateway.ts @@ -0,0 +1,311 @@ +/** + * Model Gateway - Provider orchestration with fallback and strategy + */ + +import { + Provider as IRProvider, + AdvancedModelConfig, + ModelProviderConfig, + CredentialReference +} from 'core/dist/ir.js'; +import { + IModelProvider, + ProviderRequest, + ProviderResponse, + ProviderRegistry, + RateLimiter, + CredentialResolver, + DefaultCredentialResolver +} from './provider.js'; +import { OpenAIProvider } from './openai.js'; +import { AnthropicProvider } from './anthropic.js'; + +export class ModelGateway { + private registry: ProviderRegistry; + private rateLimiters = new Map(); + private credentialResolver: CredentialResolver; + private usageStats = new Map(); + + constructor( + providers: IRProvider[], + credentialResolver?: CredentialResolver + ) { + this.registry = new ProviderRegistry(); + this.credentialResolver = credentialResolver || new DefaultCredentialResolver(); + + // Initialize providers + this.initializeProviders(providers); + } + + private async initializeProviders(providers: IRProvider[]): Promise { + for (const providerConfig of providers) { + try { + const provider = await this.createProvider(providerConfig); + this.registry.register(providerConfig.id, provider); + + // Setup rate limiting + if (providerConfig.limits) { + this.rateLimiters.set( + providerConfig.id, + new RateLimiter(providerConfig.limits) + ); + } + + // Initialize usage stats + this.usageStats.set(providerConfig.id, { + requests: 0, + tokens: 0, + errors: 0 + }); + } catch (error) { + console.error(`Failed to initialize provider ${providerConfig.id}:`, error); + } + } + } + + private async createProvider(config: IRProvider): Promise { + // Resolve credentials + let apiKey: string | undefined; + + if (config.credentials) { + if ('type' in config.credentials) { + // CredentialReference + const ref = config.credentials as CredentialReference; + if (ref.type === 'env') { + apiKey = this.credentialResolver.getEnv(ref.ref); + } else if (ref.type === 'secrets') { + apiKey = await this.credentialResolver.getSecret(ref.ref); + } + } else { + // CredentialBlock - resolve each key + for (const [key, value] of Object.entries(config.credentials)) { + const credRef = value as CredentialReference; + if ('type' in credRef && 'ref' in credRef) { + if (credRef.type === 'env') { + apiKey = this.credentialResolver.getEnv(credRef.ref); + } else if (credRef.type === 'secrets') { + apiKey = await this.credentialResolver.getSecret(credRef.ref); + } + break; + } + } + } + } + + if (!apiKey) { + throw new Error(`No credentials found for provider ${config.id}`); + } + + // Create provider instance based on type + const endpoint = config.config?.endpoint as string | undefined; + const timeout = config.config?.timeout as number | undefined; + + switch (config.type) { + case 'llm': + // Detect provider type from id or name + const providerName = config.name.toLowerCase(); + if (providerName.includes('openai') || config.id.includes('openai')) { + return new OpenAIProvider(apiKey, endpoint, timeout); + } else if (providerName.includes('anthropic') || config.id.includes('anthropic')) { + return new AnthropicProvider(apiKey, endpoint, timeout); + } else { + throw new Error(`Unknown LLM provider: ${config.name}`); + } + + default: + throw new Error(`Unsupported provider type: ${config.type}`); + } + } + + /** + * Complete a request using the specified model configuration + */ + async complete( + modelConfig: AdvancedModelConfig | string, + messages: ProviderRequest['messages'], + params?: Record + ): Promise { + // Simple string model (backward compatibility) + if (typeof modelConfig === 'string') { + const [providerId, modelName] = modelConfig.split('/'); + return this.completeWithProvider(providerId, modelName, messages, params); + } + + // Advanced model config with fallback + const strategy = modelConfig.strategy || 'failover'; + const providers = [modelConfig.primary, ...(modelConfig.fallback || [])]; + + return this.completeWithStrategy(strategy, providers, messages, params); + } + + private async completeWithStrategy( + strategy: string, + providers: ModelProviderConfig[], + messages: ProviderRequest['messages'], + params?: Record + ): Promise { + switch (strategy) { + case 'failover': + return this.failoverStrategy(providers, messages, params); + + case 'cost_optimized': + return this.costOptimizedStrategy(providers, messages, params); + + case 'latency_optimized': + return this.latencyOptimizedStrategy(providers, messages, params); + + case 'round_robin': + return this.roundRobinStrategy(providers, messages, params); + + default: + return this.failoverStrategy(providers, messages, params); + } + } + + private async failoverStrategy( + providers: ModelProviderConfig[], + messages: ProviderRequest['messages'], + params?: Record + ): Promise { + let lastError: Error | undefined; + + for (const providerConfig of providers) { + try { + const providerId = this.extractProviderId(providerConfig.provider); + return await this.completeWithProvider( + providerId, + providerConfig.name, + messages, + { ...params, ...providerConfig.params } + ); + } catch (error) { + lastError = error as Error; + console.warn(`Provider ${providerConfig.provider} failed, trying next...`); + } + } + + throw new Error(`All providers failed. Last error: ${lastError?.message}`); + } + + private async costOptimizedStrategy( + providers: ModelProviderConfig[], + messages: ProviderRequest['messages'], + params?: Record + ): Promise { + const sorted = [...providers].sort((a, b) => { + return this.estimateCost(a.name) - this.estimateCost(b.name); + }); + + return this.failoverStrategy(sorted, messages, params); + } + + private async latencyOptimizedStrategy( + providers: ModelProviderConfig[], + messages: ProviderRequest['messages'], + params?: Record + ): Promise { + // TODO: Track latency stats and sort by historical performance + return this.failoverStrategy(providers, messages, params); + } + + private async roundRobinStrategy( + providers: ModelProviderConfig[], + messages: ProviderRequest['messages'], + params?: Record + ): Promise { + const totalRequests = Array.from(this.usageStats.values()) + .reduce((sum, stats) => sum + stats.requests, 0); + const index = totalRequests % providers.length; + const providerConfig = providers[index]; + + const providerId = this.extractProviderId(providerConfig.provider); + return this.completeWithProvider( + providerId, + providerConfig.name, + messages, + { ...params, ...providerConfig.params } + ); + } + + private async completeWithProvider( + providerId: string, + modelName: string, + messages: ProviderRequest['messages'], + params?: Record + ): Promise { + const provider = this.registry.get(providerId); + if (!provider) { + throw new Error(`Provider not found: ${providerId}`); + } + + // Apply rate limiting + const limiter = this.rateLimiters.get(providerId); + if (limiter) { + await limiter.acquire(); + } + + // Track usage + const stats = this.usageStats.get(providerId)!; + stats.requests++; + + try { + const request: ProviderRequest = { + messages, + model: modelName, + ...params + }; + + const response = await provider.complete(request); + + // Track token usage + if (response.usage) { + stats.tokens += response.usage.total_tokens; + } + + return response; + } catch (error) { + stats.errors++; + throw error; + } + } + + private extractProviderId(providerRef: string): string { + const parts = providerRef.split('.'); + return parts[parts.length - 1]; + } + + private estimateCost(modelName: string): number { + const name = modelName.toLowerCase(); + if (name.includes('gpt-4')) return 100; + if (name.includes('gpt-3.5')) return 10; + if (name.includes('claude-3-opus')) return 100; + if (name.includes('claude-3-sonnet')) return 50; + if (name.includes('claude-3-haiku')) return 10; + return 50; + } + + /** + * Get usage statistics + */ + getUsageStats() { + return Object.fromEntries(this.usageStats); + } + + /** + * Check provider availability + */ + async checkProviders(): Promise> { + const results: Record = {}; + + for (const providerId of this.registry.list()) { + const provider = this.registry.get(providerId)!; + results[providerId] = await provider.isAvailable(); + } + + return results; + } +} diff --git a/src/gateway/index.ts b/src/gateway/index.ts new file mode 100644 index 0000000..c4713da --- /dev/null +++ b/src/gateway/index.ts @@ -0,0 +1,9 @@ +/** + * Model Gateway Module + * Universal abstraction for LLM providers + */ + +export * from './provider.js'; +export * from './openai.js'; +export * from './anthropic.js'; +export * from './gateway.js'; diff --git a/src/gateway/openai.ts b/src/gateway/openai.ts new file mode 100644 index 0000000..dcb41ae --- /dev/null +++ b/src/gateway/openai.ts @@ -0,0 +1,81 @@ +/** + * OpenAI Provider Implementation + */ + +import { IModelProvider, ProviderRequest, ProviderResponse, Message } from './provider.js'; + +export class OpenAIProvider implements IModelProvider { + readonly name = 'openai'; + readonly type = 'llm' as const; + + constructor( + private apiKey: string, + private endpoint: string = 'https://api.openai.com/v1', + private timeout: number = 30000 + ) {} + + async complete(request: ProviderRequest): Promise { + const openaiRequest = this.transformRequest(request); + + const response = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify(openaiRequest), + signal: AbortSignal.timeout(this.timeout) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${error}`); + } + + const data = await response.json(); + return this.transformResponse(data); + } + + async isAvailable(): Promise { + try { + const response = await fetch(`${this.endpoint}/models`, { + headers: { + 'Authorization': `Bearer ${this.apiKey}` + }, + signal: AbortSignal.timeout(5000) + }); + return response.ok; + } catch { + return false; + } + } + + private transformRequest(request: ProviderRequest): any { + return { + model: request.model, + messages: request.messages.map(msg => ({ + role: msg.role, + content: msg.content + })), + temperature: request.temperature ?? 0.7, + max_tokens: request.max_tokens, + stop: request.stop, + stream: request.stream ?? false + }; + } + + private transformResponse(data: any): ProviderResponse { + const choice = data.choices?.[0]; + + return { + content: choice?.message?.content || '', + model: data.model, + usage: data.usage ? { + prompt_tokens: data.usage.prompt_tokens, + completion_tokens: data.usage.completion_tokens, + total_tokens: data.usage.total_tokens + } : undefined, + finish_reason: choice?.finish_reason + }; + } +} diff --git a/src/gateway/provider.ts b/src/gateway/provider.ts new file mode 100644 index 0000000..16c85ba --- /dev/null +++ b/src/gateway/provider.ts @@ -0,0 +1,143 @@ +/** + * Model Provider Abstraction + * + * Universal interface for LLM providers (OpenAI, Anthropic, Google, local models, etc.) + */ + +import { Provider as IRProvider, ModelProviderConfig, RateLimits } from 'core/dist/ir.js'; + +// Provider message format (universal) +export interface Message { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +// Provider request +export interface ProviderRequest { + messages: Message[]; + model: string; + temperature?: number; + max_tokens?: number; + stop?: string[]; + stream?: boolean; + [key: string]: any; // Provider-specific params +} + +// Provider response +export interface ProviderResponse { + content: string; + model: string; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; + finish_reason?: string; +} + +// Base provider interface +export interface IModelProvider { + readonly name: string; + readonly type: IRProvider['type']; + + /** + * Generate completion + */ + complete(request: ProviderRequest): Promise; + + /** + * Check if provider is available + */ + isAvailable(): Promise; +} + +// Credentials resolver +export interface CredentialResolver { + /** + * Resolve environment variable + */ + getEnv(varName: string): string | undefined; + + /** + * Resolve secret from secrets manager + */ + getSecret(secretName: string): Promise; +} + +// Default credential resolver using process.env +export class DefaultCredentialResolver implements CredentialResolver { + getEnv(varName: string): string | undefined { + return process.env[varName]; + } + + async getSecret(secretName: string): Promise { + // Fallback to environment - override for secrets manager integration + return process.env[secretName]; + } +} + +// Provider registry +export class ProviderRegistry { + private providers = new Map(); + + register(name: string, provider: IModelProvider): void { + this.providers.set(name, provider); + } + + get(name: string): IModelProvider | undefined { + return this.providers.get(name); + } + + has(name: string): boolean { + return this.providers.has(name); + } + + list(): string[] { + return Array.from(this.providers.keys()); + } +} + +// Rate limiter (simple token bucket implementation) +export class RateLimiter { + private tokens: number; + private lastRefill: number; + + constructor( + private limits: RateLimits, + private maxTokens: number = 60 + ) { + this.tokens = maxTokens; + this.lastRefill = Date.now(); + } + + async acquire(): Promise { + this.refill(); + + if (this.tokens < 1) { + const waitTime = this.getWaitTime(); + await new Promise(resolve => setTimeout(resolve, waitTime)); + this.refill(); + } + + this.tokens--; + } + + private refill(): void { + const now = Date.now(); + const elapsed = now - this.lastRefill; + + // Refill tokens based on requests_per_minute + if (this.limits.requests_per_minute) { + const tokensToAdd = (elapsed / 60000) * this.limits.requests_per_minute; + this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd); + this.lastRefill = now; + } + } + + private getWaitTime(): number { + if (this.limits.requests_per_minute) { + return (60000 / this.limits.requests_per_minute); + } + return 1000; // Default 1 second + } +} diff --git a/src/runtime.ts b/src/runtime.ts index 300cadc..270c390 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,24 +1,82 @@ import { Lexer } from 'core/dist/lexer.js'; import { A22Parser } from 'core/dist/parser.js'; +import { Validator, Transpiler } from 'core/dist/index.js'; import * as AST from 'core/dist/ast.js'; +import * as IR from 'core/dist/ir.js'; import * as fs from 'fs'; +import { ModelGateway } from './gateway/index.js'; +import { PolicyEnforcer } from './security/index.js'; +import { AuditLogger, NoOpAuditLogger } from './security/index.js'; type BlockMap = Map; +export interface RuntimeConfig { + enableAudit?: boolean; + auditConfig?: IR.AuditConfig; +} + export class Runtime { private blocks: BlockMap = new Map(); + private ir?: IR.A22IR; + private gateway?: ModelGateway; + private policies = new Map(); + private auditLogger: AuditLogger; + + constructor(private config: RuntimeConfig = {}) { + // Initialize audit logger + if (config.enableAudit && config.auditConfig) { + this.auditLogger = new AuditLogger(config.auditConfig); + } else { + this.auditLogger = new NoOpAuditLogger(); + } + } - load(filePath: string) { + async load(filePath: string) { const content = fs.readFileSync(filePath, 'utf-8'); const lexer = new Lexer(content); const tokens = lexer.tokenize(); const parser = new A22Parser(tokens); const program = parser.parse(); + // Validate AST + const validator = new Validator(); + const errors = validator.validate(program); + if (errors.length > 0) { + throw new Error(`Validation errors:\n${errors.join('\n')}`); + } + + // Transform to IR + const transpiler = new Transpiler(); + this.ir = transpiler.toIR(program); + + // Store blocks for backward compatibility for (const block of program.blocks) { const id = `${block.type}.${block.identifier}`; this.blocks.set(id, block); } + + // Initialize gateway if providers are defined + if (this.ir.providers && this.ir.providers.length > 0) { + this.gateway = new ModelGateway(this.ir.providers); + this.auditLogger.log({ + event: 'gateway.initialized', + success: true, + metadata: { providers: this.ir.providers.map(p => p.id) } + }); + } + + // Initialize policies + if (this.ir.policies) { + for (const policy of this.ir.policies) { + this.policies.set(policy.id, new PolicyEnforcer(policy)); + } + } + + console.log(`[Runtime] Loaded ${filePath} successfully`); + console.log(`[Runtime] Agents: ${this.ir.agents.length}, Tools: ${this.ir.tools.length}, Workflows: ${this.ir.flows.length}`); + if (this.gateway) { + console.log(`[Runtime] Providers: ${this.ir.providers?.length || 0}`); + } } async emit(eventName: string, payload: any) { @@ -61,8 +119,136 @@ export class Runtime { return; } - const { WorkflowEngine } = await import('./workflow.js'); - const engine = new WorkflowEngine(this); - await engine.execute(workflowBlock, input); + this.auditLogger.log({ + event: 'workflow.start', + workflow: name, + success: true + }); + + try { + const { WorkflowEngine } = await import('./workflow.js'); + const engine = new WorkflowEngine(this); + await engine.execute(workflowBlock, input); + + this.auditLogger.logWorkflowExecution(name, true); + } catch (error: any) { + this.auditLogger.logWorkflowExecution(name, false, error.message); + throw error; + } + } + + /** + * Execute an agent with the model gateway + */ + async executeAgent(agentId: string, messages: { role: string; content: string }[], params?: Record) { + const agent = this.ir?.agents.find(a => a.id === agentId); + if (!agent) { + throw new Error(`Agent not found: ${agentId}`); + } + + if (!this.gateway) { + throw new Error('Model gateway not initialized. No providers configured.'); + } + + // Check policy if specified + if (agent.policy) { + const policyId = typeof agent.policy === 'string' + ? agent.policy.split('.').pop()! + : agent.policy.id; + const policy = this.policies.get(policyId); + if (policy) { + this.auditLogger.log({ + event: 'agent.policy_check', + agent: agentId, + success: true, + metadata: { policy: policyId } + }); + // TODO: Enforce policy checks (tool/capability access, resource limits) + } + } + + // Prepend system prompt if specified + const fullMessages = [...messages]; + if (agent.system_prompt) { + fullMessages.unshift({ + role: 'system', + content: agent.system_prompt + }); + } + + this.auditLogger.log({ + event: 'agent.execute', + agent: agentId, + success: true + }); + + try { + // Use gateway to complete + // Convert ModelConfig to string format if needed + let modelConfig: string | IR.AdvancedModelConfig = agent.model! as any; + if ('provider' in agent.model! && 'name' in agent.model! && !('primary' in agent.model!)) { + // Simple ModelConfig - convert to string + const mc = agent.model as IR.ModelConfig; + modelConfig = `${mc.provider}/${mc.name}`; + } + + const response = await this.gateway.complete( + modelConfig, + fullMessages as any, + params + ); + + this.auditLogger.log({ + event: 'agent.complete', + agent: agentId, + success: true, + metadata: { tokens: response.usage?.total_tokens } + }); + + return response; + } catch (error: any) { + this.auditLogger.log({ + event: 'agent.error', + agent: agentId, + success: false, + error: error.message + }); + throw error; + } + } + + /** + * Get the model gateway + */ + getGateway(): ModelGateway | undefined { + return this.gateway; + } + + /** + * Get a policy enforcer + */ + getPolicy(policyId: string): PolicyEnforcer | undefined { + return this.policies.get(policyId); + } + + /** + * Get the IR + */ + getIR(): IR.A22IR | undefined { + return this.ir; + } + + /** + * Get the audit logger + */ + getAuditLogger(): AuditLogger { + return this.auditLogger; + } + + /** + * Cleanup resources + */ + destroy(): void { + this.auditLogger.close(); } } diff --git a/src/security/audit.ts b/src/security/audit.ts new file mode 100644 index 0000000..6aff2f9 --- /dev/null +++ b/src/security/audit.ts @@ -0,0 +1,221 @@ +/** + * Audit Logging + */ + +import { AuditConfig } from 'core/dist/ir.js'; +import * as fs from 'fs'; + +export interface AuditEvent { + timestamp: string; + event: string; + agent?: string; + tool?: string; + workflow?: string; + user?: string; + success: boolean; + error?: string; + payload?: any; + metadata?: Record; +} + +export class AuditLogger { + private logStream?: fs.WriteStream; + + constructor(private config: AuditConfig) { + this.initializeLogger(); + } + + private initializeLogger(): void { + if (!this.config.enabled) return; + + // Parse destination + const destination = this.config.destination || 'file://./audit.log'; + + if (destination.startsWith('file://')) { + const filePath = destination.replace('file://', ''); + this.logStream = fs.createWriteStream(filePath, { flags: 'a' }); + } + // In the future, support syslog://, http://, etc. + } + + /** + * Log an audit event + */ + log(event: Partial): void { + if (!this.config.enabled) return; + + // Check if this event type should be logged + if (this.config.log_events && !this.config.log_events.includes(event.event || '')) { + return; + } + + const auditEvent: AuditEvent = { + timestamp: new Date().toISOString(), + event: event.event || 'unknown', + success: event.success ?? true, + agent: event.agent, + tool: event.tool, + workflow: event.workflow, + user: event.user, + error: event.error, + metadata: event.metadata + }; + + // Include payload if configured + if (this.config.include_payloads && event.payload) { + auditEvent.payload = event.payload; + } + + this.writeLog(auditEvent); + } + + private writeLog(event: AuditEvent): void { + const format = this.config.format || 'json'; + + let logLine: string; + switch (format) { + case 'json': + logLine = JSON.stringify(event); + break; + + case 'text': + logLine = this.formatAsText(event); + break; + + case 'cef': + logLine = this.formatAsCEF(event); + break; + + default: + logLine = JSON.stringify(event); + } + + if (this.logStream) { + this.logStream.write(logLine + '\n'); + } else { + console.log('[AUDIT]', logLine); + } + } + + private formatAsText(event: AuditEvent): string { + const parts = [ + event.timestamp, + event.event, + event.success ? 'SUCCESS' : 'FAILURE' + ]; + + if (event.agent) parts.push(`agent=${event.agent}`); + if (event.tool) parts.push(`tool=${event.tool}`); + if (event.workflow) parts.push(`workflow=${event.workflow}`); + if (event.user) parts.push(`user=${event.user}`); + if (event.error) parts.push(`error="${event.error}"`); + + return parts.join(' | '); + } + + private formatAsCEF(event: AuditEvent): string { + // Common Event Format (CEF) + // CEF:Version|Device Vendor|Device Product|Device Version|Signature ID|Name|Severity|Extension + const extension = []; + if (event.agent) extension.push(`agent=${event.agent}`); + if (event.tool) extension.push(`tool=${event.tool}`); + if (event.workflow) extension.push(`workflow=${event.workflow}`); + if (event.user) extension.push(`suser=${event.user}`); + + const severity = event.success ? '3' : '7'; // 3=Low, 7=High + + return [ + 'CEF:1', + 'A22', + 'Runtime', + '1.0', + event.event, + event.event, + severity, + extension.join(' ') + ].join('|'); + } + + /** + * Log tool execution + */ + logToolCall(toolId: string, agent: string, success: boolean, error?: string): void { + this.log({ + event: 'tool.call', + tool: toolId, + agent, + success, + error + }); + } + + /** + * Log permission denial + */ + logPermissionDenied(resource: string, action: string, agent: string): void { + this.log({ + event: 'permission.denied', + agent, + success: false, + metadata: { resource, action } + }); + } + + /** + * Log credential access + */ + logCredentialAccess(provider: string, agent?: string): void { + this.log({ + event: 'credential.access', + agent, + success: true, + metadata: { provider } + }); + } + + /** + * Log policy violation + */ + logPolicyViolation(policyId: string, agent: string, violation: string): void { + this.log({ + event: 'policy.violation', + agent, + success: false, + metadata: { policyId, violation } + }); + } + + /** + * Log workflow execution + */ + logWorkflowExecution(workflowId: string, success: boolean, error?: string): void { + this.log({ + event: 'workflow.execution', + workflow: workflowId, + success, + error + }); + } + + /** + * Close the logger + */ + close(): void { + if (this.logStream) { + this.logStream.end(); + } + } +} + +/** + * Create a no-op audit logger for when auditing is disabled + */ +export class NoOpAuditLogger extends AuditLogger { + constructor() { + super({ enabled: false }); + } + + log(): void { + // No-op + } +} diff --git a/src/security/index.ts b/src/security/index.ts new file mode 100644 index 0000000..958fe4c --- /dev/null +++ b/src/security/index.ts @@ -0,0 +1,8 @@ +/** + * Security Module + * Policy enforcement, sandboxing, and audit logging + */ + +export * from './policy.js'; +export * from './sandbox.js'; +export * from './audit.js'; diff --git a/src/security/policy.ts b/src/security/policy.ts new file mode 100644 index 0000000..faa39bd --- /dev/null +++ b/src/security/policy.ts @@ -0,0 +1,171 @@ +/** + * Policy Enforcement Engine + */ + +import { + Policy, + PolicyAllow, + PolicyDeny, + ResourceLimits, + Permission +} from 'core/dist/ir.js'; + +export class PolicyError extends Error { + constructor(message: string) { + super(message); + this.name = 'PolicyError'; + } +} + +export class PolicyEnforcer { + constructor(private policy: Policy) {} + + /** + * Check if a tool is allowed + */ + checkToolAccess(toolId: string): void { + // Check deny list first (deny takes precedence) + if (this.policy.deny?.tools?.includes(toolId)) { + throw new PolicyError(`Tool "${toolId}" is explicitly denied by policy`); + } + + // Check allow list if it exists + if (this.policy.allow?.tools) { + if (!this.policy.allow.tools.includes(toolId)) { + throw new PolicyError(`Tool "${toolId}" is not in the allowed tools list`); + } + } + } + + /** + * Check if a workflow is allowed + */ + checkWorkflowAccess(workflowId: string): void { + // Check deny list first + if (this.policy.deny?.workflows?.includes(workflowId)) { + throw new PolicyError(`Workflow "${workflowId}" is explicitly denied by policy`); + } + + // Check allow list if it exists + if (this.policy.allow?.workflows) { + if (!this.policy.allow.workflows.includes(workflowId)) { + throw new PolicyError(`Workflow "${workflowId}" is not in the allowed workflows list`); + } + } + } + + /** + * Check if data access is allowed + */ + checkDataAccess(dataId: string): void { + // Check deny list first + if (this.policy.deny?.data?.includes(dataId)) { + throw new PolicyError(`Data "${dataId}" is explicitly denied by policy`); + } + + // Check allow list if it exists + if (this.policy.allow?.data) { + if (!this.policy.allow.data.includes(dataId)) { + throw new PolicyError(`Data "${dataId}" is not in the allowed data list`); + } + } + } + + /** + * Check if a capability is allowed + */ + checkCapabilityAccess(capabilityId: string): void { + // Check allow list if it exists + if (this.policy.allow?.capabilities) { + if (!this.policy.allow.capabilities.includes(capabilityId)) { + throw new PolicyError(`Capability "${capabilityId}" is not in the allowed capabilities list`); + } + } + } + + /** + * Get resource limits + */ + getLimits(): ResourceLimits | undefined { + return this.policy.limits; + } + + /** + * Check if memory limit is exceeded + */ + checkMemoryLimit(usedMemoryMb: number): void { + const limit = this.policy.limits?.max_memory_mb; + if (limit && usedMemoryMb > limit) { + throw new PolicyError(`Memory limit exceeded: ${usedMemoryMb}MB > ${limit}MB`); + } + } + + /** + * Check if execution time limit is exceeded + */ + checkExecutionTimeLimit(executionTimeMs: number): void { + const limit = this.policy.limits?.max_execution_time; + if (limit && executionTimeMs > limit) { + throw new PolicyError(`Execution time limit exceeded: ${executionTimeMs}ms > ${limit}ms`); + } + } + + /** + * Check if tool calls limit is exceeded + */ + checkToolCallsLimit(toolCallCount: number): void { + const limit = this.policy.limits?.max_tool_calls; + if (limit && toolCallCount > limit) { + throw new PolicyError(`Tool calls limit exceeded: ${toolCallCount} > ${limit}`); + } + } + + /** + * Check if workflow depth limit is exceeded + */ + checkWorkflowDepthLimit(depth: number): void { + const limit = this.policy.limits?.max_workflow_depth; + if (limit && depth > limit) { + throw new PolicyError(`Workflow depth limit exceeded: ${depth} > ${limit}`); + } + } +} + +/** + * Permission checker for capability requirements + */ +export class PermissionChecker { + constructor(private grantedPermissions: Permission[]) {} + + /** + * Check if a permission is granted + */ + hasPermission(required: Permission): boolean { + return this.grantedPermissions.some( + granted => + granted.resource === required.resource && + (granted.action === required.action || granted.action === 'admin') + ); + } + + /** + * Check if all required permissions are granted + */ + hasAllPermissions(required: Permission[]): boolean { + return required.every(perm => this.hasPermission(perm)); + } + + /** + * Check permissions and throw if not granted + */ + checkPermissions(required: Permission[]): void { + const missing = required.filter(perm => !this.hasPermission(perm)); + + if (missing.length > 0) { + const missingStr = missing + .map(p => `${p.resource}:${p.action}`) + .join(', '); + throw new PolicyError(`Missing required permissions: ${missingStr}`); + } + } +} diff --git a/src/security/sandbox.ts b/src/security/sandbox.ts new file mode 100644 index 0000000..a2d6ac0 --- /dev/null +++ b/src/security/sandbox.ts @@ -0,0 +1,245 @@ +/** + * Tool Sandbox - Secure tool execution with validation and resource limits + */ + +import { + ToolSecurityConfig, + ValidationRules, + FieldValidation, + SandboxConfig, + OutputValidation +} from 'core/dist/ir.js'; + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class SandboxError extends Error { + constructor(message: string) { + super(message); + this.name = 'SandboxError'; + } +} + +/** + * Input validator + */ +export class InputValidator { + constructor(private rules?: ValidationRules) {} + + /** + * Validate input against rules + */ + validate(input: Record): void { + if (!this.rules) return; + + for (const [fieldName, value] of Object.entries(input)) { + const fieldRules = this.rules[fieldName]; + if (!fieldRules) continue; + + this.validateField(fieldName, value, fieldRules); + } + } + + private validateField(fieldName: string, value: any, rules: FieldValidation): void { + // String validations + if (typeof value === 'string') { + if (rules.max_length && value.length > rules.max_length) { + throw new ValidationError( + `Field "${fieldName}" exceeds max length: ${value.length} > ${rules.max_length}` + ); + } + + if (rules.min_length && value.length < rules.min_length) { + throw new ValidationError( + `Field "${fieldName}" below min length: ${value.length} < ${rules.min_length}` + ); + } + + if (rules.pattern) { + const regex = new RegExp(rules.pattern); + if (!regex.test(value)) { + throw new ValidationError( + `Field "${fieldName}" does not match pattern: ${rules.pattern}` + ); + } + } + + if (rules.deny_patterns) { + for (const pattern of rules.deny_patterns) { + const regex = new RegExp(pattern); + if (regex.test(value)) { + throw new ValidationError( + `Field "${fieldName}" matches denied pattern: ${pattern}` + ); + } + } + } + } + + // Number validations + if (typeof value === 'number') { + if (rules.min !== undefined && value < rules.min) { + throw new ValidationError( + `Field "${fieldName}" below minimum: ${value} < ${rules.min}` + ); + } + + if (rules.max !== undefined && value > rules.max) { + throw new ValidationError( + `Field "${fieldName}" exceeds maximum: ${value} > ${rules.max}` + ); + } + } + } +} + +/** + * Output validator + */ +export class OutputValidator { + constructor(private config?: OutputValidation) {} + + /** + * Validate output + */ + validate(output: any): void { + if (!this.config) return; + + // Check output size + if (this.config.max_size_kb) { + const outputStr = JSON.stringify(output); + const sizeKb = new Blob([outputStr]).size / 1024; + + if (sizeKb > this.config.max_size_kb) { + throw new ValidationError( + `Output size exceeds limit: ${sizeKb.toFixed(2)}KB > ${this.config.max_size_kb}KB` + ); + } + } + + // Schema validation would go here if config.schema is set + // For now, just pass through + } +} + +/** + * Sandboxed tool executor + */ +export class ToolSandbox { + private inputValidator: InputValidator; + private outputValidator: OutputValidator; + + constructor(private securityConfig?: ToolSecurityConfig) { + this.inputValidator = new InputValidator(securityConfig?.validate); + this.outputValidator = new OutputValidator(securityConfig?.output); + } + + /** + * Execute a tool function in a sandbox + */ + async execute( + toolFn: (input: any) => Promise, + input: Record + ): Promise { + // 1. Validate input + this.inputValidator.validate(input); + + // 2. Apply sandbox configuration + const config = this.securityConfig?.sandbox; + if (!config) { + // No sandbox config, execute directly + const output = await toolFn(input); + this.outputValidator.validate(output); + return output; + } + + // 3. Execute with timeout + const timeout = config.timeout_ms || 30000; + const output = await this.executeWithTimeout(toolFn, input, timeout); + + // 4. Validate output + this.outputValidator.validate(output); + + return output; + } + + private async executeWithTimeout( + fn: (input: any) => Promise, + input: any, + timeoutMs: number + ): Promise { + return Promise.race([ + fn(input), + new Promise((_, reject) => + setTimeout( + () => reject(new SandboxError(`Tool execution timeout after ${timeoutMs}ms`)), + timeoutMs + ) + ) + ]); + } + + /** + * Check network access + */ + checkNetworkAccess(host: string): void { + const config = this.securityConfig?.sandbox; + if (!config) return; + + if (!config.network_allowed) { + throw new SandboxError('Network access is not allowed by sandbox policy'); + } + + if (config.network_hosts && config.network_hosts.length > 0) { + const allowed = config.network_hosts.some((allowedHost: string) => { + // Simple host matching (could be enhanced with wildcard support) + return host === allowedHost || host.endsWith(`.${allowedHost}`); + }); + + if (!allowed) { + throw new SandboxError( + `Network access to "${host}" is not allowed. Allowed hosts: ${config.network_hosts.join(', ')}` + ); + } + } + } + + /** + * Check filesystem access + */ + checkFilesystemAccess(path: string, write: boolean = false): void { + const config = this.securityConfig?.sandbox; + if (!config) return; + + if (!config.filesystem_allowed) { + throw new SandboxError('Filesystem access is not allowed by sandbox policy'); + } + + if (write && config.filesystem_mode === 'readonly') { + throw new SandboxError('Filesystem is in readonly mode, write access denied'); + } + + if (config.filesystem_paths && config.filesystem_paths.length > 0) { + const allowed = config.filesystem_paths.some((allowedPath: string) => { + return path.startsWith(allowedPath); + }); + + if (!allowed) { + throw new SandboxError( + `Filesystem access to "${path}" is not allowed. Allowed paths: ${config.filesystem_paths.join(', ')}` + ); + } + } + } + + /** + * Get sandbox configuration + */ + getConfig(): SandboxConfig | undefined { + return this.securityConfig?.sandbox; + } +} diff --git a/src/workflow.ts b/src/workflow.ts index 2167061..4ef3c7e 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -1,11 +1,18 @@ import * as AST from 'core/dist/ast.js'; +import { Runtime } from './runtime.js'; +import { ToolSandbox } from './security/index.js'; export class WorkflowEngine { - constructor(private context: any) { } + constructor(private runtime: Runtime) {} async execute(workflowBlock: AST.Block, input: any): Promise { console.log(`[Workflow] Starting ${workflowBlock.identifier}`); + const ir = this.runtime.getIR(); + if (!ir) { + throw new Error('Runtime IR not initialized'); + } + // Find 'steps' block const stepsBlock = workflowBlock.children.find(c => c.type === 'steps'); if (!stepsBlock) { @@ -14,95 +21,188 @@ export class WorkflowEngine { } const scope: any = { input }; + const startTime = Date.now(); + + try { + // Steps are attributes in the steps block (steps { step1 = tool... }) + for (const attr of stepsBlock.attributes) { + const stepName = attr.key; + const expr = attr.value; + + if (expr.kind === 'BlockExpression') { + const blockExpr = expr as AST.BlockExpression; + + switch (blockExpr.type) { + case 'tool': + scope[stepName] = await this.executeTool(blockExpr, scope, ir); + break; + + case 'agent': + scope[stepName] = await this.executeAgent(blockExpr, scope); + break; + + case 'capability': + scope[stepName] = await this.executeCapability(blockExpr, scope); + break; - // Sequential execution for now (spec allows topological, but sequential is simpler for start) - // Steps are attributes in the steps block (steps { step1 = ... }) - // Wait, attributes are key=expr. - // A22 workflow: - // steps { - // embed = tool "embedder" { ... } - // } - // "embed" is key. Value is a Call Expression... wait. - // My parser: `tool "embedder" { ... }` is a nested BLOCK, not an expression. - // But `key = value` expects an expression. - // The Spec says: `embed = tool "embedder" { ... }` - // This parses as Attribute if `tool ...` is an expression. - // But `tool "embedder"` is a block definition syntax. - // - // My Parser `parseAttribute`: key = Expression. - // `parseExpression` supports Literal, List, Map, Reference. - // It does NOT support Block definitions as values. - - // CRITICAL SPEC ISSUE OR PARSER LIMITATION: - // If the syntax is `embed = tool "name" { }`, then `tool "name" { }` must be an expression. - // OR the syntax is `step "embed" { use = tool.name ... }`. - - // Let's re-read the spec example: - // embed = tool "embedder" { text = input.text } - - // This implies `tool "embedder" { ... }` matches `Expression`. - // To support this, I need to update Parser to allow a "Block-like Expression" or constructor. - // OR distinct logic: `embed` is a label for the block? - // `tool "embedder" "embed" { ... }` ? No. - - // Current Parser `parseExpression`: - // if identifier -> Reference. - - // If I see `tool`, it's an identifier. - // If I see `"embedder"`, it's a string. - // If I see `{`, it's map/block. - - // A22 v0.1 Spec implies inline block instantiation. - // I should probably simplify my parser or the spec for this "Local First" iteration. - // Simplification: treating it as a reference if possible, or support `call` expression. - - // For now, I will assume the parser parses `tool "embedder" { ... }` as a Block if it was top level. - // But inside `steps { ... }`? - // `steps` is a block. - // Inside `steps`, we see `embed = ...`. That's an attribute. - // The value `tool "embedder" { ... }` causes syntax error in my current parser because `tool` is identifier, then `"embedder"` is string. `parseExpression` sees identifier `tool`, then tries `parseReference`. It sees `"embedder"` (String) next, which acts as a valid property access? No, reference expects dot + identifier. - - // WORKAROUND: - // Modify `steps` to use Blocks instead of Assignments for now? - // `step "embed" { use = tool.embedder }` - - // OR Update Parser to handle `Identifier String Block` as an Expression (Constructor). - // Let's go with updating Parser to support `ConstructorExpression`. - // `tool "name" { ... }` -> Expression. - - // I will stick to what the parser can do or simple fixes. - // Parsing `key = type "id" { ... }` as an expression. - // I need to update `parseExpression` in `core/src/parser.ts`. - - // Steps are attributes in the steps block (steps { step1 = tool... }) - for (const attr of stepsBlock.attributes) { - const stepName = attr.key; - const expr = attr.value; - - if (expr.kind === 'BlockExpression') { - // e.g. tool "console" { message = "pong" } - if (expr.type === 'tool') { - // Execute tool - const toolName = expr.identifier || ""; - const inputs: any = {}; - - // Evaluate inputs from block attributes - for (const inputAttr of expr.body.attributes) { - inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + default: + console.warn(`[WorkflowStep] ${stepName}: Unknown step type '${blockExpr.type}'`); } + } + } + + console.log(`[Workflow] Completed ${workflowBlock.identifier} in ${Date.now() - startTime}ms`); + return scope; + + } catch (error: any) { + console.error(`[Workflow] Failed ${workflowBlock.identifier}:`, error.message); + throw error; + } + } + + private async executeTool(blockExpr: AST.BlockExpression, scope: any, ir: any): Promise { + const toolName = blockExpr.identifier || ''; - console.log(`[WorkflowStep] ${stepName}: Executing tool '${toolName}' with inputs`, inputs); - // Add result to scope (mock) - scope[stepName] = { result: "success" }; + // Find tool definition + const toolDef = ir.tools.find((t: any) => t.id === toolName); + if (!toolDef) { + throw new Error(`Tool not found: ${toolName}`); + } + + // Evaluate inputs from block attributes + const inputs: any = {}; + for (const inputAttr of blockExpr.body.attributes) { + inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + } + + console.log(`[WorkflowStep] Executing tool '${toolName}' with inputs`, inputs); + + // Apply security sandbox if configured + const sandbox = new ToolSandbox(toolDef.security); + + // Check policy enforcement + const auditLogger = this.runtime.getAuditLogger(); + + try { + const result = await sandbox.execute(async (input) => { + if (toolDef.handler) { + return await this.callToolHandler(toolDef.handler, input); } + // No handler - return input as passthrough + return { success: true, data: input }; + }, inputs); + + auditLogger.logToolCall(toolName, 'workflow', true); + return result; + + } catch (error: any) { + auditLogger.logToolCall(toolName, 'workflow', false, error.message); + throw error; + } + } + + private async executeAgent(blockExpr: AST.BlockExpression, scope: any): Promise { + const agentId = blockExpr.identifier || ''; + + // Evaluate inputs from block attributes + const inputs: any = {}; + for (const inputAttr of blockExpr.body.attributes) { + inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + } + + console.log(`[WorkflowStep] Executing agent '${agentId}' with inputs`, inputs); + + // Build messages from inputs + const messages = []; + if (inputs.message) { + messages.push({ role: 'user', content: inputs.message }); + } else if (inputs.messages) { + messages.push(...inputs.messages); + } + + // Execute agent via runtime + const response = await this.runtime.executeAgent(agentId, messages, inputs.params); + + return { + content: response.content, + usage: response.usage + }; + } + + private async executeCapability(blockExpr: AST.BlockExpression, scope: any): Promise { + const capabilityId = blockExpr.identifier || ''; + + // Evaluate inputs + const inputs: any = {}; + for (const inputAttr of blockExpr.body.attributes) { + inputs[inputAttr.key] = this.evaluateExpression(inputAttr.value, scope); + } + + console.log(`[WorkflowStep] Executing capability '${capabilityId}' with inputs`, inputs); + + // Capability invocation not yet implemented + return { success: true, capability: capabilityId }; + } + + private async callToolHandler(handler: string, input: any): Promise { + // Parse handler string + // Format: external("http://...") + const match = handler.match(/external\("(.+)"\)/); + if (match) { + const url = match[1]; + console.log(`[Tool] Calling external handler: ${url}`); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + }); + + if (!response.ok) { + throw new Error(`Tool handler returned ${response.status}`); + } + + return await response.json(); + } catch (error: any) { + throw new Error(`Tool handler failed: ${error.message}`); } } - return null; + + // Unknown handler format + throw new Error(`Unknown tool handler format: ${handler}`); } private evaluateExpression(expr: AST.Expression, scope: any): any { - if (expr.kind === 'Literal') return expr.value; - // implement refs later + if (expr.kind === 'Literal') { + return (expr as AST.Literal).value; + } + + if (expr.kind === 'Reference') { + const ref = expr as AST.Reference; + // Resolve reference from scope + // e.g., input.text -> scope.input.text + let value = scope; + for (const part of ref.path) { + value = value?.[part]; + } + return value; + } + + if (expr.kind === 'List') { + const list = expr as AST.ListExpression; + return list.elements.map(e => this.evaluateExpression(e, scope)); + } + + if (expr.kind === 'Map') { + const map = expr as AST.MapExpression; + const result: any = {}; + for (const prop of map.properties) { + result[prop.key] = this.evaluateExpression(prop.value, scope); + } + return result; + } + return null; } } diff --git a/test_runtime.a22 b/test_runtime.a22 index 52fa123..84e51d5 100644 --- a/test_runtime.a22 +++ b/test_runtime.a22 @@ -1,11 +1,8 @@ -agent "bot" { - on event "ping" { - call workflow "pong" {} - } -} +agent "bot" + when event "ping" + -> .pong -workflow "pong" { - steps { - log = tool "console" { message = "pong" } - } -} +workflow "pong" + steps + log = tool "console" + message: "pong"