187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js';
|
|
|
|
export interface GuardScriptOptions {
|
|
readonly failClosed?: boolean;
|
|
readonly successOutput?: string;
|
|
readonly denyProtocol?: 'cursor' | 'grok';
|
|
}
|
|
|
|
export function generateGuardScript(
|
|
agentName: string,
|
|
agentId: string,
|
|
options: GuardScriptOptions = {},
|
|
): string {
|
|
const failClosed = options.failClosed ?? false;
|
|
const successOutput = options.successOutput ?? '';
|
|
const denyProtocol = options.denyProtocol ?? '';
|
|
return `#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
// Elydora agent guard for ${agentName}. 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 = ${JSON.stringify(agentName)};
|
|
const AGENT_ID = ${JSON.stringify(agentId)};
|
|
const SUCCESS_OUTPUT = ${JSON.stringify(successOutput)};
|
|
const FAIL_CLOSED = ${JSON.stringify(failClosed)};
|
|
const DENY_PROTOCOL = ${JSON.stringify(denyProtocol)};
|
|
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;
|
|
|
|
${PROTECTED_RUNTIME_READER}
|
|
|
|
function validateStatus(value, source) {
|
|
if (!['active', 'frozen', 'revoked'].includes(value)) {
|
|
throw new Error(source + ' contains an invalid agent status');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function blockAgent(status) {
|
|
const state = status === 'frozen' ? 'frozen' : 'revoked';
|
|
const message = 'Agent "' + AGENT_NAME + '" is ' + state + ' in Elydora.';
|
|
if (DENY_PROTOCOL === 'cursor') {
|
|
process.stdout.write(JSON.stringify({
|
|
permission: 'deny',
|
|
userMessage: message,
|
|
agentMessage: message + ' Tool execution is blocked.',
|
|
}) + '\\n');
|
|
} else if (DENY_PROTOCOL === 'grok') {
|
|
process.stdout.write(JSON.stringify({ decision: 'deny', reason: message }) + '\\n');
|
|
}
|
|
process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\\n');
|
|
process.exitCode = 2;
|
|
}
|
|
|
|
function readStatusCache() {
|
|
let raw;
|
|
try {
|
|
raw = readProtectedFile(
|
|
STATUS_CACHE_PATH,
|
|
'Status cache',
|
|
MAX_PROTECTED_CONFIG_BYTES,
|
|
).toString('utf-8');
|
|
} catch (error) {
|
|
if (hasRuntimeErrorCode(error, 'ENOENT')) return undefined;
|
|
throw new Error('Failed to read status cache: ' + error.message);
|
|
}
|
|
let cache;
|
|
try {
|
|
cache = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error('Status cache is invalid JSON: ' + error.message);
|
|
}
|
|
if (!cache || typeof cache !== 'object' || !Number.isFinite(cache.cached_at)) {
|
|
throw new Error('Status cache has an invalid shape');
|
|
}
|
|
const age = Date.now() - cache.cached_at;
|
|
if (age < 0) throw new Error('Status cache timestamp is in the future');
|
|
return { status: validateStatus(cache.status, 'Status cache'), age };
|
|
}
|
|
|
|
function writeStatusCache(status) {
|
|
writeProtectedJson(STATUS_CACHE_PATH, 'Status cache', {
|
|
status,
|
|
cached_at: Date.now(),
|
|
});
|
|
}
|
|
|
|
async function readInput() {
|
|
const chunks = [];
|
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
if (!raw) {
|
|
if (FAIL_CLOSED) throw new Error('Hook input is empty');
|
|
process.stderr.write('[Elydora guard] Hook input is empty; enforcement continued in fail-open mode.\\n');
|
|
return;
|
|
}
|
|
try {
|
|
JSON.parse(raw);
|
|
} catch (error) {
|
|
if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message);
|
|
process.stderr.write('[Elydora guard] Hook input is invalid JSON; enforcement continued in fail-open mode: ' + error.message + '\\n');
|
|
}
|
|
}
|
|
|
|
function readConfig() {
|
|
const raw = readProtectedFile(
|
|
CONFIG_PATH,
|
|
'Agent config',
|
|
MAX_PROTECTED_CONFIG_BYTES,
|
|
).toString('utf-8');
|
|
let config;
|
|
try {
|
|
config = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error('Agent config is invalid JSON: ' + error.message);
|
|
}
|
|
if (!config || typeof config !== 'object') throw new Error('Agent config must be an object');
|
|
if (config.agent_id !== AGENT_ID
|
|
|| (config.agent_name !== undefined && config.agent_name !== AGENT_NAME)) {
|
|
throw new Error('Agent config identity does not match the generated guard');
|
|
}
|
|
return config;
|
|
}
|
|
|
|
async function main() {
|
|
await readInput();
|
|
let config;
|
|
try {
|
|
config = readConfig();
|
|
} catch (error) {
|
|
if (FAIL_CLOSED) throw new Error('Failed to read agent config: ' + error.message);
|
|
process.stderr.write('[Elydora guard] Failed to read agent config: ' + error.message + '\\n');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const cache = readStatusCache();
|
|
if (cache && cache.age < CACHE_TTL_MS) {
|
|
if (cache.status !== 'active') blockAgent(cache.status);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
if (FAIL_CLOSED) throw error;
|
|
process.stderr.write('[Elydora guard] ' + error.message + '\\n');
|
|
}
|
|
|
|
const baseUrl = String(config.base_url || 'https://api.elydora.com').replace(/\\\/+$/, '');
|
|
const token = config.token || '';
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
try {
|
|
const headers = { Accept: 'application/json' };
|
|
if (token) headers.Authorization = 'Bearer ' + token;
|
|
const response = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(AGENT_ID), {
|
|
headers,
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) throw new Error('Agent status API returned HTTP ' + response.status);
|
|
const data = await response.json();
|
|
const status = validateStatus(data && data.agent && data.agent.status, 'Agent status API');
|
|
writeStatusCache(status);
|
|
if (status !== 'active') blockAgent(status);
|
|
} catch (error) {
|
|
if (FAIL_CLOSED) throw new Error('Failed to resolve agent status: ' + error.message);
|
|
process.stderr.write('[Elydora guard] Failed to resolve agent status: ' + error.message + '\\n');
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
main().then(() => {
|
|
if ((!process.exitCode || process.exitCode === 0) && SUCCESS_OUTPUT) {
|
|
process.stdout.write(SUCCESS_OUTPUT);
|
|
}
|
|
}).catch((error) => {
|
|
process.stderr.write('[Elydora guard] ' + (error.stack || error.message || String(error)) + '\\n');
|
|
process.exitCode = 1;
|
|
});
|
|
`;
|
|
}
|