fix: make Cursor hooks transactional
This commit is contained in:
1 parent
6d36f557b1
commit
6a7fdb7595
15 files changed
+1686
-665
No files matched your search
@@ -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;
|
||||
});
|
||||
`;
|
||||
}
|
||||
Reference in new issue
Block a user