UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

479 lines (444 loc) 20.1 kB
import type { ClaudeFlowPlugin, PluginContext, MCPToolDefinition, CLICommandDefinition, AgentTypeDefinition, } from '@claude-flow/shared/src/plugin-interface.js'; import * as ed from '@noble/ed25519'; import { createHash } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; // @noble/ed25519 v2 needs a sync sha512 wired explicitly. ed.etc.sha512Sync = (...m: Uint8Array[]): Uint8Array => { const h = createHash('sha512'); for (const x of m) h.update(x); return h.digest(); }; import { FederationCoordinator, type FederationCoordinatorConfig } from './application/federation-coordinator.js'; import { DiscoveryService } from './domain/services/discovery-service.js'; import { HandshakeService } from './domain/services/handshake-service.js'; import { RoutingService } from './domain/services/routing-service.js'; import { AuditService, type ComplianceMode } from './domain/services/audit-service.js'; import { PIIPipelineService } from './domain/services/pii-pipeline-service.js'; import { TrustEvaluator } from './application/trust-evaluator.js'; import { PolicyEngine, type FederationClaimType } from './application/policy-engine.js'; import { TrustLevel, getTrustLevelLabel } from './domain/entities/trust-level.js'; import { type FederationMessageType } from './domain/entities/federation-envelope.js'; // ADR-104: real wire transport via agentic-flow loader pattern. // ADR-120: midstream-aware loader wraps the agentic-flow loader so // when `midstreamer` (ruvnet/midstream) ships its production QUIC // build (Step 1 of ADR-120) and operators opt in with // MIDSTREAMER_QUIC_NATIVE=1, the federation transport auto-upgrades. // Until then, the wrapper transparently defers to the agentic-flow // loader — which itself resolves to WebSocketFallbackTransport // today, or native QUIC when AGENTIC_FLOW_QUIC_NATIVE=1. import { loadFederationTransport, type AgentMessage, type AgentTransport, } from './transport/midstream-aware-loader.js'; type LoadedTransport = AgentTransport & { /** WebSocketFallbackTransport adds listen(); the loader's interface * doesn't include it, so we cast at the call site. */ listen?: (port: number, host?: string) => Promise<void>; }; import { dispatchInbound, canonicalizeEnvelopeForVerify } from './application/inbound-dispatcher.js'; import { createMcpTools } from './mcp-tools.js'; import { createCliCommands } from './cli-commands.js'; export class AgentFederationPlugin implements ClaudeFlowPlugin { readonly name = '@claude-flow/plugin-agent-federation'; readonly version = '1.0.0-alpha.1'; readonly description = 'Cross-installation agent federation with PII protection and AI defence'; readonly author = 'Claude Flow Team'; readonly dependencies = ['@claude-flow/security', '@claude-flow/aidefence']; private coordinator: FederationCoordinator | null = null; private context: PluginContext | null = null; // ADR-104: live transport instance, created in initialize() and torn // down in shutdown(). Null when transport is disabled (the legacy // in-process behavior — preserves backward compat for tests that // don't supply a port). private transport: LoadedTransport | null = null; async initialize(context: PluginContext): Promise<void> { this.context = context; const config = context.config; const nodeId = (config['nodeId'] as string) ?? `node-${Date.now().toString(36)}`; const endpoint = (config['endpoint'] as string) ?? 'ws://localhost:9100'; const complianceMode = (config['complianceMode'] as ComplianceMode) ?? 'none'; const staticPeers = (config['staticPeers'] as string[]) ?? []; const hashSalt = (config['hashSalt'] as string) ?? `salt-${nodeId}`; // ADR-095 G2: real Ed25519 keypair instead of empty publicKey + stub // signatures. Persist to .claude-flow/federation/key-<nodeId>.json so // the same node identity survives restarts. Audit log // audit_1776483149979 flagged the previous "verifySignature returns // true unconditionally" as a critical authn bypass; this closes it. const keyDir = join(process.cwd(), '.claude-flow', 'federation'); const keyPath = join(keyDir, `key-${nodeId}.json`); let privateKey: Uint8Array; let publicKeyHex: string; try { if (existsSync(keyPath)) { const stored = JSON.parse(readFileSync(keyPath, 'utf-8')) as { privateKey: string; publicKey: string; nodeId: string }; privateKey = new Uint8Array(Buffer.from(stored.privateKey, 'hex')); publicKeyHex = stored.publicKey; } else { privateKey = ed.utils.randomPrivateKey(); const pk = ed.getPublicKey(privateKey); publicKeyHex = Buffer.from(pk).toString('hex'); if (!existsSync(keyDir)) mkdirSync(keyDir, { recursive: true, mode: 0o700 }); writeFileSync(keyPath, JSON.stringify({ nodeId, privateKey: Buffer.from(privateKey).toString('hex'), publicKey: publicKeyHex, createdAt: new Date().toISOString(), }, null, 2), { mode: 0o600 }); } } catch (err) { // Fall back to ephemeral key if persistence fails — still real crypto. privateKey = ed.utils.randomPrivateKey(); const pk = ed.getPublicKey(privateKey); publicKeyHex = Buffer.from(pk).toString('hex'); context.logger.warn(`Federation: could not persist keypair (${err instanceof Error ? err.message : err}); using ephemeral key for this session`); } const coordConfig: FederationCoordinatorConfig = { nodeId, publicKey: publicKeyHex, endpoint, capabilities: ['send', 'receive', 'query-redacted', 'status', 'ping', 'discovery'], }; const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; // ADR-095 G2: real signing + verification using @noble/ed25519. The // sign* helpers use this node's private key; verify* helpers accept // a peer's public key over the wire and check the signature with // ed.verify(). No "return true" stubs. const signBytes = (msg: string): string => { const sig = ed.sign(new TextEncoder().encode(msg), privateKey); return Buffer.from(sig).toString('hex'); }; const verifyBytes = (msg: string, signatureHex: string, peerPublicKeyHex: string): boolean => { try { if (!signatureHex || !peerPublicKeyHex) return false; return ed.verify( Buffer.from(signatureHex, 'hex'), new TextEncoder().encode(msg), Buffer.from(peerPublicKeyHex, 'hex'), ); } catch { return false; } }; // Canonical manifest serialization for signing — sorts keys to keep // sign/verify deterministic. Excludes the signature field itself. const canonicalize = (obj: Record<string, unknown>): string => { const stripped: Record<string, unknown> = {}; for (const k of Object.keys(obj).sort()) { if (k === 'signature') continue; const v = obj[k]; stripped[k] = (v && typeof v === 'object' && !Array.isArray(v)) ? JSON.parse(canonicalize(v as Record<string, unknown>)) : v; } return JSON.stringify(stripped); }; const discovery = new DiscoveryService( { signManifest: async (manifest) => signBytes(canonicalize(manifest as unknown as Record<string, unknown>)), verifyManifest: async (manifest) => { const peerPub = (manifest as { publicKey?: string }).publicKey; const sig = (manifest as { signature?: string }).signature; if (!peerPub || !sig) return false; return verifyBytes(canonicalize(manifest as unknown as Record<string, unknown>), sig, peerPub); }, onPeerDiscovered: (node) => { context.logger.info(`Peer discovered: ${node.nodeId} at ${node.endpoint}`); }, }, { staticPeers }, ); const handshake = new HandshakeService({ generateSessionId: generateId, generateSessionToken: () => `token-${generateId()}`, generateNonce: () => `nonce-${Math.random().toString(36).slice(2)}`, signChallenge: async (nonce) => signBytes(nonce), verifySignature: async (nonce, signature, peerPublicKey) => verifyBytes(nonce, signature, peerPublicKey), getLocalNodeId: () => nodeId, getLocalPublicKey: () => publicKeyHex, getLocalCapabilities: () => coordConfig.capabilities, }); const piiPipeline = new PIIPipelineService( { hashFunction: (val, salt) => `hash-${salt}-${val.slice(0, 4)}` }, { hashSalt }, ); const auditEvents: Array<Record<string, unknown>> = []; const audit = new AuditService( { generateEventId: generateId, getLocalNodeId: () => nodeId, persistEvent: async (event) => { auditEvents.push(event as unknown as Record<string, unknown>); }, queryEvents: async (query) => { return auditEvents .filter(e => { if (query.eventType && e['eventType'] !== query.eventType) return false; if (query.severity && e['severity'] !== query.severity) return false; if (query.category && e['category'] !== query.category) return false; return true; }) .slice(query.offset ?? 0, (query.offset ?? 0) + (query.limit ?? 100)) as any; }, onAuditEvent: (event) => { context.eventBus.emit('federation:audit', event); }, }, { complianceMode }, ); const trustEvaluator = new TrustEvaluator({ onTrustChange: (nid, result) => { context.logger.info(`Trust change for ${nid}: ${getTrustLevelLabel(result.previousLevel)} -> ${getTrustLevelLabel(result.newLevel)}`); context.eventBus.emit('federation:trust-change', { nodeId: nid, ...result }); }, }); const policyEngine = new PolicyEngine( { checkClaim: () => true }, ); const sessions: Map<string, import('./domain/entities/federation-session.js').FederationSession> = new Map(); // ADR-104 + ADR-120: load wire transport. WebSocket fallback by // default; native QUIC when AGENTIC_FLOW_QUIC_NATIVE=1 (ADR-108) or // MIDSTREAMER_QUIC_NATIVE=1 (ADR-120 Step 1, when midstream@0.3.0 // ships its real QUIC build). The midstream-aware loader picks the // best available transport transparently. Failures downgrade to // in-process noop (logged), preserving backward compat for // tests/environments without ws available. let transport: LoadedTransport | null = null; try { const loaded = await loadFederationTransport({ serverName: nodeId, maxIdleTimeoutMs: 30_000, maxConcurrentStreams: 100, enable0Rtt: true, }); transport = loaded.transport as LoadedTransport; this.transport = transport; context.logger.info( `Federation transport loaded: ${nodeId} (source=${loaded.source}${ loaded.fallbackReason ? `, note=${loaded.fallbackReason}` : '' })`, ); } catch (err) { context.logger.warn( `Federation transport unavailable (${err instanceof Error ? err.message : err}); ` + `falling back to in-process routing — federation_send will log only`, ); } /** * Resolve a peer's nodeId to a wire address suitable for * transport.send(). Looks up the discovery registry to get the * peer's published endpoint, then strips the protocol prefix to * get a `host:port` string. * * Endpoint shapes accepted: * - "ws://host:port" → "host:port" * - "tailscale://host:port" → "host:port" * - "host:port" → "host:port" (passthrough) */ const resolveAddress = (targetNodeId: string): string | null => { const peer = discovery.getPeer(targetNodeId); if (!peer) return null; const ep = peer.endpoint; const m = ep.match(/^(?:[a-z]+:\/\/)?(.+)$/); return m ? m[1] : ep; }; const routing = new RoutingService({ generateEnvelopeId: generateId, generateNonce: () => `nonce-${Math.random().toString(36).slice(2)}`, signEnvelope: (payload, token) => `hmac-${token.slice(0, 6)}-${payload.length}`, verifyEnvelope: () => true, scanPii: (text, trustLevel) => { const result = piiPipeline.transform(text, trustLevel as TrustLevel); return { transformedText: result.transformedText, scanResult: { scanned: true, piiFound: result.detections.length > 0, detections: result.detections.map(d => ({ type: d.type, action: result.actionsApplied.find(a => a.type === d.type)?.action ?? 'pass', confidence: d.confidence, })), actionsApplied: result.actionsApplied.map(a => a.action), scanDurationMs: 0, }, }; }, sendToNode: async (targetNodeId, envelope) => { // ADR-104: real wire send via the loaded transport. If the // transport failed to load OR the peer's address can't be // resolved, log + return (the upstream RoutingService.send // already wraps this in try/catch and returns a RoutingResult // with the error to the caller). if (!transport) { context.logger.debug( `Federation send (in-process noop): ${envelope.envelopeId}${targetNodeId}`, ); return; } const address = resolveAddress(targetNodeId); if (!address) { context.logger.warn( `Federation send aborted: peer ${targetNodeId} not in discovery registry`, ); throw new Error(`PEER_UNKNOWN: ${targetNodeId}`); } // Build the AgentMessage WITHOUT signature first, canonicalize // the bytes, sign them, then attach the signature to metadata. // The receiver runs the same canonicalization and verifies // against this node's published public key. const baseMessage: AgentMessage = { id: envelope.envelopeId, type: envelope.messageType, payload: envelope as unknown, metadata: { sourceNodeId: envelope.sourceNodeId, targetNodeId: envelope.targetNodeId, sessionId: envelope.sessionId, }, }; const canon = canonicalizeEnvelopeForVerify(baseMessage); const signature = signBytes(canon); const signed: AgentMessage = { ...baseMessage, metadata: { ...(baseMessage.metadata as Record<string, unknown>), signature }, }; await transport.send(address, signed); context.logger.debug( `Federation send → ${address} (envelope=${envelope.envelopeId}, type=${envelope.messageType}, signed)`, ); }, getActiveSessions: () => Array.from(sessions.values()).filter(s => s.active), getLocalNodeId: () => nodeId, }); this.coordinator = new FederationCoordinator( coordConfig, discovery, handshake, routing, audit, piiPipeline, trustEvaluator, policyEngine, ); context.services.register('federation:coordinator', this.coordinator); context.services.register('federation:discovery', discovery); context.services.register('federation:audit', audit); context.services.register('federation:pii', piiPipeline); context.services.register('federation:trust', trustEvaluator); context.services.register('federation:policy', policyEngine); context.services.register('federation:routing', routing); if (transport) { context.services.register('federation:transport', transport); } // ADR-104: bind the inbound listener if config supplies a port. The // port is OPTIONAL — peers that only initiate outbound (no inbound // accept) leave it unset. Common config: port 9100 for tailscale // hosts. Defaults to "no listener" so existing tests/configs don't // need to free a port. const listenPort = config['port'] as number | undefined; if (transport && typeof listenPort === 'number' && transport.listen) { const listenHost = (config['host'] as string | undefined) ?? '0.0.0.0'; try { await transport.listen(listenPort, listenHost); context.logger.info(`Federation listening on ${listenHost}:${listenPort}`); } catch (err) { context.logger.error( `Federation listener bind failed on ${listenHost}:${listenPort}: ` + (err instanceof Error ? err.message : String(err)), ); } } // ADR-109: subscribe to inbound messages. transport.onMessage was // added in agentic-flow@2.0.12-fix.3 — older builds gracefully // degrade (optional method, no-op via the ?? guard). if (transport && typeof transport.onMessage === 'function') { // Real Ed25519 envelope verifier — closes the inbound trust gap // (without this, sourceNodeId in metadata is a self-claim, not // an authenticated assertion). const verifyEnvelope = ( canonicalBytes: string, signatureHex: string | null, peerPublicKeyHex: string, ): boolean => { if (!signatureHex || !peerPublicKeyHex) return false; return verifyBytes(canonicalBytes, signatureHex, peerPublicKeyHex); }; transport.onMessage(async (address: string, message: AgentMessage) => { await dispatchInbound(address, message, { discovery, audit, eventBus: context.eventBus, logger: context.logger, verifyEnvelope, }); }); context.logger.debug('Federation inbound dispatcher subscribed (with Ed25519 sig verify)'); } else { context.logger.warn( 'Federation transport does not expose onMessage(); inbound bytes will queue but not dispatch. ' + 'Upgrade agentic-flow to >= 2.0.12-fix.3.', ); } context.logger.info('Agent Federation plugin initialized'); } async shutdown(): Promise<void> { if (this.coordinator) { await this.coordinator.shutdown(); this.coordinator = null; } if (this.transport) { try { await this.transport.close(); } catch (err) { this.context?.logger.warn( `Federation transport close error: ${err instanceof Error ? err.message : err}`, ); } this.transport = null; } this.context?.logger.info('Agent Federation plugin shut down'); this.context = null; } registerMCPTools(): MCPToolDefinition[] { return createMcpTools(() => this.coordinator, () => this.context); } registerCLICommands(): CLICommandDefinition[] { return createCliCommands(() => this.coordinator, () => this.context); } registerAgentTypes(): AgentTypeDefinition[] { return [ { type: 'federation-coordinator', name: 'Federation Coordinator', description: 'Coordinates cross-installation agent federation, managing discovery, handshake, trust evaluation, and secure message routing between federated nodes.', defaultConfig: { id: '', name: 'federation-coordinator', type: 'coordinator', capabilities: [ 'federation:discover', 'federation:connect', 'federation:read', 'federation:write', 'federation:admin', ], maxConcurrentTasks: 10, priority: 90, timeout: 300_000, metadata: { pluginSource: '@claude-flow/plugin-agent-federation', }, }, requiredCapabilities: ['federation:discover', 'federation:connect'], metadata: { trustAware: true, piiAware: true, }, }, ]; } async healthCheck(): Promise<boolean> { if (!this.coordinator) return false; const status = this.coordinator.getStatus(); return status.healthy; } }