438 lines
13 KiB
Go
438 lines
13 KiB
Go
package plugins
|
|
|
|
import (
|
|
"fmt"
|
|
)
|
|
|
|
// 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
|
|
|
|
%s
|
|
|
|
// 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(readProtectedFile(
|
|
CONFIG_PATH,
|
|
'Agent config',
|
|
MAX_PROTECTED_CONFIG_BYTES,
|
|
).toString('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, protectedRuntimeFileReader)
|
|
}
|
|
|
|
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');
|
|
|
|
%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');
|
|
}
|
|
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 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 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.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'));
|
|
privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key'));
|
|
} 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, protectedRuntimeFileReader)
|
|
}
|