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

+2
View File
@@ -20,6 +20,8 @@ export interface PluginStatus {
}
export interface AgentPlugin {
readonly managesRuntime?: boolean;
preflightInstall?(config: InstallConfig): Promise<void>;
install(config: InstallConfig): Promise<void>;
uninstall(agentId?: string): Promise<void>;
status(): Promise<PluginStatus>;
+20 -6
View File
@@ -193,7 +193,7 @@ function rejectDuplicateKeys(node: Node | undefined, label: string, location: st
}
}
function parseJsonObject(raw: string, label: string): JsonObject {
export function parseStrictJsonObject(raw: string, label: string): JsonObject {
const errors: ParseError[] = [];
const value: unknown = parse(raw, errors, {
allowTrailingComma: false,
@@ -233,13 +233,17 @@ function readHooks(value: unknown, label: string): CursorHooks {
export function parseDocument(filePath: string, raw: string): CursorDocument {
const label = `Cursor user hooks at ${filePath}`;
const root = parseJsonObject(raw, label);
if (root.version !== 1) throw new Error(`${label} must declare version 1`);
const root = parseStrictJsonObject(raw, label);
const hooks = readHooks(root.hooks, label);
const hasVersion = Object.prototype.hasOwnProperty.call(root, 'version');
if (root.version !== 1 && (hasVersion || !containsManagedHook(hooks))) {
throw new Error(`${label} must declare version 1`);
}
return {
exists: true,
filePath,
root,
hooks: readHooks(root.hooks, label),
hooks,
raw,
};
}
@@ -255,6 +259,7 @@ export function removeManagedHooks(hooks: CursorHooks, agentId?: string): Cursor
for (const [event, scriptName] of [
['preToolUse', GUARD_SCRIPT],
['postToolUse', AUDIT_SCRIPT],
['postToolUseFailure', AUDIT_SCRIPT],
] as const) {
const handlers = (next[event] ?? []).filter((handler) => {
const managedId = managedAgentId(handler, scriptName);
@@ -277,7 +282,7 @@ function entirelyManaged(document: CursorDocument): boolean {
for (const [event, handlers] of events) {
const scriptName = event === 'preToolUse'
? GUARD_SCRIPT
: event === 'postToolUse'
: event === 'postToolUse' || event === 'postToolUseFailure'
? AUDIT_SCRIPT
: undefined;
if (!scriptName || handlers.length === 0) return false;
@@ -315,12 +320,21 @@ function managedIds(handlers: JsonObject[], scriptName: string): Map<string, str
return result;
}
function containsManagedHook(hooks: CursorHooks): boolean {
return [
...(hooks.preToolUse ?? []).map((handler) => managedAgentId(handler, GUARD_SCRIPT)),
...(hooks.postToolUse ?? []).map((handler) => managedAgentId(handler, AUDIT_SCRIPT)),
...(hooks.postToolUseFailure ?? []).map((handler) => managedAgentId(handler, AUDIT_SCRIPT)),
].some((agentId) => agentId !== undefined);
}
export function runtimeContracts(hooks: CursorHooks): RuntimeContract[] {
const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT);
const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT);
const failures = managedIds(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), '.elydora');
return [...guards]
.filter(([key]) => audits.has(key))
.filter(([key]) => audits.has(key) && failures.has(key))
.map(([, agentId]) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
+448
View File
@@ -0,0 +1,448 @@
import { randomUUID } from 'node:crypto';
import { constants } from 'node:fs';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js';
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
type JsonObject,
type RenderedDocument,
parseStrictJsonObject,
samePath,
} from './cursor-contract.js';
import { generateGuardScript } from './guard-template.js';
import { generateHookScript } from './hook-template.js';
import { cursorConfigPath } from './cursor-io.js';
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
interface FileSnapshot {
readonly contents: string;
readonly device: bigint | number;
readonly inode: bigint | number;
readonly mode: number;
}
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly next: string;
readonly mode: number;
readonly original?: FileSnapshot;
}
interface StagedChange {
readonly change: FileChange;
readonly temporaryPath: string;
readonly rollbackPath?: string;
committed: boolean;
}
export interface CursorRuntimePaths {
readonly runtimeRoot: string;
readonly agentDirectory: string;
readonly configPath: string;
readonly keyPath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface PreparedCursorInstallation {
readonly paths: CursorRuntimePaths;
readonly changes: readonly FileChange[];
}
export type RenameFile = (source: string, destination: string) => Promise<void>;
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
async function readPhysicalFile(
filePath: string,
label: string,
maximumBytes = MAX_SOURCE_BYTES,
): Promise<FileSnapshot | undefined> {
let before;
try {
before = await fsp.lstat(filePath, { bigint: true });
} catch (error) {
if (hasCode(error, 'ENOENT')) return undefined;
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!before.isFile() || before.isSymbolicLink()) {
throw new Error(`${label} path is not a physical file: ${filePath}`);
}
if (before.size > BigInt(maximumBytes)) {
throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`);
}
let flags = constants.O_RDONLY;
if (process.platform !== 'win32' && typeof constants.O_NOFOLLOW === 'number') {
flags |= constants.O_NOFOLLOW;
}
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, flags);
const after = await handle.stat({ bigint: true });
if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(`${label} changed while opening: ${filePath}`);
}
const contents = await handle.readFile('utf-8');
if (Buffer.byteLength(contents) > maximumBytes) {
throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`);
}
return {
contents,
device: after.dev,
inode: after.ino,
mode: Number(after.mode & 0o777n),
};
} finally {
await handle?.close();
}
}
async function inspectDirectory(directory: string, label: string): Promise<boolean> {
let metadata;
try {
metadata = await fsp.lstat(directory);
} catch (error) {
if (hasCode(error, 'ENOENT')) return false;
throw new Error(`Inspect ${label} at ${directory}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`${label} is not a physical directory: ${directory}`);
}
return true;
}
function validateInstallConfig(config: InstallConfig): void {
for (const [field, value] of [
['orgId', config.orgId],
['agentId', config.agentId],
['kid', config.kid],
['privateKey', config.privateKey],
] as const) {
if (!value) throw new Error(`${field} is required`);
}
if (config.agentName !== AGENT_KEY) {
throw new Error(`Cursor installation requires agentName ${AGENT_KEY}`);
}
const seed = Buffer.from(config.privateKey, 'base64url');
if (seed.length !== 32 || seed.toString('base64url') !== config.privateKey) {
throw new Error('privateKey must be a canonical 32-byte base64url value');
}
const baseUrl = new URL(config.baseUrl);
if (!['http:', 'https:'].includes(baseUrl.protocol)) {
throw new Error('baseUrl must use HTTP or HTTPS');
}
}
export function cursorRuntimePaths(config: InstallConfig): CursorRuntimePaths {
validateInstallConfig(config);
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId);
const guardPath = path.join(agentDirectory, GUARD_SCRIPT);
const auditPath = path.join(agentDirectory, AUDIT_SCRIPT);
if (!samePath(config.guardScriptPath, guardPath)) {
throw new Error(`Elydora guard runtime must use the managed agent directory: ${guardPath}`);
}
if (!samePath(config.hookScriptPath, auditPath)) {
throw new Error(`Elydora audit runtime must use the managed agent directory: ${auditPath}`);
}
return {
runtimeRoot,
agentDirectory,
configPath: path.join(agentDirectory, 'config.json'),
keyPath: path.join(agentDirectory, 'private.key'),
guardPath,
auditPath,
};
}
function sameAgentId(left: unknown, right: string): boolean {
return typeof left === 'string' && (process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right);
}
async function validateRuntimeIdentity(paths: CursorRuntimePaths): Promise<void> {
const runtimeRootExists = await inspectDirectory(paths.runtimeRoot, 'Elydora runtime directory');
if (!runtimeRootExists) return;
const agentDirectoryExists = await inspectDirectory(
paths.agentDirectory,
'Elydora agent runtime directory',
);
if (!agentDirectoryExists) return;
const config = await readPhysicalFile(paths.configPath, 'Elydora runtime config', MAX_CONFIG_BYTES);
const artifacts = await Promise.all([
readPhysicalFile(paths.keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(paths.guardPath, 'Elydora guard runtime'),
readPhysicalFile(paths.auditPath, 'Elydora audit runtime'),
readPhysicalFile(path.join(paths.agentDirectory, 'chain-state.json'), 'Elydora chain state'),
readPhysicalFile(path.join(paths.agentDirectory, 'status-cache.json'), 'Elydora status cache'),
]);
if (!config) {
if (artifacts.some(Boolean)) {
throw new Error(
`Elydora runtime identity cannot be verified without config.json: ${paths.agentDirectory}`,
);
}
return;
}
const value = parseStrictJsonObject(config.contents, `Elydora runtime config at ${paths.configPath}`);
if (value.agent_name !== AGENT_KEY || !sameAgentId(value.agent_id, path.basename(paths.agentDirectory))) {
throw new Error(
`Elydora runtime config identity does not match Cursor agent ${path.basename(paths.agentDirectory)}: ${paths.configPath}`,
);
}
}
export async function preflightCursorInstallation(config: InstallConfig): Promise<CursorRuntimePaths> {
const paths = cursorRuntimePaths(config);
await inspectDirectory(path.dirname(cursorConfigPath()), 'Cursor hooks directory');
await validateRuntimeIdentity(paths);
return paths;
}
function runtimeConfig(config: InstallConfig): string {
const value: JsonObject = {
org_id: config.orgId,
agent_id: config.agentId,
kid: config.kid,
base_url: config.baseUrl,
...(config.token ? { token: config.token } : {}),
agent_name: AGENT_KEY,
};
const encoded = `${JSON.stringify(value, null, 2)}\n`;
if (Buffer.byteLength(encoded) > MAX_CONFIG_BYTES) {
throw new Error(`Elydora runtime config exceeds ${MAX_CONFIG_BYTES} bytes`);
}
return encoded;
}
async function prepareChange(
filePath: string,
label: string,
next: string,
mode: number,
maximumBytes = MAX_SOURCE_BYTES,
): Promise<FileChange> {
return {
filePath,
label,
next,
mode,
original: await readPhysicalFile(filePath, label, maximumBytes),
};
}
export async function prepareCursorInstallation(
config: InstallConfig,
rendered: RenderedDocument,
): Promise<PreparedCursorInstallation> {
const paths = await preflightCursorInstallation(config);
if (!rendered.changed && rendered.document.raw === undefined) {
throw new Error('Cursor hook installation did not produce a configuration document');
}
const hooksConfig = rendered.next ?? rendered.document.raw;
if (hooksConfig === undefined) throw new Error('Cursor hook configuration is missing');
const changes = await Promise.all([
prepareChange(
paths.guardPath,
'Elydora guard runtime',
generateGuardScript(AGENT_KEY, config.agentId, {
failClosed: true,
successOutput: '{"permission":"allow"}\n',
}),
0o700,
),
prepareChange(paths.configPath, 'Elydora runtime config', runtimeConfig(config), 0o600, MAX_CONFIG_BYTES),
prepareChange(paths.keyPath, 'Elydora private key', config.privateKey, 0o600, MAX_SECRET_BYTES),
prepareChange(
paths.auditPath,
'Elydora audit runtime',
generateHookScript(AGENT_KEY, config.agentId, {
failClosed: true,
nativePayload: true,
successOutput: '{}\n',
}),
0o700,
),
prepareChange(
rendered.document.filePath,
'Cursor user hooks',
hooksConfig,
0o600,
MAX_SOURCE_BYTES,
),
]);
const hooksSource = changes.at(-1)!.original?.contents;
if (hooksSource !== rendered.document.raw) {
throw new Error(
`Cursor user hooks changed before installation: ${rendered.document.filePath}`,
);
}
await validateRuntimeIdentity(paths);
return { paths, changes };
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) throw error;
}
}
async function assertUnchanged(change: FileChange): Promise<void> {
const current = await readPhysicalFile(change.filePath, change.label);
if ((!current && change.original)
|| (current && !change.original)
|| (current && change.original && (
current.contents !== change.original.contents
|| current.device !== change.original.device
|| current.inode !== change.original.inode
))) {
throw new Error(`${change.label} changed during Cursor installation: ${change.filePath}`);
}
}
async function writeExclusive(
filePath: string,
contents: string,
mode: number,
label: string,
): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', mode);
await handle.writeFile(contents, 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
if (process.platform !== 'win32') await fsp.chmod(filePath, mode);
} catch (error) {
const failures = [asError(error)];
if (handle) {
try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); }
}
try { await unlinkOptional(filePath); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`);
throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function stage(change: FileChange): Promise<StagedChange> {
await assertUnchanged(change);
const token = randomUUID();
const directory = path.dirname(change.filePath);
const temporaryPath = path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
const rollbackPath = change.original
? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`)
: undefined;
try {
await writeExclusive(temporaryPath, change.next, change.mode, change.label);
if (rollbackPath && change.original) {
await writeExclusive(
rollbackPath,
change.original.contents,
change.original.mode,
`${change.label} rollback`,
);
}
return { change, temporaryPath, rollbackPath, committed: false };
} catch (error) {
const failures = [asError(error)];
for (const item of [temporaryPath, rollbackPath]) {
if (!item) continue;
try { await unlinkOptional(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
throw error;
}
}
async function commit(staged: StagedChange, renameFile: RenameFile): Promise<void> {
await assertUnchanged(staged.change);
await renameFile(staged.temporaryPath, staged.change.filePath);
staged.committed = true;
}
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
if (!staged.committed) return;
if (!staged.change.original) {
await unlinkOptional(staged.change.filePath);
return;
}
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
await renameFile(staged.rollbackPath, staged.change.filePath);
}
async function cleanup(staged: StagedChange): Promise<void> {
await Promise.all(
[staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)),
);
}
async function ensureCursorDirectory(directory: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
await inspectDirectory(directory, 'Cursor hooks directory');
}
export async function commitCursorInstallation(
prepared: PreparedCursorInstallation,
renameFile: RenameFile = fsp.rename,
): Promise<void> {
await ensurePrivateDirectory(prepared.paths.runtimeRoot);
await ensurePrivateDirectory(prepared.paths.agentDirectory);
await ensureCursorDirectory(path.dirname(prepared.changes.at(-1)!.filePath));
const staged: StagedChange[] = [];
try {
for (const change of prepared.changes) staged.push(await stage(change));
for (const item of staged) await commit(item, renameFile);
} catch (error) {
const failures = [asError(error)];
for (const item of [...staged].reverse()) {
try { await rollback(item, renameFile); } catch (rollbackError) {
failures.push(asError(rollbackError));
}
}
for (const item of staged) {
try { await cleanup(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
throw new AggregateError(failures, `Install Cursor hooks: ${errorMessage(error)}`);
}
const failures: Error[] = [];
for (const item of staged) {
try { await cleanup(item); } catch (error) { failures.push(asError(error)); }
}
if (failures.length > 0) {
throw new AggregateError(failures, 'Clean Cursor installation transaction files');
}
}
+6 -12
View File
@@ -13,6 +13,7 @@ import {
createDocument,
isObject,
parseDocument,
parseStrictJsonObject,
} from './cursor-contract.js';
function errorMessage(error: unknown): string {
@@ -204,18 +205,7 @@ export async function requireRuntime(filePath: string, label: string): Promise<v
async function readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
const raw = await readOptionalPhysical(filePath, 'Elydora runtime config');
if (raw === undefined) return undefined;
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!isObject(value)) {
throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`);
}
return value;
return parseStrictJsonObject(raw, `Elydora runtime config at ${filePath}`);
}
function sameAgentId(left: unknown, right: string): boolean {
@@ -236,6 +226,10 @@ export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<b
const files = await Promise.all([
physicalFileExists(contract.guardPath, 'Elydora guard runtime'),
physicalFileExists(contract.auditPath, 'Elydora audit runtime'),
physicalFileExists(
path.join(path.dirname(contract.guardPath), 'private.key'),
'Elydora private key',
),
]);
if (files.every(Boolean)) return true;
}
+24 -23
View File
@@ -1,20 +1,18 @@
import os from 'node:os';
import path from 'node:path';
import { resolvePrivateChildDirectory } from '../runtime-paths.js';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
buildHandler,
removeManagedHooks,
renderDocument,
runtimeContracts,
samePath,
} from './cursor-contract.js';
import {
commitCursorInstallation,
preflightCursorInstallation,
prepareCursorInstallation,
} from './cursor-installation.js';
import {
readDocument,
requireRuntime,
runtimeFilesExist,
writeDocument,
} from './cursor-io.js';
@@ -23,26 +21,29 @@ import { SUPPORTED_AGENTS } from './registry.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
export const cursorPlugin: AgentPlugin = {
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
await readDocument();
await preflightCursorInstallation(config);
},
async install(config: InstallConfig): Promise<void> {
if (!config.agentId) throw new Error('agentId is required');
const document = await readDocument();
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId);
const expectedGuard = path.join(agentDirectory, GUARD_SCRIPT);
const expectedAudit = path.join(agentDirectory, AUDIT_SCRIPT);
if (!samePath(config.guardScriptPath, expectedGuard)) {
throw new Error(`Elydora guard runtime must use the managed agent directory: ${expectedGuard}`);
}
if (!samePath(config.hookScriptPath, expectedAudit)) {
throw new Error(`Elydora audit runtime must use the managed agent directory: ${expectedAudit}`);
}
await requireRuntime(expectedGuard, 'Elydora guard runtime');
await requireRuntime(expectedAudit, 'Elydora audit runtime');
const paths = await preflightCursorInstallation(config);
const hooks = removeManagedHooks(document.hooks);
hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(expectedGuard)];
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(expectedAudit)];
await writeDocument(renderDocument(document, hooks));
hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(paths.guardPath)];
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(paths.auditPath)];
hooks.postToolUseFailure = [
...(hooks.postToolUseFailure ?? []),
buildHandler(paths.auditPath),
];
const prepared = await prepareCursorInstallation(
config,
renderDocument(document, hooks),
);
await commitCursorInstallation(prepared);
console.log(` Cursor hooks: ${document.filePath}`);
},
+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;
});
`;
}
+230 -302
View File
@@ -1,49 +1,59 @@
/**
* Generates a self-contained Node.js hook script that embeds all crypto logic
* inline so it has zero runtime dependency on the SDK.
*/
import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js';
export function generateHookScript(agentName: string, agentId: string): string {
export { generateGuardScript } from './guard-template.js';
export type { GuardScriptOptions } from './guard-template.js';
export interface HookScriptOptions {
readonly failClosed?: boolean;
readonly nativePayload?: boolean;
readonly successOutput?: string;
}
/** Generates a self-contained Node.js audit hook with no SDK runtime dependency. */
export function generateHookScript(
agentName: string,
agentId: string,
options: HookScriptOptions = {},
): string {
const failClosed = options.failClosed ?? false;
const nativePayload = options.nativePayload ?? false;
const successOutput = options.successOutput ?? '';
return `#!/usr/bin/env node
'use strict';
// ---------------------------------------------------------------------------
// Self-contained Elydora audit hook for: ${agentName}
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
// Elydora audit hook 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 NATIVE_PAYLOAD = ${JSON.stringify(nativePayload)};
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');
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
// ---------------------------------------------------------------------------
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
// ---------------------------------------------------------------------------
${PROTECTED_RUNTIME_READER}
function base64urlEncode(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buf.toString('base64url');
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buffer.toString('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 (Array.isArray(value)) return '[' + value.map(jcsCanonicalise).join(',') + ']';
if (typeof value === 'object') {
const keys = Object.keys(value).sort();
const pairs = [];
for (const key of keys) {
for (const key of Object.keys(value).sort()) {
if (value[key] !== undefined) {
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
}
@@ -62,9 +72,8 @@ function computePayloadHash(payload) {
return sha256Base64url(jcsCanonicalise(payload));
}
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) {
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt;
return sha256Base64url(input);
function computeChainHash(previous, payload, operationId, issuedAt) {
return sha256Base64url(previous + '|' + payload + '|' + operationId + '|' + issuedAt);
}
const PKCS8_ED25519_PREFIX = Buffer.from([
@@ -72,15 +81,25 @@ const PKCS8_ED25519_PREFIX = Buffer.from([
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);
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 || base64urlEncode(seed) !== value) {
throw new Error('Private key must be a canonical 32-byte base64url value');
}
return seed;
}
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
function signEd25519(seed, data) {
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
const key = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
return base64urlEncode(crypto.sign(null, data, key));
}
function uuidv7() {
const now = Date.now();
@@ -103,308 +122,217 @@ function uuidv7() {
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)
);
return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' +
hex.slice(16, 20) + '-' + hex.slice(20);
}
function generateNonce() {
return base64urlEncode(crypto.randomBytes(16));
function logError(error) {
try {
const message = new Date().toISOString() + ' [' + AGENT_NAME + '] ' +
(error.stack || error.message || String(error)) + '\\n';
fs.appendFileSync(ERROR_LOG_PATH, message, { encoding: 'utf-8', mode: 0o600 });
} catch (logFailure) {
process.stderr.write('[Elydora audit] Failed to write error log: ' + logFailure.message + '\\n');
}
}
// ---------------------------------------------------------------------------
// Chain state persistence (atomic writes)
// ---------------------------------------------------------------------------
function readChainState() {
let raw;
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;
raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
} catch (error) {
if (error.code === '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.prev_chain_hash !== 'string' ||
!/^[A-Za-z0-9_-]{43}$/.test(state.prev_chain_hash)) {
throw new Error('Chain state contains an invalid prev_chain_hash');
}
return state.prev_chain_hash;
}
function writeChainState(chainHash) {
const tmpPath = CHAIN_STATE_PATH + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
const temporaryPath = CHAIN_STATE_PATH + '.' + process.pid + '.tmp';
let descriptor;
try {
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
} catch (err) {
if (err.code === 'EPERM' || err.code === 'EEXIST') {
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch { /* ignore */ }
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
} else {
throw err;
}
}
}
// ---------------------------------------------------------------------------
// 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;
descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
fs.writeFileSync(descriptor, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
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(fs.readFileSync(CONFIG_PATH, 'utf-8'));
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
} 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;
fs.renameSync(temporaryPath, CHAIN_STATE_PATH);
} catch (error) {
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch (unlinkError) {
if (unlinkError.code !== 'ENOENT') throw unlinkError;
}
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);
fs.renameSync(temporaryPath, CHAIN_STATE_PATH);
}
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
try { fs.unlinkSync(temporaryPath); } catch (error) {
if (error.code !== 'ENOENT') throw error;
}
} catch (err) {
logError(err);
}
}
main();
`;
}
/**
* Generates a lightweight PreToolUse guard script that checks if the agent
* is frozen and blocks tool execution if so.
*
* - Caches the agent status locally (60s TTL) to avoid hitting the API on every tool call
* - Fails open: if the API is unreachable, tool execution is allowed
* - If frozen: exits with code 2, the blocking code shared by supported hook CLIs
*/
export function generateGuardScript(agentName: string, agentId: string): string {
return `#!/usr/bin/env node
'use strict';
// ---------------------------------------------------------------------------
// Elydora agent guard (PreToolUse) for: ${agentName}
// 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 = ${JSON.stringify(agentName)};
const AGENT_ID = ${JSON.stringify(agentId)};
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
// Discard stdin immediately so the process can exit cleanly
process.stdin.destroy();
async function main() {
// Read agent config
function readConfigAndKey() {
const raw = readProtectedFile(
CONFIG_PATH,
'Agent config',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
let config;
try {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return; // Can't read config — fail-open (allow)
config = JSON.parse(raw);
} catch (error) {
throw new Error('Agent config is invalid JSON: ' + error.message);
}
// 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
if (!config || typeof config !== 'object') throw new Error('Agent config must be an object');
if (config.agent_name !== AGENT_NAME || config.agent_id !== AGENT_ID) {
throw new Error('Agent config identity does not match the generated audit hook');
}
// 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;
for (const field of ['org_id', 'agent_id', 'kid']) {
if (typeof config[field] !== 'string' || !config[field]) {
throw new Error('Agent config is missing ' + field);
}
}
if (config.token !== undefined && typeof config.token !== 'string') {
throw new Error('Agent config token must be a string');
}
const baseUrl = config.base_url || 'https://api.elydora.com';
if (typeof baseUrl !== 'string') throw new Error('Agent config base_url must be a string');
const parsedBaseUrl = new URL(baseUrl);
if (!['http:', 'https:'].includes(parsedBaseUrl.protocol)) {
throw new Error('Agent config base_url must use HTTP or HTTPS');
}
return {
config,
baseUrl: baseUrl.replace(/\\\/+$/, ''),
privateKey: parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key')),
};
}
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
headers: headers,
async function readHookInput() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (!raw) throw new Error('Hook input is empty');
let value;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error('Hook input is invalid JSON: ' + error.message);
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Hook input must contain a JSON object');
}
return value;
}
async function submitOperation(hookData, runtime) {
const toolEvent = 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 ||
toolEvent.name || 'unknown';
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
hookData.parameters || toolEvent.input || {};
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
hookData.session || hookData.taskId || 'unknown';
const previousHash = readChainState();
const operationId = uuidv7();
const issuedAt = Date.now();
const payload = NATIVE_PAYLOAD ? hookData : {
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
};
const payloadHash = computePayloadHash(payload);
const chainHash = computeChainHash(previousHash, payloadHash, operationId, issuedAt);
const unsigned = {
op_version: '1.0',
operation_id: operationId,
org_id: runtime.config.org_id,
agent_id: runtime.config.agent_id,
issued_at: issuedAt,
ttl_ms: 30000,
nonce: base64urlEncode(crypto.randomBytes(16)),
operation_type: 'ai.tool_use',
subject: { session_id: sessionId },
action: { tool: toolName },
payload,
payload_hash: payloadHash,
prev_chain_hash: previousHash,
agent_pubkey_kid: runtime.config.kid,
};
const signature = signEd25519(
runtime.privateKey,
Buffer.from(jcsCanonicalise(unsigned), 'utf-8'),
);
const operation = { ...unsigned, chain_hash: chainHash, signature };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
const response = await fetch(runtime.baseUrl + '/v1/operations', {
method: 'POST',
headers,
body: JSON.stringify(operation),
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 (response.ok) {
writeChainState(chainHash);
return;
}
const responseBody = await response.text();
if (response.status === 400 && responseBody) {
let failure;
try { failure = JSON.parse(responseBody); } catch (error) {
throw new Error('Audit API returned invalid error JSON: ' + error.message);
}
if (agentStatus === 'frozen') {
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
process.exitCode = 2;
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
const match = String(failure.error.message || '').match(
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
);
if (match) {
writeChainState(match[1]);
logError(new Error('Chain hash resynced to server: ' + match[1]));
}
}
}
} catch {
// API unreachable — fail-open (allow)
throw new Error('Audit API returned HTTP ' + response.status);
} finally {
clearTimeout(timeout);
}
}
main();
async function main() {
try {
const hookData = await readHookInput();
const runtime = readConfigAndKey();
await submitOperation(hookData, runtime);
} catch (error) {
logError(error);
if (FAIL_CLOSED) throw error;
}
}
main().then(() => {
if (SUCCESS_OUTPUT) process.stdout.write(SUCCESS_OUTPUT);
}).catch((error) => {
process.stderr.write('[Elydora audit] ' + (error.stack || error.message || String(error)) + '\\n');
process.exitCode = 1;
});
`;
}
+44
View File
@@ -0,0 +1,44 @@
export const PROTECTED_RUNTIME_READER: string = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024;
const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024;
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) {
const before = fs.lstatSync(filePath);
if (!before.isFile() || before.isSymbolicLink()) {
throw new Error(label + ' path is not a physical file: ' + filePath);
}
if (before.size > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) {
throw new Error(label + ' permissions are too broad: ' + filePath);
}
let flags = fs.constants.O_RDONLY;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
flags |= fs.constants.O_NOFOLLOW;
}
const descriptor = fs.openSync(filePath, flags);
try {
const after = fs.fstatSync(descriptor);
if (!after.isFile()) {
throw new Error(label + ' path is not a physical file: ' + filePath);
}
if (after.size > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
if (process.platform !== 'win32' && (after.mode & 0o077) !== 0) {
throw new Error(label + ' permissions are too broad: ' + filePath);
}
if (before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(label + ' changed while opening: ' + filePath);
}
const raw = fs.readFileSync(descriptor);
if (raw.length > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
return raw;
} finally {
fs.closeSync(descriptor);
}
}
`;