fix: make Cursor hooks transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 00:48:33 -04:00
1 parent 6d36f557b1
commit 6a7fdb7595
15 files changed
+1686 -665

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js';
export interface GuardScriptOptions {
readonly failClosed?: boolean;
readonly successOutput?: string;
}
export function generateGuardScript(
agentName: string,
agentId: string,
options: GuardScriptOptions = {},
): string {
const failClosed = options.failClosed ?? false;
const successOutput = options.successOutput ?? '';
return `#!/usr/bin/env node
'use strict';
// Elydora agent guard for ${agentName}. Generated by @elydora/sdk.
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 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 (SUCCESS_OUTPUT) {
process.stdout.write(JSON.stringify({
permission: 'deny',
userMessage: message,
agentMessage: message + ' Tool execution is blocked.',
}) + '\\n');
}
process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\\n');
process.exitCode = 2;
}
function readStatusCache() {
let raw;
try {
raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
} catch (error) {
if (error.code === '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) {
const temporaryPath = STATUS_CACHE_PATH + '.' + process.pid + '.tmp';
try {
fs.writeFileSync(temporaryPath, JSON.stringify({
status,
cached_at: Date.now(),
}), { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
try {
fs.renameSync(temporaryPath, STATUS_CACHE_PATH);
} catch (error) {
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
try { fs.unlinkSync(STATUS_CACHE_PATH); } catch (unlinkError) {
if (unlinkError.code !== 'ENOENT') throw unlinkError;
}
fs.renameSync(temporaryPath, STATUS_CACHE_PATH);
}
} finally {
try { fs.unlinkSync(temporaryPath); } catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
}
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');
return;
}
try {
JSON.parse(raw);
} catch (error) {
if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message);
}
}
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;
});
`;
}