package plugins import "fmt" func buildHookScript(agentName string, agentId string) string { return buildHookScriptWithOutput(agentName, agentId, "", false, false) } func buildHookScriptWithOutput( agentName, agentId, successOutput string, failClosed, nativePayload bool, ) string { return fmt.Sprintf(`#!/usr/bin/env node 'use strict'; // Elydora audit hook for %s. Generated by Elydora SDK. 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 SUCCESS_OUTPUT = %q; const FAIL_CLOSED = %t; const NATIVE_PAYLOAD = %t; 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'); %s function parsePrivateKey(raw) { let value = raw.toString('utf-8'); if (value.endsWith('\r\n')) { value = value.slice(0, -2); } else if (value.endsWith('\n')) { value = value.slice(0, -1); } if (!value || value.includes('\r') || value.includes('\n') || value.includes('\0')) { throw new Error('Private key file must contain exactly one line'); } const seed = Buffer.from(value, 'base64url'); if (seed.length !== 32 || seed.toString('base64url') !== value) { throw new Error('Private key must be a canonical 32-byte base64url value'); } return value; } 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 MAX_CHAIN_ATTEMPTS = 5; const SUBMIT_BUDGET_MS = 4000; const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; function validateChainHash(value, source) { if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value)) { throw new Error(source + ' contains an invalid chain hash'); } return value; } 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)); } function readChainState() { let raw; try { raw = readProtectedFile( CHAIN_STATE_PATH, 'Chain state', MAX_PROTECTED_CONFIG_BYTES, ).toString('utf-8'); } catch (error) { if (hasRuntimeErrorCode(error, 'ENOENT')) return ZERO_CHAIN_HASH; throw new Error('Failed to read chain state: ' + error.message); } let state; try { state = JSON.parse(raw); } catch (error) { throw new Error('Chain state is invalid JSON: ' + error.message); } if (!state || typeof state !== 'object' || Array.isArray(state)) { throw new Error('Chain state has an invalid shape'); } return validateChainHash(state.prev_chain_hash, 'Chain state'); } function writeChainState(chainHash) { writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash }); } function lockOwnerAlive(lockPath) { let owner; try { owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10); } catch (error) { if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error; return false; } if (!Number.isInteger(owner) || owner <= 0) return false; try { process.kill(owner, 0); return true; } catch (error) { if (hasRuntimeErrorCode(error, 'ESRCH')) return false; return true; } } function withChainLock(update) { const lockPath = CHAIN_STATE_PATH + '.lock'; const deadline = performance.now() + 1000; for (;;) { let descriptor; try { descriptor = fs.openSync(lockPath, 'wx', 0o600); fs.writeSync(descriptor, String(process.pid)); } catch (error) { if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error; if (lockOwnerAlive(lockPath)) { if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); continue; } let age = 0; try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) { if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError; } if (age > 5000) { const reclaimed = lockPath + '.' + process.pid + '.stale'; try { fs.renameSync(lockPath, reclaimed); fs.unlinkSync(reclaimed); } catch (reclaimError) { if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError; } continue; } if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); continue; } try { return update(); } finally { let current = null; try { current = fs.lstatSync(lockPath); } catch (statError) { if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError; } const own = fs.fstatSync(descriptor); fs.closeSync(descriptor); if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath); } } } function advanceChainState(fromHash, toHash) { return withChainLock(() => { if (readChainState() !== fromHash) return false; writeChainState(toHash); return true; }); } function logError(err) { try { const ts = new Date().toISOString(); const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n'; appendProtectedText(ERROR_LOG_PATH, 'Elydora error log', msg); } catch (logFailure) { process.stderr.write( '[Elydora audit] Failed to write error log: ' + logFailure.message + '\n', ); } } async function postOperation(baseUrl, token, eor, deadline) { const remaining = deadline - performance.now(); if (remaining <= 0) throw new Error('Audit API retry budget exhausted'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining)); try { const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; if (token) headers['Authorization'] = 'Bearer ' + token; const res = await fetch(baseUrl + '/v1/operations', { method: 'POST', headers: headers, body: JSON.stringify(eor), signal: controller.signal, }); if (res.ok) return { accepted: true }; if (res.status === 400) { let errBody; try { errBody = await res.json(); } catch (err) { throw new Error('Audit API returned invalid error JSON: ' + err.message); } if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') { const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/); if (match) { return { accepted: false, expected: validateChainHash(match[1], 'Audit API mismatch response') }; } } } throw new Error('Audit API returned HTTP ' + res.status); } finally { clearTimeout(timeout); } } async function main() { try { const chunks = []; for await (const chunk of process.stdin) { chunks.push(chunk); } const rawInput = Buffer.concat(chunks).toString('utf-8').trim(); if (!rawInput) { throw new Error('Hook input is empty'); } let hookData; try { hookData = JSON.parse(rawInput); } catch (err) { throw new Error('Hook input is invalid JSON: ' + err.message); } if (!hookData || typeof hookData !== 'object' || Array.isArray(hookData)) { throw new Error('Hook input must contain a JSON object'); } // Extract provider-native tool fields without reshaping the hook input. const clineToolEvent = hookData.tool_result && typeof hookData.tool_result === 'object' ? hookData.tool_result : hookData.tool_call && typeof hookData.tool_call === 'object' ? hookData.tool_call : {}; const toolName = hookData.tool_name || hookData.toolName || hookData.name || clineToolEvent.name || 'unknown'; const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || clineToolEvent.input || {}; const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId || hookData.session || hookData.taskId || 'unknown'; let config; let privateKey; try { config = JSON.parse(readProtectedFile( CONFIG_PATH, 'Agent config', MAX_PROTECTED_CONFIG_BYTES, ).toString('utf-8')); if (!config || typeof config !== 'object' || Array.isArray(config)) { throw new Error('Agent config must contain a JSON object'); } if (config.agent_id !== AGENT_ID || config.agent_name !== AGENT_NAME) { throw new Error('Agent config identity does not match the generated audit hook'); } privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key')); } catch (err) { const failure = new Error('Failed to read agent config/key: ' + err.message); if (FAIL_CLOSED) { throw failure; } logError(failure); return; } const orgId = config.org_id; const agentId = config.agent_id; const kid = config.kid; const baseUrl = resolveConfigBaseURL(config); const token = config.token || ''; if (typeof orgId !== 'string' || !orgId || typeof agentId !== 'string' || !agentId || typeof kid !== 'string' || !kid) { throw new Error('Agent config is missing org_id, agent_id, or kid'); } if (typeof token !== 'string') throw new Error('Agent config token must be a string'); // Retry from the server chain head on PREV_HASH_MISMATCH. const payload = NATIVE_PAYLOAD ? hookData : { tool_name: toolName, tool_input: toolInput, session_id: sessionId, }; const payloadHash = computePayloadHash(payload); const deadline = performance.now() + SUBMIT_BUDGET_MS; let prevChainHash = readChainState(); for (let attempt = 1; ; attempt += 1) { const operationId = uuidv7(); const issuedAt = Date.now(); 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: generateNonce(), 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 signature = signEd25519(privateKey, Buffer.from(jcsCanonicalise(eorWithoutSig), 'utf-8')); const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature }); const result = await postOperation(baseUrl, token, eor, deadline); if (result.accepted) { advanceChainState(prevChainHash, chainHash); return; } advanceChainState(prevChainHash, result.expected); logError(new Error('Chain hash resynced to server: ' + result.expected)); if (attempt >= MAX_CHAIN_ATTEMPTS) { throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times'); } prevChainHash = readChainState(); } } catch (err) { logError(err); if (FAIL_CLOSED) { throw err; } } } main().then(() => { if (SUCCESS_OUTPUT) { process.stdout.write(SUCCESS_OUTPUT); } }).catch((err) => { process.stderr.write('[Elydora audit] ' + (err.stack || err.message || String(err)) + '\n'); process.exitCode = 1; }); `, agentName, agentName, agentId, successOutput, failClosed, nativePayload, protectedRuntimeFileReader) }