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

315 lines
11 KiB
TypeScript

import os from 'node:os';
import path from 'node:path';
import type { JsonObject } from './strict-json.js';
export const AGENT_KEY = 'droid';
export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10;
export const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'] as const;
const HANDLER_KEYS = ['command', 'timeout', 'type'];
const GROUP_KEYS = ['hooks', 'matcher'];
const WINDOWS_EXIT_SUFFIX = '; exit $LASTEXITCODE';
export type ToolEvent = typeof TOOL_EVENTS[number];
export interface DroidHandler extends JsonObject {
readonly type: 'command';
readonly command: string;
readonly timeout?: number;
}
export interface DroidGroup extends JsonObject {
readonly matcher?: string;
readonly commandRegex?: string;
readonly hooks: DroidHandler[];
}
export interface DroidHookMap extends JsonObject {
readonly PreToolUse?: DroidGroup[];
readonly PostToolUse?: DroidGroup[];
}
export interface RuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedRemoval {
readonly event: ToolEvent;
readonly groupIndex: number;
readonly handlerIndexes: number[];
readonly removeGroup: boolean;
}
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function buildCommand(scriptPath: string): string {
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
throw new Error('Factory Droid hook commands require absolute executable and script paths');
}
return process.platform === 'win32'
? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}${WINDOWS_EXIT_SUFFIX}`
: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
}
export function buildGroup(scriptPath: string): DroidGroup {
return {
matcher: '*',
hooks: [{
type: 'command',
command: buildCommand(scriptPath),
timeout: HOOK_TIMEOUT_SECONDS,
}],
};
}
function validateRegex(value: string, label: string, allowWildcard: boolean): void {
if (allowWildcard && (value === '*' || value === '')) return;
try {
new RegExp(value);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${label} must be a valid regular expression: ${message}`);
}
}
function validateHandler(
value: unknown,
label: string,
groupIndex: number,
handlerIndex: number,
): DroidHandler {
const location = `${label}[${groupIndex}].hooks[${handlerIndex}]`;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${location} must be an object`);
}
const handler = value as JsonObject;
if (handler.type !== 'command') throw new Error(`${location} type must be "command"`);
if (typeof handler.command !== 'string' || handler.command.trim().length === 0) {
throw new Error(`${location} command must be a non-empty string`);
}
if (handler.timeout !== undefined
&& (typeof handler.timeout !== 'number'
|| !Number.isFinite(handler.timeout)
|| handler.timeout <= 0)) {
throw new Error(`${location} timeout must be a positive finite number`);
}
return handler as DroidHandler;
}
function validateGroup(value: unknown, label: string, groupIndex: number): DroidGroup {
const location = `${label}[${groupIndex}]`;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${location} must be an object`);
}
const group = value as JsonObject;
if (group.matcher !== undefined) {
if (typeof group.matcher !== 'string') throw new Error(`${location} matcher must be a string`);
validateRegex(group.matcher, `${location} matcher`, true);
}
if (group.commandRegex !== undefined) {
if (typeof group.commandRegex !== 'string') {
throw new Error(`${location} commandRegex must be a string`);
}
validateRegex(group.commandRegex, `${location} commandRegex`, false);
}
if (!Array.isArray(group.hooks)) throw new Error(`${location} must contain a hooks array`);
return {
...group,
hooks: group.hooks.map(
(handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex),
),
} as DroidGroup;
}
export function readHookMap(value: unknown, label: string): DroidHookMap {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${label} must contain a JSON object`);
}
const hooks: DroidHookMap = {};
for (const [event, groups] of Object.entries(value)) {
if (!Array.isArray(groups)) throw new Error(`${label} field "${event}" must be an array`);
hooks[event] = groups.map(
(group, groupIndex) => validateGroup(group, `${label} field "${event}"`, groupIndex),
);
}
return hooks;
}
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;
}
const POSIX_APOSTROPHE = `'"'"'`;
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
let value = '';
for (let index = start + 1; index < command.length;) {
if (command.startsWith(POSIX_APOSTROPHE, index)) {
value += "'";
index += POSIX_APOSTROPHE.length;
continue;
}
if (command[index] === "'") return { value, next: index + 1 };
value += command[index];
index += 1;
}
return undefined;
}
function parseTwoArguments(
command: string,
start: number,
readArgument: (command: string, start: number) => ParsedArgument | undefined,
): readonly [string, string] | undefined {
const executable = readArgument(command, start);
if (!executable || command[executable.next] !== ' ') return undefined;
const script = readArgument(command, executable.next + 1);
if (!script || script.next !== command.length || !executable.value || !script.value) return undefined;
return [executable.value, script.value];
}
function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined {
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
return match ? [match[1], match[2]] : 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) !== WINDOWS_EXIT_SUFFIX
|| !executable.value
|| !script.value) return undefined;
return [executable.value, script.value];
}
function parseGeneratedCommand(
command: string,
includeLegacy: boolean,
): readonly [string, string] | undefined {
if (process.platform !== 'win32') return parseTwoArguments(command, 0, readPosixArgument);
const current = parsePowerShellCommand(command);
return current ?? (includeLegacy ? parseLegacyWindowsCommand(command) : undefined);
}
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;
}
export function sameAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
export function managedAgentId(
handler: DroidHandler,
scriptName: string,
includeLegacy = false,
): string | undefined {
if (Object.keys(handler).sort().join('\0') !== HANDLER_KEYS.join('\0')
|| handler.type !== 'command'
|| handler.timeout !== HOOK_TIMEOUT_SECONDS) return undefined;
const parsed = parseGeneratedCommand(handler.command, includeLegacy);
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
const scriptPath = parsed[1];
if (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 exactOwnedGroup(group: DroidGroup, managedIndexes: number[]): boolean {
return Object.keys(group).sort().join('\0') === GROUP_KEYS.join('\0')
&& group.matcher === '*'
&& managedIndexes.length > 0
&& managedIndexes.length === group.hooks.length;
}
export function managedRemovals(hooks: DroidHookMap, agentId?: string): ManagedRemoval[] {
const removals: ManagedRemoval[] = [];
for (const [event, scriptName] of [
['PreToolUse', GUARD_SCRIPT],
['PostToolUse', AUDIT_SCRIPT],
] as const) {
const groups = hooks[event] ?? [];
groups.forEach((group, groupIndex) => {
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
const managedId = managedAgentId(handler, scriptName, true);
return managedId && (agentId === undefined || sameAgentId(managedId, agentId))
? [handlerIndex]
: [];
});
if (handlerIndexes.length > 0) {
removals.push({
event,
groupIndex,
handlerIndexes,
removeGroup: exactOwnedGroup(group, handlerIndexes),
});
}
});
}
return removals;
}
function configuredIds(groups: DroidGroup[], scriptName: string): Set<string> {
const ids = new Set<string>();
for (const group of groups) {
if (Object.keys(group).sort().join('\0') !== GROUP_KEYS.join('\0')
|| group.matcher !== '*'
|| group.hooks.length !== 1) continue;
const agentId = managedAgentId(group.hooks[0], scriptName);
if (agentId) ids.add(agentId);
}
return ids;
}
export function runtimeContracts(hooks: DroidHookMap): RuntimeContract[] {
const guards = configuredIds(hooks.PreToolUse ?? [], GUARD_SCRIPT);
const audits = configuredIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), '.elydora');
return [...guards]
.filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId)))
.map((agentId) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}