package plugins import ( "fmt" "os" "path/filepath" ) // GenerateHookScript writes a self-contained Node.js hook script to disk. // The script reads config/key from ~/.elydora// at runtime, persists // chain state, and performs fire-and-forget EOR submission with inline Ed25519 crypto. // It always exits 0 so it never blocks the host agent. func GenerateHookScript(destPath string, config InstallConfig) error { dir := filepath.Dir(destPath) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create hook script directory: %w", err) } // Write the agent config and key files for runtime reading if err := writeAgentConfig(config); err != nil { return fmt.Errorf("write agent config: %w", err) } script := buildHookScript(config.AgentName, config.AgentID) if err := os.WriteFile(destPath, []byte(script), 0755); err != nil { return fmt.Errorf("write hook script: %w", err) } return nil } // writeAgentConfig persists the agent configuration and private key to // ~/.elydora//config.json and ~/.elydora//private.key so that // the generated hook script can read them at runtime without embedding secrets. func writeAgentConfig(config InstallConfig) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("resolve home directory: %w", err) } agentDir := filepath.Join(home, ".elydora", config.AgentID) if err := os.MkdirAll(agentDir, 0755); err != nil { return fmt.Errorf("create agent directory: %w", err) } baseURL := config.BaseURL if baseURL == "" { baseURL = "https://api.elydora.com" } configJSON := fmt.Sprintf(`{ "org_id": %q, "agent_id": %q, "kid": %q, "base_url": %q, "token": %q, "agent_name": %q } `, config.OrgID, config.AgentID, config.KID, baseURL, config.Token, config.AgentName) configPath := filepath.Join(agentDir, "config.json") // Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows. if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil { return fmt.Errorf("write agent config: %w", err) } keyPath := filepath.Join(agentDir, "private.key") // Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows. if err := os.WriteFile(keyPath, []byte(config.PrivateKey+"\n"), 0600); err != nil { return fmt.Errorf("write agent key: %w", err) } return nil } // GenerateGuardScript returns a self-contained Node.js guard script that checks // whether the agent is frozen and blocks tool execution if so. // It caches the agent status locally (60s TTL) to avoid hitting the API on every // tool call, and fails open if the API is unreachable or config is missing. func GenerateGuardScript(agentName string, agentId string) string { return fmt.Sprintf(`#!/usr/bin/env node 'use strict'; // --------------------------------------------------------------------------- // Elydora agent guard (PreToolUse) for: %s // Generated by @elydora/sdk — DO NOT EDIT // --------------------------------------------------------------------------- const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const AGENT_NAME = %q; const AGENT_ID = %q; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json'); const CACHE_TTL_MS = 60000; // 60 seconds // Consume stdin so the parent process doesn't block on a full pipe process.stdin.resume(); process.stdin.on('data', () => {}); async function main() { // Read agent config let config; try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return; // Can't read config — fail-open (allow) } // Check cached status try { const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8'); const cache = JSON.parse(raw); const age = Date.now() - (cache.cached_at || 0); if (age < CACHE_TTL_MS) { if (cache.status === 'frozen') { process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n'); process.exitCode = 2; } return; // Cache is fresh — use cached result } } catch { // No cache or invalid — need to check API } // Fetch agent status from API const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, ''); const token = config.token || ''; const agentId = config.agent_id; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); const headers = { 'Accept': 'application/json' }; if (token) { headers['Authorization'] = 'Bearer ' + token; } const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), { headers: headers, signal: controller.signal, }); clearTimeout(timeout); if (res.ok) { const data = await res.json(); const agentStatus = (data.agent && data.agent.status) || 'active'; // Update cache try { fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({ status: agentStatus, cached_at: Date.now(), }), 'utf-8'); } catch { // Ignore cache write failures } if (agentStatus === 'frozen') { process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n'); process.exitCode = 2; } } } catch { // API unreachable — fail-open (allow) } } main(); `, agentName, agentName, agentId) } func buildHookScript(agentName string, agentId string) string { return fmt.Sprintf(`#!/usr/bin/env node 'use strict'; // --------------------------------------------------------------------------- // Self-contained Elydora audit hook for: %s // Generated by @elydora/sdk — DO NOT EDIT // --------------------------------------------------------------------------- const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const AGENT_NAME = %q; const AGENT_ID = %q; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key'); const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json'); const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log'); // --------------------------------------------------------------------------- // Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts) // --------------------------------------------------------------------------- function base64urlEncode(data) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); return buf.toString('base64url'); } function base64urlDecode(input) { return Buffer.from(input, 'base64url'); } function jcsCanonicalise(value) { if (value === null || value === undefined) return 'null'; if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value); if (typeof value === 'string') return JSON.stringify(value); if (Array.isArray(value)) { return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']'; } if (typeof value === 'object') { const keys = Object.keys(value).sort(); const pairs = []; for (const key of keys) { if (value[key] !== undefined) { pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key])); } } return '{' + pairs.join(',') + '}'; } return JSON.stringify(value); } function sha256Base64url(data) { const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data; return base64urlEncode(crypto.createHash('sha256').update(input).digest()); } function computePayloadHash(payload) { return sha256Base64url(jcsCanonicalise(payload)); } function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) { const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt; return sha256Base64url(input); } const PKCS8_ED25519_PREFIX = Buffer.from([ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]); function signEd25519(privateKeyBase64url, data) { const seed = Buffer.from(privateKeyBase64url, 'base64url'); const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]); const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); const signature = crypto.sign(null, data, keyObject); return base64urlEncode(signature); } const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; function uuidv7() { const now = Date.now(); const random = crypto.randomBytes(10); const bytes = Buffer.alloc(16); bytes[0] = (now / 2 ** 40) & 0xff; bytes[1] = (now / 2 ** 32) & 0xff; bytes[2] = (now / 2 ** 24) & 0xff; bytes[3] = (now / 2 ** 16) & 0xff; bytes[4] = (now / 2 ** 8) & 0xff; bytes[5] = now & 0xff; bytes[6] = 0x70 | (random[0] & 0x0f); bytes[7] = random[1]; bytes[8] = 0x80 | (random[2] & 0x3f); bytes[9] = random[3]; bytes[10] = random[4]; bytes[11] = random[5]; bytes[12] = random[6]; bytes[13] = random[7]; bytes[14] = random[8]; bytes[15] = random[9]; const hex = bytes.toString('hex'); return ( hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + hex.slice(16, 20) + '-' + hex.slice(20, 32) ); } function generateNonce() { return base64urlEncode(crypto.randomBytes(16)); } // --------------------------------------------------------------------------- // Chain state persistence (atomic writes) // --------------------------------------------------------------------------- function readChainState() { try { const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8'); const state = JSON.parse(raw); return state.prev_chain_hash || ZERO_CHAIN_HASH; } catch { return ZERO_CHAIN_HASH; } } function writeChainState(chainHash) { const tmpPath = CHAIN_STATE_PATH + '.tmp'; fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8'); try { fs.renameSync(tmpPath, CHAIN_STATE_PATH); } catch { // Windows fallback: rename can fail if target exists try { fs.unlinkSync(CHAIN_STATE_PATH); } catch {} fs.renameSync(tmpPath, CHAIN_STATE_PATH); } } // --------------------------------------------------------------------------- // Error logging // --------------------------------------------------------------------------- function logError(err) { try { const ts = new Date().toISOString(); const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n'; fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8'); } catch { // Silently ignore logging failures } } // --------------------------------------------------------------------------- // Main hook logic // --------------------------------------------------------------------------- async function main() { try { // Read JSON from stdin const chunks = []; for await (const chunk of process.stdin) { chunks.push(chunk); } const rawInput = Buffer.concat(chunks).toString('utf-8').trim(); if (!rawInput) return; let hookData; try { hookData = JSON.parse(rawInput); } catch { return; } // Extract tool_name and tool_input from hook data // Different agents may pass data in different shapes const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown'; const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {}; const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown'; // Read agent config and private key let config; let privateKey; try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim(); } catch (err) { logError(new Error('Failed to read agent config/key: ' + err.message)); return; } const orgId = config.org_id; const agentId = config.agent_id; const kid = config.kid; const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, ''); const token = config.token || ''; // Read chain state const prevChainHash = readChainState(); // Build EOR const operationId = uuidv7(); const issuedAt = Date.now(); const nonce = generateNonce(); const payload = { tool_name: toolName, tool_input: toolInput, session_id: sessionId, }; const payloadHash = computePayloadHash(payload); const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt); const eorWithoutSig = { op_version: '1.0', operation_id: operationId, org_id: orgId, agent_id: agentId, issued_at: issuedAt, ttl_ms: 30000, nonce: nonce, operation_type: 'ai.tool_use', subject: { session_id: sessionId }, action: { tool: toolName }, payload: payload, payload_hash: payloadHash, prev_chain_hash: prevChainHash, agent_pubkey_kid: kid, }; const canonical = jcsCanonicalise(eorWithoutSig); const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8')); const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature }); // POST to /v1/operations (5s timeout) // Only update local chain state on success to prevent desync const url = baseUrl + '/v1/operations'; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; if (token) { headers['Authorization'] = 'Bearer ' + token; } const res = await fetch(url, { method: 'POST', headers: headers, body: JSON.stringify(eor), signal: controller.signal, }); if (res.ok) { // Server accepted — safe to advance local chain state writeChainState(chainHash); } else if (res.status === 400) { // Possible chain hash mismatch — try to resync from server response try { const errBody = await res.json(); if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') { // Extract the expected hash from the error message const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/); if (match) { writeChainState(match[1]); logError(new Error('Chain hash resynced to server: ' + match[1])); } } } catch { /* ignore parse errors */ } } } catch { // Network error — don't advance chain state, will retry with same prev_chain_hash } finally { clearTimeout(timeout); } } catch (err) { logError(err); } } main(); `, agentName, agentName, agentId) }