fix(node): sync transactional Cursor hooks
This commit is contained in:
1 parent
68918e22d7
commit
2e699c11e3
16 files changed
+2466
-462
No files matched your search
+24
-31
@@ -188,41 +188,10 @@ async function cmdInstall(args: string[]): Promise<void> {
|
||||
|
||||
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`);
|
||||
|
||||
// Create ~/.elydora/{agentId}/ directory
|
||||
await ensurePrivateDirectory(ELYDORA_DIR);
|
||||
await ensurePrivateDirectory(agentDir);
|
||||
|
||||
// Write agent config
|
||||
const agentConfigPath = path.join(agentDir, 'config.json');
|
||||
const agentConfig = {
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
kid,
|
||||
base_url: baseUrl,
|
||||
...(token ? { token } : {}),
|
||||
agent_name: agentName,
|
||||
};
|
||||
await writePrivateFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n');
|
||||
console.log(` Agent config: ${agentConfigPath}`);
|
||||
|
||||
// Write private key with owner-restricted access
|
||||
const keyPath = path.join(agentDir, 'private.key');
|
||||
await writePrivateFile(keyPath, privateKey);
|
||||
console.log(` Private key: ${keyPath}`);
|
||||
|
||||
// Generate and write hook script (PostToolUse — audit logging)
|
||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||
const hookScript = generateHookScript(agentName, agentId);
|
||||
await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
console.log(` Hook script: ${hookScriptPath}`);
|
||||
|
||||
// Generate and write guard script (PreToolUse — freeze enforcement)
|
||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||
const guardScript = generateGuardScript(agentName, agentId);
|
||||
await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
console.log(` Guard script: ${guardScriptPath}`);
|
||||
|
||||
// Install agent-specific config hook
|
||||
const plugin = PLUGINS.get(agentName)!;
|
||||
const installConfig: InstallConfig = {
|
||||
agentName,
|
||||
@@ -235,8 +204,32 @@ async function cmdInstall(args: string[]): Promise<void> {
|
||||
hookScriptPath,
|
||||
guardScriptPath,
|
||||
};
|
||||
await plugin.preflightInstall?.(installConfig);
|
||||
|
||||
if (!plugin.managesRuntime) {
|
||||
await ensurePrivateDirectory(ELYDORA_DIR);
|
||||
await ensurePrivateDirectory(agentDir);
|
||||
const agentConfig = {
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
kid,
|
||||
base_url: baseUrl,
|
||||
...(token ? { token } : {}),
|
||||
agent_name: agentName,
|
||||
};
|
||||
await writePrivateFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n');
|
||||
await writePrivateFile(keyPath, privateKey);
|
||||
const hookScript = generateHookScript(agentName, agentId);
|
||||
await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
const guardScript = generateGuardScript(agentName, agentId);
|
||||
await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
}
|
||||
|
||||
await plugin.install(installConfig);
|
||||
console.log(` Agent config: ${agentConfigPath}`);
|
||||
console.log(` Private key: ${keyPath}`);
|
||||
console.log(` Hook script: ${hookScriptPath}`);
|
||||
console.log(` Guard script: ${guardScriptPath}`);
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(agentName)!;
|
||||
console.log(`\nElydora audit hook installed for ${entry.name}.`);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
parse,
|
||||
parseTree,
|
||||
printParseErrorCode,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
|
||||
export const AGENT_KEY = 'cursor';
|
||||
export const CONFIG_FILE = 'hooks.json';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
export type CursorHooks = Record<string, JsonObject[]>;
|
||||
|
||||
export interface CursorDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: CursorHooks;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
readonly document: CursorDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function samePath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.resolve(left);
|
||||
const normalizedRight = path.resolve(right);
|
||||
return process.platform === 'win32'
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
function sameAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32'
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function buildHandler(scriptPath: string): JsonObject {
|
||||
const command = process.platform === 'win32'
|
||||
? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`
|
||||
: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
return {
|
||||
command,
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
failClosed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
const apostrophe = `'"'"'`;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(apostrophe, index)) {
|
||||
value += "'";
|
||||
index += apostrophe.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePosixCommand(command: string): readonly [string, string] | undefined {
|
||||
const executable = readPosixArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPosixArgument(command, executable.next + 1);
|
||||
if (!script || script.next !== command.length) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] !== "'") {
|
||||
value += command[index];
|
||||
continue;
|
||||
}
|
||||
if (command[index + 1] === "'") {
|
||||
value += "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return { value, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePowerShellCommand(command: string): readonly [string, string] | undefined {
|
||||
if (!command.startsWith('& ')) return undefined;
|
||||
const executable = readPowerShellArgument(command, 2);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPowerShellArgument(command, executable.next + 1);
|
||||
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function parseLegacyCommand(command: string): readonly [string, string] | undefined {
|
||||
const match = /^(node(?:\.exe)?)\s+"([^"\r\n]+)"$/i.exec(command);
|
||||
return match ? [match[1], match[2]] : undefined;
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath);
|
||||
return basename === 'node' || basename.toLowerCase() === 'node.exe';
|
||||
}
|
||||
|
||||
function managedScriptPath(handler: JsonObject): string | undefined {
|
||||
const keys = Object.keys(handler);
|
||||
if (keys.length === 3
|
||||
&& typeof handler.command === 'string'
|
||||
&& handler.timeout === HOOK_TIMEOUT_SECONDS
|
||||
&& handler.failClosed === true) {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellCommand(handler.command)
|
||||
: parsePosixCommand(handler.command);
|
||||
if (parsed
|
||||
&& path.isAbsolute(parsed[0])
|
||||
&& path.isAbsolute(parsed[1])
|
||||
&& isNodeExecutable(parsed[0])) return parsed[1];
|
||||
}
|
||||
if (keys.length !== 1 || typeof handler.command !== 'string') return undefined;
|
||||
const legacy = parseLegacyCommand(handler.command);
|
||||
return legacy && isNodeExecutable(legacy[0]) && path.isAbsolute(legacy[1])
|
||||
? legacy[1]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedAgentId(handler: JsonObject, scriptName: string): string | undefined {
|
||||
const scriptPath = managedScriptPath(handler);
|
||||
if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
|
||||
}
|
||||
|
||||
function rejectDuplicateKeys(node: Node | undefined, label: string, location: string[] = []): void {
|
||||
if (!node) return;
|
||||
if (node.type === 'object') {
|
||||
const keys = new Set<string>();
|
||||
for (const property of node.children ?? []) {
|
||||
const key = String(property.children?.[0]?.value);
|
||||
const childLocation = [...location, key];
|
||||
if (keys.has(key)) {
|
||||
throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`);
|
||||
}
|
||||
keys.add(key);
|
||||
rejectDuplicateKeys(property.children?.[1], label, childLocation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.type === 'array') {
|
||||
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStrictJsonObject(raw: string, label: string): JsonObject {
|
||||
const errors: ParseError[] = [];
|
||||
const value: unknown = parse(raw, errors, {
|
||||
allowTrailingComma: false,
|
||||
disallowComments: true,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
const details = errors
|
||||
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||
.join(', ');
|
||||
throw new Error(`Failed to parse ${label}: ${details}`);
|
||||
}
|
||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||
rejectDuplicateKeys(parseTree(raw, [], {
|
||||
allowTrailingComma: false,
|
||||
disallowComments: true,
|
||||
}), label);
|
||||
return value;
|
||||
}
|
||||
|
||||
function readHooks(value: unknown, label: string): CursorHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
|
||||
const hooks: CursorHooks = {};
|
||||
for (const [event, candidate] of Object.entries(value)) {
|
||||
if (!Array.isArray(candidate)) {
|
||||
throw new Error(`${label} field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = candidate.map((handler, index) => {
|
||||
if (!isObject(handler)) {
|
||||
throw new Error(`${label} handler hooks.${event}[${index}] must be an object`);
|
||||
}
|
||||
return handler;
|
||||
});
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function parseDocument(filePath: string, raw: string): CursorDocument {
|
||||
const label = `Cursor user hooks at ${filePath}`;
|
||||
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,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDocument(filePath: string): CursorDocument {
|
||||
return { exists: false, filePath, root: {}, hooks: {} };
|
||||
}
|
||||
|
||||
export function removeManagedHooks(hooks: CursorHooks, agentId?: string): CursorHooks {
|
||||
const next: CursorHooks = Object.fromEntries(
|
||||
Object.entries(hooks).map(([event, handlers]) => [event, [...handlers]]),
|
||||
);
|
||||
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);
|
||||
return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId));
|
||||
});
|
||||
if (handlers.length > 0) next[event] = handlers;
|
||||
else delete next[event];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function entirelyManaged(document: CursorDocument): boolean {
|
||||
if (!document.exists
|
||||
|| !Object.keys(document.root).every((key) => key === 'version' || key === 'hooks')) {
|
||||
return false;
|
||||
}
|
||||
const events = Object.entries(document.hooks);
|
||||
if (events.length === 0) return false;
|
||||
let handlerCount = 0;
|
||||
for (const [event, handlers] of events) {
|
||||
const scriptName = event === 'preToolUse'
|
||||
? GUARD_SCRIPT
|
||||
: event === 'postToolUse' || event === 'postToolUseFailure'
|
||||
? AUDIT_SCRIPT
|
||||
: undefined;
|
||||
if (!scriptName || handlers.length === 0) return false;
|
||||
handlerCount += handlers.length;
|
||||
if (handlers.some((handler) => !managedAgentId(handler, scriptName))) return false;
|
||||
}
|
||||
return handlerCount > 0;
|
||||
}
|
||||
|
||||
export function renderDocument(
|
||||
document: CursorDocument,
|
||||
hooks: CursorHooks,
|
||||
): RenderedDocument {
|
||||
if (!document.exists && Object.keys(hooks).length === 0) {
|
||||
return { document, changed: false };
|
||||
}
|
||||
if (Object.keys(hooks).length === 0 && entirelyManaged(document)) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
const root: JsonObject = { ...document.root, version: 1 };
|
||||
if (Object.keys(hooks).length > 0) root.hooks = hooks;
|
||||
else delete root.hooks;
|
||||
const next = `${JSON.stringify(root, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
function managedIds(handlers: JsonObject[], scriptName: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const handler of handlers) {
|
||||
const agentId = managedAgentId(handler, scriptName);
|
||||
if (!agentId) continue;
|
||||
const key = process.platform === 'win32' ? agentId.toLowerCase() : agentId;
|
||||
result.set(key, agentId);
|
||||
}
|
||||
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) && failures.has(key))
|
||||
.map(([, agentId]) => ({
|
||||
agentId,
|
||||
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
||||
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
||||
}));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
type CursorDocument,
|
||||
type JsonObject,
|
||||
type RenderedDocument,
|
||||
type RuntimeContract,
|
||||
createDocument,
|
||||
isObject,
|
||||
parseDocument,
|
||||
parseStrictJsonObject,
|
||||
} from './cursor-contract.js';
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return isObject(error) && error.code === code;
|
||||
}
|
||||
|
||||
export function cursorConfigPath(): string {
|
||||
return path.join(os.homedir(), '.cursor', CONFIG_FILE);
|
||||
}
|
||||
|
||||
async function readOptionalPhysical(filePath: string, label: string): Promise<string | undefined> {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await fsp.lstat(filePath);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return undefined;
|
||||
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new Error(`${label} path is not a physical file: ${filePath}`);
|
||||
}
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDocument(): Promise<CursorDocument> {
|
||||
const filePath = cursorConfigPath();
|
||||
const raw = await readOptionalPhysical(filePath, 'Cursor user hooks');
|
||||
return raw === undefined ? createDocument(filePath) : parseDocument(filePath, raw);
|
||||
}
|
||||
|
||||
async function ensurePhysicalDirectory(directory: string): Promise<void> {
|
||||
try {
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
} catch (error) {
|
||||
throw new Error(`Create Cursor hooks directory at ${directory}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await fsp.lstat(directory);
|
||||
} catch (error) {
|
||||
throw new Error(`Inspect Cursor hooks directory at ${directory}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
throw new Error(`Cursor hooks directory is not a physical directory: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkOptional(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusive(filePath: string, contents: string): Promise<void> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await fsp.open(filePath, 'wx', 0o600);
|
||||
await handle.writeFile(contents, 'utf-8');
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
} 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 Cursor user hooks');
|
||||
}
|
||||
throw new Error(`Stage Cursor user hooks: ${errorMessage(error)}`, { cause: failures[0] });
|
||||
}
|
||||
}
|
||||
|
||||
async function assertUnchanged(document: CursorDocument): Promise<void> {
|
||||
const current = await readOptionalPhysical(document.filePath, 'Cursor user hooks');
|
||||
if (current !== document.raw) {
|
||||
throw new Error(`Cursor user hooks changed during update: ${document.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
|
||||
if (!rendered.changed) return;
|
||||
await assertUnchanged(rendered.document);
|
||||
if (rendered.next === undefined) {
|
||||
try {
|
||||
await fsp.unlink(rendered.document.filePath);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, 'ENOENT')) {
|
||||
throw new Error(
|
||||
`Remove Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
|
||||
{ cause: asError(error) },
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const directory = path.dirname(rendered.document.filePath);
|
||||
await ensurePhysicalDirectory(directory);
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`,
|
||||
);
|
||||
let committed = false;
|
||||
let failure: Error | undefined;
|
||||
try {
|
||||
await writeExclusive(temporaryPath, rendered.next);
|
||||
await assertUnchanged(rendered.document);
|
||||
await fsp.rename(temporaryPath, rendered.document.filePath);
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
failure = new Error(
|
||||
`Write Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
|
||||
{ cause: asError(error) },
|
||||
);
|
||||
}
|
||||
let cleanupFailure: Error | undefined;
|
||||
if (!committed) {
|
||||
try {
|
||||
await unlinkOptional(temporaryPath);
|
||||
} catch (error) {
|
||||
cleanupFailure = new Error(
|
||||
`Clean Cursor hook staging file at ${temporaryPath}: ${errorMessage(error)}`,
|
||||
{ cause: asError(error) },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failure && cleanupFailure) {
|
||||
throw new AggregateError([failure, cleanupFailure], 'Write Cursor user hooks');
|
||||
}
|
||||
if (failure) throw failure;
|
||||
if (cleanupFailure) throw cleanupFailure;
|
||||
}
|
||||
|
||||
export async function physicalFileExists(filePath: string, label: string): Promise<boolean> {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await fsp.lstat(filePath);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false;
|
||||
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new Error(`${label} path is not a physical file: ${filePath}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requireRuntime(filePath: string, label: string): Promise<void> {
|
||||
if (!filePath) throw new Error(`${label} path is required`);
|
||||
if (!await physicalFileExists(filePath, label)) {
|
||||
throw new Error(`${label} is missing: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
|
||||
const raw = await readOptionalPhysical(filePath, 'Elydora runtime config');
|
||||
if (raw === undefined) return undefined;
|
||||
return parseStrictJsonObject(raw, `Elydora runtime config at ${filePath}`);
|
||||
}
|
||||
|
||||
function sameAgentId(left: unknown, right: string): boolean {
|
||||
if (typeof left !== 'string') return false;
|
||||
return process.platform === 'win32'
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
const runtimeConfig = await readRuntimeConfig(
|
||||
path.join(path.dirname(contract.guardPath), 'config.json'),
|
||||
);
|
||||
if (!runtimeConfig
|
||||
|| runtimeConfig.agent_name !== AGENT_KEY
|
||||
|| !sameAgentId(runtimeConfig.agent_id, contract.agentId)) continue;
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+47
-128
@@ -1,151 +1,70 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildHandler,
|
||||
removeManagedHooks,
|
||||
renderDocument,
|
||||
runtimeContracts,
|
||||
} from './cursor-contract.js';
|
||||
import {
|
||||
commitCursorInstallation,
|
||||
preflightCursorInstallation,
|
||||
prepareCursorInstallation,
|
||||
} from './cursor-installation.js';
|
||||
import {
|
||||
readDocument,
|
||||
runtimeFilesExist,
|
||||
writeDocument,
|
||||
} from './cursor-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'cursor';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
// .cursor is project-relative, but for global install use home dir
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter(
|
||||
(h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId),
|
||||
);
|
||||
}
|
||||
|
||||
export const cursorPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
await readDocument();
|
||||
await preflightCursorInstallation(config);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
const document = await readDocument();
|
||||
const paths = await preflightCursorInstallation(config);
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({ command: buildHookCommand(config.guardScriptPath) });
|
||||
hooks.preToolUse = preFiltered;
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({ command: buildHookCommand(config.hookScriptPath) });
|
||||
hooks.postToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
settings.version = 1;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const hooks = removeManagedHooks(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}`);
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const document = await readDocument();
|
||||
const hooks = removeManagedHooks(document.hooks, agentId);
|
||||
await writeDocument(renderDocument(document, hooks));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const check = (arr: unknown) =>
|
||||
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
||||
|
||||
// Extract hook script path from postToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
|
||||
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const document = await readDocument();
|
||||
const contracts = runtimeContracts(document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: document.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
`;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export interface AgentRegistryEntry {
|
||||
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
|
||||
['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }],
|
||||
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
|
||||
['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }],
|
||||
['cursor', { name: 'Cursor', configDir: '~/.cursor', configFile: 'hooks.json' }],
|
||||
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
Reference in new issue
Block a user