Files
Elydora-Open-Source/sdks/go/cmd/elydora/plugins/hooktemplate.go
T

411 lines
13 KiB
Go

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';
// ---------------------------------------------------------------------------
// 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 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;
}
// ---------------------------------------------------------------------------
// 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 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));
}
// ---------------------------------------------------------------------------
// Chain state persistence (atomic writes)
// ---------------------------------------------------------------------------
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 });
}
// ---------------------------------------------------------------------------
// Error logging
// ---------------------------------------------------------------------------
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',
);
}
}
function resolveAuditBaseURL(config) {
const value = config.base_url || 'https://api.elydora.com';
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
let parsed;
try {
parsed = new URL(value);
} catch (error) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error(
'Agent config base_url must exclude credentials, query parameters, and fragments',
);
}
return value.replace(/\/+$/, '');
}
// ---------------------------------------------------------------------------
// 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) {
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';
// Read agent config and private key
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 = resolveAuditBaseURL(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');
// Read chain state
const prevChainHash = readChainState();
// Build EOR
const operationId = uuidv7();
const issuedAt = Date.now();
const nonce = generateNonce();
const payload = NATIVE_PAYLOAD ? hookData : {
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) {
writeChainState(chainHash);
return;
}
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) {
const expectedHash = validateChainHash(match[1], 'Audit API mismatch response');
writeChainState(expectedHash);
logError(new Error('Chain hash resynced to server: ' + expectedHash));
}
}
} catch (err) {
logError(new Error('Failed to parse audit API error: ' + err.message));
}
}
throw new Error('Audit API returned HTTP ' + res.status);
} finally {
clearTimeout(timeout);
}
} 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)
}