Files
Elydora-Node-SDK/src/plugins/codex-contract.ts
T

374 lines
13 KiB
TypeScript

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),
}));
}