fix: make Codex hooks transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 02:07:52 -04:00
1 parent 6a7fdb7595
commit a9adba1fc1
19 files changed
+2525 -1114

No files matched your search

+373
View File
@@ -0,0 +1,373 @@
import os from 'node:os';
import path from 'node:path';
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
export const AGENT_KEY = 'codex';
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 const OWNED_DESCRIPTION = 'Elydora audit and freeze enforcement';
export const GUARD_STATUS = 'Checking Elydora agent state';
export const AUDIT_STATUS = 'Recording Elydora tool use';
export type CodexHooks = Record<string, JsonObject[]>;
export interface CodexDocument {
readonly exists: boolean;
readonly filePath: string;
readonly root: JsonObject;
readonly hooks: CodexHooks;
readonly raw?: string;
}
export interface RenderedDocument {
readonly document: CodexDocument;
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;
}
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("'", "''")}'`;
}
function windowsPowerShellPath(): string {
const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined;
const systemRoot = configuredRoot
&& path.win32.isAbsolute(configuredRoot)
&& !/["%\r\n]/.test(configuredRoot)
? configuredRoot
: 'C:\\Windows';
return path.win32.join(
systemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe',
);
}
function windowsCommand(scriptPath: string): string {
const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
const encoded = Buffer.from(source, 'utf16le').toString('base64');
return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
}
export function buildHandler(scriptPath: string, statusMessage: string): JsonObject {
return {
type: 'command',
command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`,
commandWindows: windowsCommand(scriptPath),
timeout: HOOK_TIMEOUT_SECONDS,
statusMessage,
};
}
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: unknown): readonly [string, string] | undefined {
if (typeof command !== 'string') return 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 parsePowerShellSource(source: string): readonly [string, string] | undefined {
if (!source.startsWith('& ')) return undefined;
const executable = readPowerShellArgument(source, 2);
if (!executable || source[executable.next] !== ' ') return undefined;
const script = readPowerShellArgument(source, executable.next + 1);
if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
return [executable.value, script.value];
}
function parseWindowsCommand(command: unknown): readonly [string, string] | undefined {
if (typeof command !== 'string') return undefined;
const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command);
if (!match
|| !path.win32.isAbsolute(match[1])
|| path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined;
const encoded = match[2];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined;
const buffer = Buffer.from(encoded, 'base64');
if (buffer.toString('base64') !== encoded || buffer.length % 2 !== 0) return undefined;
return parsePowerShellSource(buffer.toString('utf16le'));
}
function parseLegacyWindowsCommand(command: unknown): readonly [string, string] | undefined {
if (typeof command !== 'string') return undefined;
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.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 exactHandlerKeys(handler: JsonObject): boolean {
return Object.keys(handler).sort().join('|')
=== 'command|commandWindows|statusMessage|timeout|type';
}
function managedScriptPath(handler: JsonObject, statusMessage: string): string | undefined {
if (!exactHandlerKeys(handler)
|| handler.type !== 'command'
|| handler.timeout !== HOOK_TIMEOUT_SECONDS
|| handler.statusMessage !== statusMessage) return undefined;
const posix = parsePosixCommand(handler.command);
const windows = parseWindowsCommand(handler.commandWindows)
?? parseLegacyWindowsCommand(handler.commandWindows);
if (!posix || !windows
|| !path.isAbsolute(posix[0])
|| !path.isAbsolute(posix[1])
|| !isNodeExecutable(posix[0])
|| !isNodeExecutable(windows[0])
|| !samePath(posix[0], windows[0])
|| !samePath(posix[1], windows[1])) return undefined;
return posix[1];
}
function managedAgentId(
handler: JsonObject,
scriptName: string,
statusMessage: string,
): string | undefined {
const scriptPath = managedScriptPath(handler, statusMessage);
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 readHooks(value: unknown, label: string): CodexHooks {
if (value === undefined) return {};
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
const hooks: CodexHooks = {};
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((group, groupIndex) => {
if (!isObject(group)) {
throw new Error(`${label} matcher group hooks.${event}[${groupIndex}] must be an object`);
}
if (!Array.isArray(group.hooks)) {
throw new Error(
`${label} matcher group hooks.${event}[${groupIndex}] must contain a hooks array`,
);
}
for (const [handlerIndex, handler] of group.hooks.entries()) {
if (!isObject(handler)) {
throw new Error(
`${label} handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}] must be an object`,
);
}
}
return group;
});
}
return hooks;
}
export function parseDocument(filePath: string, raw: string): CodexDocument {
const label = `Codex user hooks at ${filePath}`;
const root = parseStrictJsonObject(raw, label);
return {
exists: true,
filePath,
root,
hooks: readHooks(root.hooks, label),
raw,
};
}
export function createDocument(filePath: string): CodexDocument {
return {
exists: false,
filePath,
root: { description: OWNED_DESCRIPTION },
hooks: {},
};
}
function removeFromGroups(
groups: JsonObject[],
scriptName: string,
statusMessage: string,
agentId?: string,
): JsonObject[] {
const result: JsonObject[] = [];
for (const group of groups) {
const handlers = (group.hooks as JsonObject[]).filter((handler) => {
const managedId = managedAgentId(handler, scriptName, statusMessage);
return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId));
});
if (handlers.length > 0) result.push({ ...group, hooks: handlers });
else if (!exactMatcherGroup(group)) result.push({ ...group, hooks: [] });
}
return result;
}
export function removeManagedHooks(hooks: CodexHooks, agentId?: string): CodexHooks {
const next: CodexHooks = Object.fromEntries(
Object.entries(hooks).map(([event, groups]) => [event, [...groups]]),
);
for (const [event, scriptName, statusMessage] of [
['PreToolUse', GUARD_SCRIPT, GUARD_STATUS],
['PostToolUse', AUDIT_SCRIPT, AUDIT_STATUS],
] as const) {
const groups = removeFromGroups(next[event] ?? [], scriptName, statusMessage, agentId);
if (groups.length > 0) next[event] = groups;
else delete next[event];
}
return next;
}
function entirelyManaged(document: CodexDocument): boolean {
if (!document.exists
|| document.root.description !== OWNED_DESCRIPTION
|| !Object.keys(document.root).every((key) => key === 'description' || key === 'hooks')) {
return false;
}
const events = Object.entries(document.hooks);
if (events.length === 0) return false;
let handlerCount = 0;
for (const [event, groups] of events) {
const contract = event === 'PreToolUse'
? [GUARD_SCRIPT, GUARD_STATUS] as const
: event === 'PostToolUse'
? [AUDIT_SCRIPT, AUDIT_STATUS] as const
: undefined;
if (!contract || groups.length === 0) return false;
for (const group of groups) {
const handlers = group.hooks as JsonObject[];
if (!exactMatcherGroup(group)
|| handlers.length === 0
|| handlers.some((handler) => !managedAgentId(handler, contract[0], contract[1]))) {
return false;
}
handlerCount += handlers.length;
}
}
return handlerCount > 0;
}
export function renderDocument(
document: CodexDocument,
hooks: CodexHooks,
): 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 };
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 exactMatcherGroup(group: JsonObject): boolean {
return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*';
}
function managedIds(
groups: JsonObject[],
scriptName: string,
statusMessage: string,
): Map<string, string> {
const result = new Map<string, string>();
for (const group of groups) {
if (!exactMatcherGroup(group)) continue;
for (const handler of group.hooks as JsonObject[]) {
const agentId = managedAgentId(handler, scriptName, statusMessage);
if (!agentId) continue;
const key = process.platform === 'win32' ? agentId.toLowerCase() : agentId;
result.set(key, agentId);
}
}
return result;
}
export function runtimeContracts(hooks: CodexHooks): RuntimeContract[] {
const guards = managedIds(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_STATUS);
const audits = managedIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_STATUS);
const root = path.join(os.homedir(), '.elydora');
return [...guards]
.filter(([key]) => audits.has(key))
.map(([, agentId]) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}
+69
View File
@@ -0,0 +1,69 @@
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
type RenderedDocument,
} from './codex-contract.js';
import {
commitManagedInstallation,
managedRuntimePaths,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
const DISPLAY_NAME = 'Codex';
const HOOKS_DIRECTORY_LABEL = 'Codex hooks directory';
const HOOKS_LABEL = 'Codex user hooks';
export type CodexRuntimePaths = ManagedRuntimePaths;
export type PreparedCodexInstallation = PreparedManagedInstallation;
export type { RenameFile };
export function codexRuntimePaths(config: InstallConfig): CodexRuntimePaths {
return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function preflightCodexInstallation(
config: InstallConfig,
hooksPath: string,
): Promise<CodexRuntimePaths> {
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
hooksPath,
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function prepareCodexInstallation(
config: InstallConfig,
rendered: RenderedDocument,
): Promise<PreparedCodexInstallation> {
if (!rendered.changed && rendered.document.raw === undefined) {
throw new Error('Codex hook installation did not produce a configuration document');
}
const hooksSource = rendered.next ?? rendered.document.raw;
if (hooksSource === undefined) throw new Error('Codex hook configuration is missing');
return prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
hooksLabel: HOOKS_LABEL,
hooksPath: rendered.document.filePath,
expectedHooksSource: rendered.document.raw,
hooksSource,
config,
auditOptions: { nativePayload: true },
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function commitCodexInstallation(
prepared: PreparedCodexInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
+197
View File
@@ -0,0 +1,197 @@
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 CodexDocument,
type RenderedDocument,
type RuntimeContract,
createDocument,
parseDocument,
} from './codex-contract.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { parseStrictJsonObject } from './strict-json.js';
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 codexHomePath(): Promise<string> {
const configured = process.env.CODEX_HOME;
if (configured === undefined || configured === '') return path.join(os.homedir(), '.codex');
let metadata;
try {
metadata = await fsp.stat(configured);
} catch (error) {
throw new Error(`Resolve CODEX_HOME at ${configured}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!metadata.isDirectory()) throw new Error(`CODEX_HOME is not a directory: ${configured}`);
let canonical;
try {
canonical = await fsp.realpath(configured);
} catch (error) {
throw new Error(`Canonicalize CODEX_HOME at ${configured}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!await inspectPhysicalDirectory(canonical, 'CODEX_HOME')) {
throw new Error(`CODEX_HOME is missing: ${canonical}`);
}
return canonical;
}
export async function codexConfigPath(): Promise<string> {
return path.join(await codexHomePath(), CONFIG_FILE);
}
export async function readDocument(): Promise<CodexDocument> {
const filePath = await codexConfigPath();
const snapshot = await readPhysicalFile(filePath, 'Codex user hooks');
return snapshot ? parseDocument(filePath, snapshot.contents) : createDocument(filePath);
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(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;
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
} 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 Codex user hooks');
throw new Error(`Stage Codex user hooks: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function assertUnchanged(document: CodexDocument): Promise<void> {
const current = await readPhysicalFile(document.filePath, 'Codex user hooks');
if (current?.contents !== document.raw) {
throw new Error(`Codex user hooks changed during update: ${document.filePath}`);
}
}
async function ensureHooksDirectory(directory: string): Promise<void> {
try {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
} catch (error) {
throw new Error(`Create Codex hooks directory at ${directory}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!await inspectPhysicalDirectory(directory, 'Codex hooks directory')) {
throw new Error(`Codex hooks directory is missing: ${directory}`);
}
}
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
if (!rendered.changed) return;
await assertUnchanged(rendered.document);
const directory = path.dirname(rendered.document.filePath);
await ensureHooksDirectory(directory);
if (rendered.next === undefined) {
try {
await fsp.unlink(rendered.document.filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) {
throw new Error(
`Remove Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
{ cause: asError(error) },
);
}
}
return;
}
const temporaryPath = path.join(
directory,
`.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`,
);
let failure: Error | undefined;
try {
await writeExclusive(temporaryPath, rendered.next);
await assertUnchanged(rendered.document);
await fsp.rename(temporaryPath, rendered.document.filePath);
} catch (error) {
failure = new Error(
`Write Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
{ cause: asError(error) },
);
}
let cleanupFailure: Error | undefined;
try {
await unlinkOptional(temporaryPath);
} catch (error) {
cleanupFailure = new Error(
`Clean Codex hook staging file at ${temporaryPath}: ${errorMessage(error)}`,
{ cause: asError(error) },
);
}
if (failure && cleanupFailure) {
throw new AggregateError([failure, cleanupFailure], 'Write Codex user hooks');
}
if (failure) throw failure;
if (cleanupFailure) throw cleanupFailure;
}
async function physicalFileExists(filePath: string, label: string): Promise<boolean> {
return Boolean(await readPhysicalFile(filePath, label));
}
function sameAgentId(left: unknown, right: string): boolean {
return typeof left === 'string' && (process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right);
}
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
for (const contract of contracts) {
const directory = path.dirname(contract.guardPath);
const configPath = path.join(directory, 'config.json');
const snapshot = await readPhysicalFile(configPath, 'Elydora runtime config');
if (!snapshot) continue;
const config = parseStrictJsonObject(
snapshot.contents,
`Elydora runtime config at ${configPath}`,
);
if (config.agent_name !== AGENT_KEY || !sameAgentId(config.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(directory, 'private.key'), 'Elydora private key'),
]);
if (files.every(Boolean)) return true;
}
return false;
}
+43 -292
View File
@@ -1,320 +1,71 @@
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 type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
AUDIT_STATUS,
GUARD_STATUS,
buildHandler,
removeManagedHooks,
renderDocument,
runtimeContracts,
} from './codex-contract.js';
import {
commitCodexInstallation,
preflightCodexInstallation,
prepareCodexInstallation,
} from './codex-installation.js';
import { readDocument, runtimeFilesExist, writeDocument } from './codex-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'codex';
const OWNED_DESCRIPTION = 'Elydora audit and freeze enforcement';
const GUARD_STATUS = 'Checking Elydora agent state';
const AUDIT_STATUS = 'Recording Elydora tool use';
const GUARD_SCRIPT = 'guard.js';
const AUDIT_SCRIPT = 'hook.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasErrorCode(error: unknown, code: string): boolean {
return isObject(error) && error.code === code;
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function resolveConfigPath(): string {
const configDir = entry.configDir.replace(/^~/, os.homedir());
return path.join(configDir, entry.configFile);
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quoteWindows(value: string): string {
return `"${value.replaceAll('"', '\\"')}"`;
}
function buildHandler(scriptPath: string, statusMessage: string): JsonObject {
return {
type: 'command',
command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`,
commandWindows: `${quoteWindows(process.execPath)} ${quoteWindows(scriptPath)}`,
timeout: 10,
statusMessage,
};
}
function eventGroups(hooks: JsonObject, event: string): JsonObject[] {
const value = hooks[event];
if (value === undefined) return [];
if (!Array.isArray(value) || !value.every(isObject)) {
throw new Error(`Codex hooks config field "hooks.${event}" must be an array of objects`);
}
return value;
}
function isManagedCommand(command: unknown, scriptName: string, agentId?: string): boolean {
if (typeof command !== 'string') return false;
const normalized = command.toLowerCase();
if (!normalized.includes('.elydora') || !normalized.includes(scriptName)) return false;
return !agentId || command.includes(agentId);
}
function isElydoraHandler(handler: JsonObject, agentId?: string): boolean {
const scriptName = handler.statusMessage === GUARD_STATUS
? GUARD_SCRIPT
: handler.statusMessage === AUDIT_STATUS
? AUDIT_SCRIPT
: undefined;
if (!scriptName) return false;
return [handler.command, handler.commandWindows].some(
(command) => isManagedCommand(command, scriptName, agentId),
);
}
function withoutElydora(groups: JsonObject[], agentId?: string): JsonObject[] {
const result: JsonObject[] = [];
for (const group of groups) {
if (!Array.isArray(group.hooks) || !group.hooks.every(isObject)) {
throw new Error('Codex hook matcher group must contain a hooks array');
}
const handlers = group.hooks.filter((handler) => !isElydoraHandler(handler, agentId));
if (handlers.length > 0) result.push({ ...group, hooks: handlers });
}
return result;
}
function findHandler(groups: JsonObject[], statusMessage: string): JsonObject | undefined {
for (const group of groups) {
if (!Array.isArray(group.hooks) || !group.hooks.every(isObject)) {
throw new Error('Codex hook matcher group must contain a hooks array');
}
const handler = group.hooks.find(
(candidate) => candidate.statusMessage === statusMessage
&& isElydoraHandler(candidate),
);
if (isObject(handler)) return handler;
}
return undefined;
}
function commandReferences(handler: JsonObject, scriptPath: string): boolean {
return [handler.command, handler.commandWindows].some(
(command) => typeof command === 'string' && command.includes(scriptPath),
);
}
async function readJsonObject(filePath: string, label: string): Promise<JsonObject | undefined> {
let raw: string;
try {
raw = await fsp.readFile(filePath, 'utf-8');
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return undefined;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`);
return value;
}
async function readSettings(configPath: string): Promise<JsonObject | undefined> {
return readJsonObject(configPath, 'Codex hooks config');
}
async function failWrite(
handle: FileHandle | undefined,
tempPath: string,
label: string,
cause: unknown,
): Promise<never> {
const errors = [asError(cause)];
if (handle) {
try {
await handle.close();
} catch (error) {
errors.push(asError(error));
}
}
try {
await fsp.unlink(tempPath);
} catch (error) {
if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error));
}
const message = `Write ${label}: ${errorMessage(cause)}`;
if (errors.length > 1) throw new AggregateError(errors, message);
throw new Error(message, { cause: errors[0] });
}
async function writeSettings(configPath: string, settings: JsonObject): Promise<void> {
const directory = path.dirname(configPath);
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
const tempPath = path.join(directory, `.${path.basename(configPath)}.${randomUUID()}.tmp`);
let handle: FileHandle | undefined;
try {
handle = await fsp.open(tempPath, 'wx', 0o600);
await handle.writeFile(JSON.stringify(settings, null, 2) + '\n', 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
await fsp.rename(tempPath, configPath);
} catch (error) {
await failWrite(handle, tempPath, `Codex hooks config at ${configPath}`, error);
}
}
async function removeSettings(configPath: string): Promise<void> {
try {
await fsp.unlink(configPath);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return;
throw new Error(`Remove Codex hooks config at ${configPath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
function getHooks(settings: JsonObject): JsonObject {
if (settings.hooks === undefined) return {};
if (!isObject(settings.hooks)) throw new Error('Codex hooks config field "hooks" must be an object');
return { ...settings.hooks };
}
function isOwnedSettings(settings: JsonObject, hooks: JsonObject): boolean {
const settingsKeys = new Set(['description', 'hooks']);
const hookKeys = new Set(['PreToolUse', 'PostToolUse']);
return Object.keys(settings).every((key) => settingsKeys.has(key))
&& settings.description === OWNED_DESCRIPTION
&& Object.keys(hooks).every((key) => hookKeys.has(key))
&& Object.values(hooks).every((value) => Array.isArray(value) && value.length === 0);
}
async function regularFileExists(filePath: string, label: string): Promise<boolean> {
try {
const metadata = await fsp.stat(filePath);
return metadata.isFile();
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return false;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
async function requireRuntime(filePath: string, label: string): Promise<void> {
if (!filePath) throw new Error(`${label} path is required`);
if (!await regularFileExists(filePath, label)) {
throw new Error(`${label} is missing: ${filePath}`);
}
}
async function runtimeScriptsExist(guard: JsonObject, audit: JsonObject): Promise<boolean> {
const root = path.join(os.homedir(), '.elydora');
let entries: Array<{ isDirectory(): boolean; name: string }>;
try {
entries = await fsp.readdir(root, { withFileTypes: true });
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return false;
throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
for (const directory of entries) {
if (!directory.isDirectory()) continue;
const agentDir = path.join(root, directory.name);
const guardPath = path.join(agentDir, GUARD_SCRIPT);
const hookPath = path.join(agentDir, AUDIT_SCRIPT);
if (!commandReferences(guard, guardPath) || !commandReferences(audit, hookPath)) continue;
const configPath = path.join(agentDir, 'config.json');
const config = await readJsonObject(configPath, 'Elydora runtime config');
if (!config || config.agent_name !== AGENT_KEY) continue;
const [guardExists, hookExists] = await Promise.all([
regularFileExists(guardPath, 'Elydora guard runtime'),
regularFileExists(hookPath, 'Elydora audit runtime'),
]);
return guardExists && hookExists;
}
return false;
function matcherGroup(handler: ReturnType<typeof buildHandler>): Record<string, unknown> {
return { matcher: '*', hooks: [handler] };
}
export const codexPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
if (!config.agentId) throw new Error('agentId is required');
const configPath = resolveConfigPath();
const existingSettings = await readSettings(configPath);
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
const settings = existingSettings ?? { description: OWNED_DESCRIPTION };
const hooks = getHooks(settings);
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
const document = await readDocument();
await preflightCodexInstallation(config, document.filePath);
},
async install(config: InstallConfig): Promise<void> {
const document = await readDocument();
const paths = await preflightCodexInstallation(config, document.filePath);
const hooks = removeManagedHooks(document.hooks);
hooks.PreToolUse = [
...withoutElydora(eventGroups(hooks, 'PreToolUse')),
{ matcher: '*', hooks: [buildHandler(config.guardScriptPath, GUARD_STATUS)] },
...(hooks.PreToolUse ?? []),
matcherGroup(buildHandler(paths.guardPath, GUARD_STATUS)),
];
hooks.PostToolUse = [
...withoutElydora(eventGroups(hooks, 'PostToolUse')),
{ matcher: '*', hooks: [buildHandler(config.hookScriptPath, AUDIT_STATUS)] },
...(hooks.PostToolUse ?? []),
matcherGroup(buildHandler(paths.auditPath, AUDIT_STATUS)),
];
await writeSettings(configPath, { ...settings, hooks });
console.log(' Codex: run /hooks to review and trust the Elydora hooks.');
const prepared = await prepareCodexInstallation(config, renderDocument(document, hooks));
await commitCodexInstallation(prepared);
console.log(` Codex hooks: ${document.filePath}`);
console.log(' Codex trust: run /hooks and approve both Elydora command hooks.');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
const settings = await readSettings(configPath);
if (!settings) return;
const hooks = getHooks(settings);
hooks.PreToolUse = withoutElydora(eventGroups(hooks, 'PreToolUse'), agentId);
hooks.PostToolUse = withoutElydora(eventGroups(hooks, 'PostToolUse'), agentId);
if (isOwnedSettings(settings, hooks)) {
await removeSettings(configPath);
} else {
await writeSettings(configPath, { ...settings, hooks });
}
const document = await readDocument();
const hooks = removeManagedHooks(document.hooks, agentId);
await writeDocument(renderDocument(document, hooks));
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
const settings = await readSettings(configPath);
let guard: JsonObject | undefined;
let audit: JsonObject | undefined;
if (settings) {
const hooks = getHooks(settings);
guard = findHandler(eventGroups(hooks, 'PreToolUse'), GUARD_STATUS);
audit = findHandler(eventGroups(hooks, 'PostToolUse'), AUDIT_STATUS);
}
const hookConfigured = Boolean(guard && audit);
const hookScriptExists = guard && audit ? await runtimeScriptsExist(guard, audit) : false;
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,
};
},
};
+4 -52
View File
@@ -1,12 +1,9 @@
import os from 'node:os';
import path from 'node:path';
import {
parse,
parseTree,
printParseErrorCode,
type Node,
type ParseError,
} from 'jsonc-parser';
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
export { isObject, parseStrictJsonObject };
export type { JsonObject };
export const AGENT_KEY = 'cursor';
export const CONFIG_FILE = 'hooks.json';
@@ -14,7 +11,6 @@ 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 {
@@ -42,10 +38,6 @@ interface ParsedArgument {
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);
@@ -173,46 +165,6 @@ function managedAgentId(handler: JsonObject, scriptName: string): string | undef
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`);
+48 -419
View File
@@ -1,448 +1,77 @@
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 {
commitManagedInstallation,
managedRuntimePaths,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.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;
const DISPLAY_NAME = 'Cursor';
const HOOKS_DIRECTORY_LABEL = 'Cursor hooks directory';
const HOOKS_LABEL = 'Cursor user hooks';
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 type CursorRuntimePaths = ManagedRuntimePaths;
export type PreparedCursorInstallation = PreparedManagedInstallation;
export type { RenameFile };
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,
};
return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT);
}
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 preflightCursorInstallation(
config: InstallConfig,
): Promise<CursorRuntimePaths> {
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
hooksPath: cursorConfigPath(),
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
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');
const hooksSource = rendered.next ?? rendered.document.raw;
if (hooksSource === undefined) throw new Error('Cursor hook configuration is missing');
return prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
hooksLabel: HOOKS_LABEL,
hooksPath: rendered.document.filePath,
expectedHooksSource: rendered.document.raw,
hooksSource,
config,
guardOptions: {
failClosed: true,
successOutput: '{"permission":"allow"}\n',
},
auditOptions: {
failClosed: true,
nativePayload: true,
successOutput: '{}\n',
},
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function commitCursorInstallation(
prepared: PreparedCursorInstallation,
renameFile: RenameFile = fsp.rename,
renameFile?: RenameFile,
): 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');
}
await commitManagedInstallation(prepared, renameFile);
}
+13 -22
View File
@@ -16,6 +16,7 @@ export function generateGuardScript(
'use strict';
// Elydora agent guard for ${agentName}. Generated by @elydora/sdk.
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
@@ -55,9 +56,13 @@ function blockAgent(status) {
function readStatusCache() {
let raw;
try {
raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
raw = readProtectedFile(
STATUS_CACHE_PATH,
'Status cache',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
} catch (error) {
if (error.code === 'ENOENT') return undefined;
if (hasRuntimeErrorCode(error, 'ENOENT')) return undefined;
throw new Error('Failed to read status cache: ' + error.message);
}
let cache;
@@ -75,26 +80,10 @@ function readStatusCache() {
}
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;
}
}
writeProtectedJson(STATUS_CACHE_PATH, 'Status cache', {
status,
cached_at: Date.now(),
});
}
async function readInput() {
@@ -103,12 +92,14 @@ async function readInput() {
const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (!raw) {
if (FAIL_CLOSED) throw new Error('Hook input is empty');
process.stderr.write('[Elydora guard] Hook input is empty; enforcement continued in fail-open mode.\\n');
return;
}
try {
JSON.parse(raw);
} catch (error) {
if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message);
process.stderr.write('[Elydora guard] Hook input is invalid JSON; enforcement continued in fail-open mode: ' + error.message + '\\n');
}
}
+8 -26
View File
@@ -130,7 +130,7 @@ 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 });
appendProtectedText(ERROR_LOG_PATH, 'Error log', message);
} catch (logFailure) {
process.stderr.write('[Elydora audit] Failed to write error log: ' + logFailure.message + '\\n');
}
@@ -139,9 +139,13 @@ function logError(error) {
function readChainState() {
let raw;
try {
raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
raw = readProtectedFile(
CHAIN_STATE_PATH,
'Chain state',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
} catch (error) {
if (error.code === 'ENOENT') return ZERO_CHAIN_HASH;
if (hasRuntimeErrorCode(error, 'ENOENT')) return ZERO_CHAIN_HASH;
throw new Error('Failed to read chain state: ' + error.message);
}
let state;
@@ -158,29 +162,7 @@ function readChainState() {
}
function writeChainState(chainHash) {
const temporaryPath = CHAIN_STATE_PATH + '.' + process.pid + '.tmp';
let descriptor;
try {
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 {
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;
}
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;
}
}
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
}
function readConfigAndKey() {
+86
View File
@@ -0,0 +1,86 @@
import { constants } from 'node:fs';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
export interface FileSnapshot {
readonly contents: string;
readonly device: bigint | number;
readonly inode: bigint | number;
readonly mode: number;
}
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;
}
export 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') || hasCode(error, 'ENOTDIR')) 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();
}
}
export async function inspectPhysicalDirectory(directory: string, label: string): Promise<boolean> {
let metadata;
try {
metadata = await fsp.lstat(directory);
} catch (error) {
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) 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;
}
+432
View File
@@ -0,0 +1,432 @@
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 { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js';
import type { InstallConfig } from './base.js';
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
import { generateHookScript, type HookScriptOptions } from './hook-template.js';
import {
inspectPhysicalDirectory,
readPhysicalFile,
type FileSnapshot,
} from './managed-files.js';
import { parseStrictJsonObject } from './strict-json.js';
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly next: string;
readonly mode: number;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
interface StagedChange {
readonly change: FileChange;
readonly temporaryPath: string;
readonly rollbackPath?: string;
committed: boolean;
}
export interface ManagedRuntimePaths {
readonly runtimeRoot: string;
readonly agentDirectory: string;
readonly configPath: string;
readonly keyPath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedInstallationSpec {
readonly agentKey: string;
readonly displayName: string;
readonly hooksDirectoryLabel: string;
readonly hooksLabel: string;
readonly hooksPath: string;
readonly expectedHooksSource?: string;
readonly hooksSource: string;
readonly config: InstallConfig;
readonly guardOptions?: GuardScriptOptions;
readonly auditOptions?: HookScriptOptions;
}
export interface ManagedPreflightSpec {
readonly agentKey: string;
readonly hooksDirectoryLabel: string;
readonly hooksPath: string;
readonly config: InstallConfig;
}
export interface PreparedManagedInstallation {
readonly displayName: string;
readonly hooksDirectoryLabel: string;
readonly paths: ManagedRuntimePaths;
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;
}
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 validateInstallConfig(config: InstallConfig, agentKey: string): void {
for (const [field, value] of [
['agentName', config.agentName],
['orgId', config.orgId],
['agentId', config.agentId],
['kid', config.kid],
['privateKey', config.privateKey],
['baseUrl', config.baseUrl],
['guardScriptPath', config.guardScriptPath],
['hookScriptPath', config.hookScriptPath],
] as const) {
if (typeof value !== 'string' || !value) throw new Error(`${field} is required`);
}
if (config.token !== undefined && (typeof config.token !== 'string' || !config.token)) {
throw new Error('token must be a non-empty string when provided');
}
if (config.agentName !== agentKey) {
throw new Error(`${agentKey} installation requires agentName ${agentKey}`);
}
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');
}
if (baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
throw new Error('baseUrl must exclude credentials, query parameters, and fragments');
}
}
export function managedRuntimePaths(
config: InstallConfig,
agentKey: string,
guardScript: string,
auditScript: string,
): ManagedRuntimePaths {
validateInstallConfig(config, agentKey);
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId);
const guardPath = path.join(agentDirectory, guardScript);
const auditPath = path.join(agentDirectory, auditScript);
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: ManagedRuntimePaths,
agentKey: string,
): Promise<void> {
if (!await inspectPhysicalDirectory(paths.runtimeRoot, 'Elydora runtime directory')) return;
if (!await inspectPhysicalDirectory(paths.agentDirectory, 'Elydora agent runtime directory')) 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'),
readPhysicalFile(path.join(paths.agentDirectory, 'error.log'), 'Elydora error log'),
]);
if (!config) {
if (artifacts.some(Boolean)) {
throw new Error(
`Elydora runtime identity cannot be verified without config.json: ${paths.agentDirectory}`,
);
}
return;
}
const label = `Elydora runtime config at ${paths.configPath}`;
const value = parseStrictJsonObject(config.contents, label);
if (value.agent_name !== agentKey
|| !sameAgentId(value.agent_id, path.basename(paths.agentDirectory))) {
throw new Error(
`Elydora runtime config identity does not match ${agentKey} agent ${path.basename(paths.agentDirectory)}: ${paths.configPath}`,
);
}
}
export async function preflightManagedInstallation(
spec: ManagedPreflightSpec,
guardScript: string,
auditScript: string,
): Promise<ManagedRuntimePaths> {
const paths = managedRuntimePaths(spec.config, spec.agentKey, guardScript, auditScript);
await inspectPhysicalDirectory(path.dirname(spec.hooksPath), spec.hooksDirectoryLabel);
await validateRuntimeIdentity(paths, spec.agentKey);
return paths;
}
function runtimeConfig(config: InstallConfig, agentKey: string): string {
const value = {
org_id: config.orgId,
agent_id: config.agentId,
kid: config.kid,
base_url: config.baseUrl,
...(config.token ? { token: config.token } : {}),
agent_name: agentKey,
};
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,
maximumBytes,
original: await readPhysicalFile(filePath, label, maximumBytes),
};
}
export async function prepareManagedInstallation(
spec: ManagedInstallationSpec,
guardScript: string,
auditScript: string,
): Promise<PreparedManagedInstallation> {
const paths = await preflightManagedInstallation(spec, guardScript, auditScript);
const changes = await Promise.all([
prepareChange(
paths.guardPath,
'Elydora guard runtime',
generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions),
0o700,
),
prepareChange(
paths.configPath,
'Elydora runtime config',
runtimeConfig(spec.config, spec.agentKey),
0o600,
MAX_CONFIG_BYTES,
),
prepareChange(
paths.keyPath,
'Elydora private key',
spec.config.privateKey,
0o600,
MAX_SECRET_BYTES,
),
prepareChange(
paths.auditPath,
'Elydora audit runtime',
generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions),
0o700,
),
prepareChange(spec.hooksPath, spec.hooksLabel, spec.hooksSource, 0o600),
]);
const hooksSource = changes.at(-1)!.original?.contents;
if (hooksSource !== spec.expectedHooksSource) {
throw new Error(`${spec.hooksLabel} changed before installation: ${spec.hooksPath}`);
}
await validateRuntimeIdentity(paths, spec.agentKey);
return {
displayName: spec.displayName,
hooksDirectoryLabel: spec.hooksDirectoryLabel,
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, displayName: string): Promise<void> {
const current = await readPhysicalFile(change.filePath, change.label, change.maximumBytes);
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 ${displayName} 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, displayName: string): Promise<StagedChange> {
await assertUnchanged(change, displayName);
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,
displayName: string,
renameFile: RenameFile,
): Promise<void> {
await assertUnchanged(staged.change, displayName);
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 ensureManagedDirectory(directory: string, label: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
if (!await inspectPhysicalDirectory(directory, label)) {
throw new Error(`${label} is missing: ${directory}`);
}
}
export async function commitManagedInstallation(
prepared: PreparedManagedInstallation,
renameFile: RenameFile = fsp.rename,
): Promise<void> {
await ensurePrivateDirectory(prepared.paths.runtimeRoot);
await ensurePrivateDirectory(prepared.paths.agentDirectory);
await ensureManagedDirectory(
path.dirname(prepared.changes.at(-1)!.filePath),
prepared.hooksDirectoryLabel,
);
const staged: StagedChange[] = [];
try {
for (const change of prepared.changes) {
staged.push(await stage(change, prepared.displayName));
}
for (const item of staged) await commit(item, prepared.displayName, 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 ${prepared.displayName} 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 ${prepared.displayName} installation transaction files`,
);
}
}
+114 -14
View File
@@ -1,18 +1,31 @@
export const PROTECTED_RUNTIME_READER: string = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024;
const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024;
const MAX_PROTECTED_LOG_BYTES = 2 * 1024 * 1024;
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) {
const before = fs.lstatSync(filePath);
if (!before.isFile() || before.isSymbolicLink()) {
function hasRuntimeErrorCode(error, code) {
return error && typeof error === 'object' && error.code === code;
}
function validateProtectedMetadata(metadata, filePath, label, maxBytes) {
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error(label + ' path is not a physical file: ' + filePath);
}
if (before.size > maxBytes) {
if (metadata.size > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) {
if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
throw new Error(label + ' permissions are too broad: ' + filePath);
}
}
function inspectProtectedFile(filePath, label, maxBytes) {
const metadata = fs.lstatSync(filePath);
validateProtectedMetadata(metadata, filePath, label, maxBytes);
return metadata;
}
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) {
const before = inspectProtectedFile(filePath, label, maxBytes);
let flags = fs.constants.O_RDONLY;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
flags |= fs.constants.O_NOFOLLOW;
@@ -20,15 +33,7 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE
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);
}
validateProtectedMetadata(after, filePath, label, maxBytes);
if (before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(label + ' changed while opening: ' + filePath);
}
@@ -41,4 +46,99 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE
fs.closeSync(descriptor);
}
}
function openProtectedAppend(filePath, label, maxBytes) {
for (let attempt = 0; attempt < 3; attempt += 1) {
let before;
try {
before = inspectProtectedFile(filePath, label, maxBytes);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
let createFlags = fs.constants.O_WRONLY | fs.constants.O_APPEND |
fs.constants.O_CREAT | fs.constants.O_EXCL;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
createFlags |= fs.constants.O_NOFOLLOW;
}
try {
const descriptor = fs.openSync(filePath, createFlags, 0o600);
const metadata = fs.fstatSync(descriptor);
validateProtectedMetadata(metadata, filePath, label, maxBytes);
return descriptor;
} catch (createError) {
if (hasRuntimeErrorCode(createError, 'EEXIST')) continue;
throw createError;
}
}
let flags = fs.constants.O_WRONLY | fs.constants.O_APPEND;
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);
validateProtectedMetadata(after, filePath, label, maxBytes);
if (before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(label + ' changed while opening: ' + filePath);
}
return descriptor;
} catch (error) {
fs.closeSync(descriptor);
throw error;
}
}
throw new Error(label + ' changed repeatedly while opening: ' + filePath);
}
function appendProtectedText(filePath, label, text, maxBytes = MAX_PROTECTED_LOG_BYTES) {
const descriptor = openProtectedAppend(filePath, label, maxBytes);
try {
const metadata = fs.fstatSync(descriptor);
const bytes = Buffer.byteLength(text, 'utf-8');
if (metadata.size + bytes > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
fs.writeFileSync(descriptor, text, 'utf-8');
fs.fsyncSync(descriptor);
if (process.platform !== 'win32') fs.fchmodSync(descriptor, 0o600);
} finally {
fs.closeSync(descriptor);
}
}
function writeProtectedJson(filePath, label, value) {
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded, 'utf-8') > MAX_PROTECTED_CONFIG_BYTES) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
const temporaryPath = filePath + '.' + process.pid + '.' +
crypto.randomBytes(8).toString('hex') + '.tmp';
let descriptor;
try {
descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
fs.writeFileSync(descriptor, encoded, 'utf-8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
if (process.platform !== 'win32') fs.chmodSync(temporaryPath, 0o600);
try {
inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
}
try {
fs.renameSync(temporaryPath, filePath);
} catch (error) {
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES);
fs.unlinkSync(filePath);
fs.renameSync(temporaryPath, filePath);
}
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
try { fs.unlinkSync(temporaryPath); } catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
}
}
}
`;
+57
View File
@@ -0,0 +1,57 @@
import {
parse,
parseTree,
printParseErrorCode,
type Node,
type ParseError,
} from 'jsonc-parser';
export type JsonObject = Record<string, unknown>;
export function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
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;
}