fix(node): sync hardened Copilot hooks
This commit is contained in:
1 parent
ab54b75020
commit
68918e22d7
6 files changed
+985
-138
No files matched your search
+1
-1
@@ -95,7 +95,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera
|
|||||||
| Kiro CLI | `kirocli` |
|
| Kiro CLI | `kirocli` |
|
||||||
| Kiro IDE | `kiroide` |
|
| Kiro IDE | `kiroide` |
|
||||||
| OpenCode | `opencode` |
|
| OpenCode | `opencode` |
|
||||||
| Copilot CLI | `copilot` |
|
| GitHub Copilot CLI | `copilot` |
|
||||||
| Letta Code | `letta` |
|
| Letta Code | `letta` |
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const AGENT_KEY = 'copilot';
|
||||||
|
export const GUARD_SCRIPT = 'guard.js';
|
||||||
|
export const AUDIT_SCRIPT = 'hook.js';
|
||||||
|
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||||
|
export const CONFIG_FILE = 'elydora-audit.json';
|
||||||
|
|
||||||
|
export type JsonObject = Record<string, unknown>;
|
||||||
|
export type CopilotHooks = Record<string, JsonObject[]>;
|
||||||
|
|
||||||
|
export interface CopilotDocument {
|
||||||
|
readonly exists: boolean;
|
||||||
|
readonly filePath: string;
|
||||||
|
readonly root: JsonObject;
|
||||||
|
readonly hooks: CopilotHooks;
|
||||||
|
readonly raw?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopilotSources {
|
||||||
|
readonly user: CopilotDocument;
|
||||||
|
readonly legacy?: CopilotDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedDocument {
|
||||||
|
readonly document: CopilotDocument;
|
||||||
|
readonly changed: boolean;
|
||||||
|
readonly next?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeContract {
|
||||||
|
readonly agentId: string;
|
||||||
|
readonly guardPath: string;
|
||||||
|
readonly auditPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedArgument {
|
||||||
|
readonly value: string;
|
||||||
|
readonly next: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObject(value: unknown): value is JsonObject {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePath(left: string, right: string): boolean {
|
||||||
|
const normalizedLeft = path.resolve(left);
|
||||||
|
const normalizedRight = path.resolve(right);
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||||
|
: normalizedLeft === normalizedRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAgentId(left: string, right: string): boolean {
|
||||||
|
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePosix(value: string): string {
|
||||||
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePowerShell(value: string): string {
|
||||||
|
return `'${value.replaceAll("'", "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHandler(scriptPath: string): JsonObject {
|
||||||
|
return {
|
||||||
|
type: 'command',
|
||||||
|
bash: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`,
|
||||||
|
powershell: `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`,
|
||||||
|
timeoutSec: HOOK_TIMEOUT_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parseGeneratedBash(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 parseGeneratedPowerShell(command: unknown): readonly [string, string] | undefined {
|
||||||
|
if (typeof command !== 'string' || !command.startsWith('& ')) return undefined;
|
||||||
|
const executable = readPowerShellArgument(command, 2);
|
||||||
|
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||||
|
const script = readPowerShellArgument(command, executable.next + 1);
|
||||||
|
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
|
||||||
|
return [executable.value, script.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyCommand(command: unknown): string | undefined {
|
||||||
|
if (typeof command !== 'string') return undefined;
|
||||||
|
return /^node(?:\.exe)?\s+"([^"]+)"$/i.exec(command)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedScriptPath(handler: JsonObject): string | undefined {
|
||||||
|
if (handler.type !== 'command') return undefined;
|
||||||
|
const bash = parseGeneratedBash(handler.bash);
|
||||||
|
const powershell = parseGeneratedPowerShell(handler.powershell);
|
||||||
|
if (handler.timeoutSec === HOOK_TIMEOUT_SECONDS && bash && powershell) {
|
||||||
|
if (samePath(bash[0], process.execPath)
|
||||||
|
&& samePath(powershell[0], process.execPath)
|
||||||
|
&& samePath(bash[1], powershell[1])) return bash[1];
|
||||||
|
}
|
||||||
|
const legacyBash = parseLegacyCommand(handler.bash);
|
||||||
|
const legacyPowerShell = parseLegacyCommand(handler.powershell);
|
||||||
|
return legacyBash && legacyPowerShell && samePath(legacyBash, legacyPowerShell)
|
||||||
|
? legacyBash
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedAgentId(handler: JsonObject, scriptName: string): string | undefined {
|
||||||
|
const scriptPath = managedScriptPath(handler);
|
||||||
|
if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined;
|
||||||
|
const agentDirectory = path.dirname(scriptPath);
|
||||||
|
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined;
|
||||||
|
const agentId = path.basename(agentDirectory);
|
||||||
|
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHooks(value: unknown, label: string): CopilotHooks {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
|
||||||
|
const hooks: CopilotHooks = {};
|
||||||
|
for (const [event, handlers] of Object.entries(value)) {
|
||||||
|
if (!Array.isArray(handlers)) throw new Error(`${label} field "hooks.${event}" must be an array`);
|
||||||
|
hooks[event] = handlers.map((handler, index) => {
|
||||||
|
if (!isObject(handler)) throw new Error(`${label} handler hooks.${event}[${index}] must be an object`);
|
||||||
|
return handler;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDocument(filePath: string, raw: string, label: string): CopilotDocument {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Failed to parse ${label} at ${filePath}: ${message}`, { cause: error });
|
||||||
|
}
|
||||||
|
if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`);
|
||||||
|
if (value.version !== 1) throw new Error(`${label} at ${filePath} must declare version 1`);
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
filePath,
|
||||||
|
root: value,
|
||||||
|
hooks: validateHooks(value.hooks, label),
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocument(filePath: string): CopilotDocument {
|
||||||
|
return { exists: false, filePath, root: {}, hooks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeManagedHooks(hooks: CopilotHooks, agentId?: string): CopilotHooks {
|
||||||
|
const next: CopilotHooks = { ...hooks };
|
||||||
|
for (const [event, scriptName] of [
|
||||||
|
['preToolUse', GUARD_SCRIPT],
|
||||||
|
['postToolUse', AUDIT_SCRIPT],
|
||||||
|
] as const) {
|
||||||
|
const handlers = (next[event] ?? []).filter((handler) => {
|
||||||
|
const managedId = managedAgentId(handler, scriptName);
|
||||||
|
return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId));
|
||||||
|
});
|
||||||
|
if (handlers.length > 0) next[event] = handlers;
|
||||||
|
else delete next[event];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyOwnedDocument(root: JsonObject, hooks: CopilotHooks): boolean {
|
||||||
|
return Object.keys(hooks).length === 0
|
||||||
|
&& Object.keys(root).every((key) => key === 'version' || key === 'hooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDocument(
|
||||||
|
document: CopilotDocument,
|
||||||
|
hooks: CopilotHooks,
|
||||||
|
): RenderedDocument {
|
||||||
|
if (!document.exists && Object.keys(hooks).length === 0) {
|
||||||
|
return { document, changed: false };
|
||||||
|
}
|
||||||
|
if (document.exists && isEmptyOwnedDocument(document.root, hooks)) {
|
||||||
|
return { document, changed: true };
|
||||||
|
}
|
||||||
|
const root: JsonObject = { ...document.root, version: 1 };
|
||||||
|
if (Object.keys(hooks).length > 0) root.hooks = hooks;
|
||||||
|
else delete root.hooks;
|
||||||
|
const next = JSON.stringify(root, null, 2) + '\n';
|
||||||
|
return { document, changed: next !== document.raw, next };
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedIds(handlers: JsonObject[], scriptName: string): Set<string> {
|
||||||
|
return new Set(handlers.flatMap((handler) => {
|
||||||
|
const agentId = managedAgentId(handler, scriptName);
|
||||||
|
return agentId ? [agentId] : [];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeContracts(hooks: CopilotHooks): RuntimeContract[] {
|
||||||
|
const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT);
|
||||||
|
const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT);
|
||||||
|
const root = path.join(os.homedir(), '.elydora');
|
||||||
|
return [...guards]
|
||||||
|
.filter((agentId) => [...audits].some((auditId) => sameAgentId(agentId, auditId)))
|
||||||
|
.map((agentId) => ({
|
||||||
|
agentId,
|
||||||
|
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
||||||
|
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
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 CopilotDocument,
|
||||||
|
type CopilotSources,
|
||||||
|
type JsonObject,
|
||||||
|
type RenderedDocument,
|
||||||
|
type RuntimeContract,
|
||||||
|
createDocument,
|
||||||
|
isObject,
|
||||||
|
parseDocument,
|
||||||
|
} from './copilot-contract.js';
|
||||||
|
|
||||||
|
interface FileChange {
|
||||||
|
readonly filePath: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly original?: string;
|
||||||
|
readonly next?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StagedChange {
|
||||||
|
readonly change: FileChange;
|
||||||
|
readonly tempPath?: string;
|
||||||
|
readonly rollbackPath?: string;
|
||||||
|
committed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasErrorCode(error: unknown, code: string): boolean {
|
||||||
|
return isObject(error) && error.code === code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paths(): { readonly user: string; readonly legacy: string } {
|
||||||
|
const override = process.env.COPILOT_HOME?.trim();
|
||||||
|
const copilotHome = override || path.join(os.homedir(), '.copilot');
|
||||||
|
return {
|
||||||
|
user: path.join(copilotHome, 'hooks', CONFIG_FILE),
|
||||||
|
legacy: path.join(process.cwd(), '.github', 'hooks', 'hooks.json'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOptional(filePath: string, label: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
return 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDocument(filePath: string, label: string): Promise<CopilotDocument | undefined> {
|
||||||
|
const raw = await readOptional(filePath, label);
|
||||||
|
return raw === undefined ? undefined : parseDocument(filePath, raw, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSources(): Promise<CopilotSources> {
|
||||||
|
const configPaths = paths();
|
||||||
|
const [user, legacy] = await Promise.all([
|
||||||
|
readDocument(configPaths.user, 'GitHub Copilot user hooks'),
|
||||||
|
readDocument(configPaths.legacy, 'GitHub Copilot legacy project hooks'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
user: user ?? createDocument(configPaths.user),
|
||||||
|
legacy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeFor(rendered: RenderedDocument): FileChange | undefined {
|
||||||
|
if (!rendered.changed) return undefined;
|
||||||
|
return {
|
||||||
|
filePath: rendered.document.filePath,
|
||||||
|
label: 'GitHub Copilot hook source',
|
||||||
|
original: rendered.document.raw,
|
||||||
|
next: rendered.next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkOptional(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeExclusive(filePath: string, contents: string, label: string): Promise<void> {
|
||||||
|
let handle: FileHandle | undefined;
|
||||||
|
try {
|
||||||
|
handle = await fsp.open(filePath, 'wx', 0o600);
|
||||||
|
await handle.writeFile(contents, 'utf-8');
|
||||||
|
await handle.sync();
|
||||||
|
await handle.close();
|
||||||
|
handle = undefined;
|
||||||
|
} catch (error) {
|
||||||
|
const failures = [asError(error)];
|
||||||
|
if (handle) {
|
||||||
|
try {
|
||||||
|
await handle.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
failures.push(asError(closeError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await unlinkOptional(filePath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
failures.push(asError(cleanupError));
|
||||||
|
}
|
||||||
|
if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`);
|
||||||
|
throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertUnchanged(change: FileChange): Promise<void> {
|
||||||
|
const current = await readOptional(change.filePath, change.label);
|
||||||
|
if (current !== change.original) {
|
||||||
|
throw new Error(`${change.label} changed during Copilot hook update: ${change.filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stage(change: FileChange): Promise<StagedChange> {
|
||||||
|
await assertUnchanged(change);
|
||||||
|
const directory = path.dirname(change.filePath);
|
||||||
|
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const token = randomUUID();
|
||||||
|
const tempPath = change.next === undefined
|
||||||
|
? undefined
|
||||||
|
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
|
||||||
|
const rollbackPath = change.original === undefined
|
||||||
|
? undefined
|
||||||
|
: path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`);
|
||||||
|
try {
|
||||||
|
if (tempPath) await writeExclusive(tempPath, change.next!, change.label);
|
||||||
|
if (rollbackPath && change.next !== undefined) {
|
||||||
|
await writeExclusive(rollbackPath, change.original!, `${change.label} rollback`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const failures = [asError(error)];
|
||||||
|
for (const stagedPath of [tempPath, rollbackPath]) {
|
||||||
|
if (!stagedPath) continue;
|
||||||
|
try {
|
||||||
|
await unlinkOptional(stagedPath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
failures.push(asError(cleanupError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { change, tempPath, rollbackPath, committed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(staged: StagedChange): Promise<void> {
|
||||||
|
await assertUnchanged(staged.change);
|
||||||
|
if (staged.change.next === undefined) {
|
||||||
|
if (!staged.rollbackPath) throw new Error(`Missing rollback path for ${staged.change.label}`);
|
||||||
|
await fsp.rename(staged.change.filePath, staged.rollbackPath);
|
||||||
|
} else {
|
||||||
|
if (!staged.tempPath) throw new Error(`Missing staged file for ${staged.change.label}`);
|
||||||
|
await fsp.rename(staged.tempPath, staged.change.filePath);
|
||||||
|
}
|
||||||
|
staged.committed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollback(staged: StagedChange): Promise<void> {
|
||||||
|
if (!staged.committed) return;
|
||||||
|
if (staged.change.original === undefined) {
|
||||||
|
await unlinkOptional(staged.change.filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
|
||||||
|
await fsp.rename(staged.rollbackPath, staged.change.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(staged: StagedChange): Promise<void> {
|
||||||
|
const files = [staged.tempPath, staged.rollbackPath].filter(
|
||||||
|
(filePath): filePath is string => filePath !== undefined,
|
||||||
|
);
|
||||||
|
await Promise.all(files.map(unlinkOptional));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeDocuments(rendered: RenderedDocument[]): Promise<void> {
|
||||||
|
const changes = rendered.flatMap((document) => {
|
||||||
|
const change = changeFor(document);
|
||||||
|
return change ? [change] : [];
|
||||||
|
});
|
||||||
|
const staged: StagedChange[] = [];
|
||||||
|
try {
|
||||||
|
for (const change of changes) staged.push(await stage(change));
|
||||||
|
for (const item of staged) await commit(item);
|
||||||
|
} catch (error) {
|
||||||
|
const failures = [asError(error)];
|
||||||
|
for (const item of [...staged].reverse()) {
|
||||||
|
try {
|
||||||
|
await rollback(item);
|
||||||
|
} 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, `Write GitHub Copilot hook sources: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
const cleanupErrors: Error[] = [];
|
||||||
|
for (const item of staged) {
|
||||||
|
try {
|
||||||
|
await cleanup(item);
|
||||||
|
} catch (error) {
|
||||||
|
cleanupErrors.push(asError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleanupErrors.length > 0) {
|
||||||
|
throw new AggregateError(cleanupErrors, 'Clean GitHub Copilot hook transaction files');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regularFileExists(filePath: string, label: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await fsp.stat(filePath)).isFile();
|
||||||
|
} catch (error) {
|
||||||
|
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false;
|
||||||
|
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
|
||||||
|
const raw = await readOptional(filePath, 'Elydora runtime config');
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isObject(value)) throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||||
|
for (const contract of contracts) {
|
||||||
|
const runtimeConfig = await readRuntimeConfig(
|
||||||
|
path.join(path.dirname(contract.guardPath), 'config.json'),
|
||||||
|
);
|
||||||
|
if (!runtimeConfig
|
||||||
|
|| runtimeConfig.agent_name !== AGENT_KEY
|
||||||
|
|| runtimeConfig.agent_id !== contract.agentId) continue;
|
||||||
|
const files = await Promise.all([
|
||||||
|
regularFileExists(contract.guardPath, 'Elydora guard runtime'),
|
||||||
|
regularFileExists(contract.auditPath, 'Elydora audit runtime'),
|
||||||
|
]);
|
||||||
|
if (files.every(Boolean)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,164 +1,89 @@
|
|||||||
import fsp from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import {
|
||||||
|
AGENT_KEY,
|
||||||
|
AUDIT_SCRIPT,
|
||||||
|
GUARD_SCRIPT,
|
||||||
|
buildHandler,
|
||||||
|
removeManagedHooks,
|
||||||
|
renderDocument,
|
||||||
|
runtimeContracts,
|
||||||
|
} from './copilot-contract.js';
|
||||||
|
import {
|
||||||
|
readSources,
|
||||||
|
requireRuntime,
|
||||||
|
runtimeFilesExist,
|
||||||
|
writeDocuments,
|
||||||
|
} from './copilot-io.js';
|
||||||
import { SUPPORTED_AGENTS } from './registry.js';
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
const AGENT_KEY = 'copilot';
|
|
||||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
function resolveConfigDir(): string {
|
function mergedContracts(
|
||||||
return path.join(process.cwd(), entry.configDir);
|
userHooks: Parameters<typeof runtimeContracts>[0],
|
||||||
}
|
legacyHooks?: Parameters<typeof runtimeContracts>[0],
|
||||||
|
): ReturnType<typeof runtimeContracts> {
|
||||||
function resolveConfigPath(): string {
|
const contracts = [
|
||||||
return path.join(resolveConfigDir(), entry.configFile);
|
...runtimeContracts(userHooks),
|
||||||
}
|
...runtimeContracts(legacyHooks ?? {}),
|
||||||
|
];
|
||||||
function buildHookCommand(scriptPath: string): string {
|
const unique = new Map(contracts.map((contract) => [contract.agentId, contract]));
|
||||||
return `node "${scriptPath}"`;
|
return [...unique.values()];
|
||||||
}
|
|
||||||
|
|
||||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
|
||||||
if (!cmd.includes('elydora')) return false;
|
|
||||||
if (agentId) {
|
|
||||||
return cmd.includes(agentId);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
|
||||||
return arr.filter((h) => {
|
|
||||||
if (typeof h.bash === 'string' && isElydoraCommand(h.bash, agentId)) return false;
|
|
||||||
if (typeof h.powershell === 'string' && isElydoraCommand(h.powershell, agentId)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const copilotPlugin: AgentPlugin = {
|
export const copilotPlugin: AgentPlugin = {
|
||||||
async install(config: InstallConfig): Promise<void> {
|
async install(config: InstallConfig): Promise<void> {
|
||||||
const configDir = resolveConfigDir();
|
if (!config.agentId) throw new Error('agentId is required');
|
||||||
await fsp.mkdir(configDir, { recursive: true });
|
const sources = await readSources();
|
||||||
|
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
|
||||||
|
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
|
||||||
|
|
||||||
const configPath = resolveConfigPath();
|
const userHooks = removeManagedHooks(sources.user.hooks);
|
||||||
let settings: Record<string, unknown> = { version: 1 };
|
userHooks.preToolUse = [
|
||||||
|
...(userHooks.preToolUse ?? []),
|
||||||
|
buildHandler(config.guardScriptPath),
|
||||||
|
];
|
||||||
|
userHooks.postToolUse = [
|
||||||
|
...(userHooks.postToolUse ?? []),
|
||||||
|
buildHandler(config.hookScriptPath),
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
const rendered = [renderDocument(sources.user, userHooks)];
|
||||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
if (sources.legacy) {
|
||||||
settings = JSON.parse(raw);
|
rendered.push(renderDocument(
|
||||||
} catch {
|
sources.legacy,
|
||||||
// Start fresh
|
removeManagedHooks(sources.legacy.hooks),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
await writeDocuments(rendered);
|
||||||
settings.version = 1;
|
console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`);
|
||||||
|
|
||||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
||||||
settings.hooks = {};
|
|
||||||
}
|
|
||||||
const hooks = settings.hooks as Record<string, unknown>;
|
|
||||||
|
|
||||||
// --- preToolUse (guard — freeze enforcement) ---
|
|
||||||
if (!Array.isArray(hooks.preToolUse)) {
|
|
||||||
hooks.preToolUse = [];
|
|
||||||
}
|
|
||||||
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
|
||||||
preFiltered.push({
|
|
||||||
type: 'command',
|
|
||||||
bash: buildHookCommand(config.guardScriptPath),
|
|
||||||
powershell: buildHookCommand(config.guardScriptPath),
|
|
||||||
timeoutSec: 5,
|
|
||||||
});
|
|
||||||
hooks.preToolUse = preFiltered;
|
|
||||||
|
|
||||||
// --- postToolUse (audit logging) ---
|
|
||||||
if (!Array.isArray(hooks.postToolUse)) {
|
|
||||||
hooks.postToolUse = [];
|
|
||||||
}
|
|
||||||
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
|
||||||
postFiltered.push({
|
|
||||||
type: 'command',
|
|
||||||
bash: buildHookCommand(config.hookScriptPath),
|
|
||||||
powershell: buildHookCommand(config.hookScriptPath),
|
|
||||||
timeoutSec: 5,
|
|
||||||
});
|
|
||||||
hooks.postToolUse = postFiltered;
|
|
||||||
|
|
||||||
settings.hooks = hooks;
|
|
||||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(agentId?: string): Promise<void> {
|
async uninstall(agentId?: string): Promise<void> {
|
||||||
const configPath = resolveConfigPath();
|
const sources = await readSources();
|
||||||
|
const rendered = [
|
||||||
let settings: Record<string, unknown>;
|
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)),
|
||||||
try {
|
];
|
||||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
if (sources.legacy) {
|
||||||
settings = JSON.parse(raw);
|
rendered.push(renderDocument(
|
||||||
} catch {
|
sources.legacy,
|
||||||
return;
|
removeManagedHooks(sources.legacy.hooks, agentId),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
await writeDocuments(rendered);
|
||||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
|
||||||
if (!hooks) return;
|
|
||||||
|
|
||||||
if (Array.isArray(hooks.preToolUse)) {
|
|
||||||
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
|
|
||||||
}
|
|
||||||
if (Array.isArray(hooks.postToolUse)) {
|
|
||||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async status(): Promise<PluginStatus> {
|
async status(): Promise<PluginStatus> {
|
||||||
const configPath = resolveConfigPath();
|
const sources = await readSources();
|
||||||
|
const contracts = mergedContracts(sources.user.hooks, sources.legacy?.hooks);
|
||||||
let hookConfigured = false;
|
const hookConfigured = contracts.length > 0;
|
||||||
let hookScriptPath = '';
|
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
|
||||||
try {
|
|
||||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
|
||||||
const settings = JSON.parse(raw);
|
|
||||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
|
||||||
if (hooks) {
|
|
||||||
const check = (arr: unknown) =>
|
|
||||||
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
|
||||||
(h) =>
|
|
||||||
(typeof h.bash === 'string' && h.bash.includes('elydora')) ||
|
|
||||||
(typeof h.powershell === 'string' && h.powershell.includes('elydora')),
|
|
||||||
);
|
|
||||||
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
|
||||||
|
|
||||||
// Extract hook script path from postToolUse bash/powershell command
|
|
||||||
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
|
|
||||||
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
|
|
||||||
const cmd = (h.bash || h.powershell) as string;
|
|
||||||
if (cmd && cmd.includes('elydora')) {
|
|
||||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Config not readable
|
|
||||||
}
|
|
||||||
|
|
||||||
let hookScriptExists = false;
|
|
||||||
if (hookScriptPath) {
|
|
||||||
try {
|
|
||||||
await fsp.access(hookScriptPath);
|
|
||||||
hookScriptExists = true;
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: hookConfigured && hookScriptExists,
|
installed: hookConfigured && hookScriptExists,
|
||||||
agentName: AGENT_KEY,
|
agentName: AGENT_KEY,
|
||||||
displayName: entry.name,
|
displayName: entry.name,
|
||||||
hookConfigured,
|
hookConfigured,
|
||||||
hookScriptExists,
|
hookScriptExists,
|
||||||
configPath,
|
configPath: sources.user.filePath,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -12,7 +12,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
|
|||||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
|
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
|
||||||
['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }],
|
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
|
||||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||||
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||||
['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }],
|
['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }],
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot.js')).href;
|
||||||
|
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
|
||||||
|
|
||||||
|
function runNode(args, env, cwd, input = '') {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, args, {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||||
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||||
|
child.stdin.end(input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHook(handler, input) {
|
||||||
|
const command = process.platform === 'win32'
|
||||||
|
? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', handler.powershell]]
|
||||||
|
: ['/bin/sh', ['-c', handler.bash]];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command[0], command[1], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||||
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||||
|
child.stdin.end(input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJson(filePath, value) {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPlugin(fixture, method, argument) {
|
||||||
|
const source = `
|
||||||
|
import { copilotPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
||||||
|
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
||||||
|
const result = await copilotPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
||||||
|
if (result !== undefined) console.log(JSON.stringify(result));
|
||||||
|
`;
|
||||||
|
return runNode(
|
||||||
|
['--input-type=module', '--eval', source],
|
||||||
|
{
|
||||||
|
HOME: fixture.homeDir,
|
||||||
|
USERPROFILE: fixture.homeDir,
|
||||||
|
COPILOT_HOME: fixture.copilotHome,
|
||||||
|
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
||||||
|
ELYDORA_TEST_METHOD: method,
|
||||||
|
},
|
||||||
|
fixture.projectDir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixture({
|
||||||
|
userConfig,
|
||||||
|
legacyConfig,
|
||||||
|
guardSource,
|
||||||
|
hookSource,
|
||||||
|
createRuntimes = true,
|
||||||
|
} = {}) {
|
||||||
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-copilot-'));
|
||||||
|
const projectDir = path.join(homeDir, 'project');
|
||||||
|
const copilotHome = path.join(homeDir, 'custom-copilot');
|
||||||
|
const configPath = path.join(copilotHome, 'hooks', 'elydora-audit.json');
|
||||||
|
const legacyPath = path.join(projectDir, '.github', 'hooks', 'hooks.json');
|
||||||
|
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
||||||
|
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||||
|
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||||
|
await mkdir(projectDir, { recursive: true });
|
||||||
|
if (createRuntimes) {
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
guardScriptPath,
|
||||||
|
guardSource ?? "process.stdin.resume(); process.stdin.once('end', () => { process.stderr.write('Agent is frozen by Elydora.'); process.exit(2); });\n",
|
||||||
|
);
|
||||||
|
await writeFile(hookScriptPath, hookSource ?? 'process.stdin.resume();\n');
|
||||||
|
await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({
|
||||||
|
agent_id: 'agent-1',
|
||||||
|
agent_name: 'copilot',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (userConfig !== undefined) await writeJson(configPath, userConfig);
|
||||||
|
if (legacyConfig !== undefined) await writeJson(legacyPath, legacyConfig);
|
||||||
|
return {
|
||||||
|
agentDir,
|
||||||
|
configPath,
|
||||||
|
copilotHome,
|
||||||
|
guardScriptPath,
|
||||||
|
homeDir,
|
||||||
|
hookScriptPath,
|
||||||
|
legacyPath,
|
||||||
|
projectDir,
|
||||||
|
async install() {
|
||||||
|
return runPlugin(this, 'install', {
|
||||||
|
agentName: 'copilot',
|
||||||
|
agentId: 'agent-1',
|
||||||
|
guardScriptPath,
|
||||||
|
hookScriptPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedHandler(config, event, scriptName) {
|
||||||
|
return config.hooks?.[event]?.find(
|
||||||
|
(handler) => handler.bash?.includes(scriptName) || handler.powershell?.includes(scriptName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNativeHandler(handler) {
|
||||||
|
assert.deepEqual(Object.keys(handler).sort(), ['bash', 'powershell', 'timeoutSec', 'type']);
|
||||||
|
assert.equal(handler.type, 'command');
|
||||||
|
assert.equal(handler.timeoutSec, 10);
|
||||||
|
assert.match(handler.bash, /node(?:\.exe)?/i);
|
||||||
|
assert.match(handler.powershell, /^& /);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyManagedConfig(fixture, extraHooks = {}) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
hooks: {
|
||||||
|
preToolUse: [{
|
||||||
|
type: 'command',
|
||||||
|
bash: `node "${fixture.guardScriptPath}"`,
|
||||||
|
powershell: `node "${fixture.guardScriptPath}"`,
|
||||||
|
timeoutSec: 5,
|
||||||
|
}],
|
||||||
|
postToolUse: [{
|
||||||
|
type: 'command',
|
||||||
|
bash: `node "${fixture.hookScriptPath}"`,
|
||||||
|
powershell: `node "${fixture.hookScriptPath}"`,
|
||||||
|
timeoutSec: 5,
|
||||||
|
}],
|
||||||
|
...extraHooks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GitHub Copilot CLI is registered with the native user hook file', async () => {
|
||||||
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||||
|
assert.deepEqual(SUPPORTED_AGENTS.get('copilot'), {
|
||||||
|
name: 'GitHub Copilot CLI',
|
||||||
|
configDir: '~/.copilot/hooks',
|
||||||
|
configFile: 'elydora-audit.json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot install preserves user hooks, migrates legacy entries, and is idempotent', async () => {
|
||||||
|
const fixture = await createFixture({
|
||||||
|
userConfig: {
|
||||||
|
version: 1,
|
||||||
|
disableAllHooks: false,
|
||||||
|
hooks: {
|
||||||
|
sessionStart: [{ type: 'command', command: 'user-session-hook' }],
|
||||||
|
preToolUse: [{ type: 'command', command: 'user-pre-hook' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await writeJson(fixture.legacyPath, legacyManagedConfig(fixture, {
|
||||||
|
notification: [{ type: 'command', command: 'user-notification-hook' }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const first = await fixture.install();
|
||||||
|
assert.equal(first.code, 0, first.stderr);
|
||||||
|
const second = await fixture.install();
|
||||||
|
assert.equal(second.code, 0, second.stderr);
|
||||||
|
|
||||||
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
assert.equal(config.version, 1);
|
||||||
|
assert.equal(config.disableAllHooks, false);
|
||||||
|
assert.deepEqual(config.hooks.sessionStart, [{ type: 'command', command: 'user-session-hook' }]);
|
||||||
|
assert.deepEqual(config.hooks.preToolUse[0], { type: 'command', command: 'user-pre-hook' });
|
||||||
|
assert.equal(config.hooks.preToolUse.length, 2);
|
||||||
|
assert.equal(config.hooks.postToolUse.length, 1);
|
||||||
|
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
|
||||||
|
assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js'));
|
||||||
|
|
||||||
|
const legacy = JSON.parse(await readFile(fixture.legacyPath, 'utf-8'));
|
||||||
|
assert.deepEqual(legacy, {
|
||||||
|
version: 1,
|
||||||
|
hooks: {
|
||||||
|
notification: [{ type: 'command', command: 'user-notification-hook' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot migration removes a legacy file owned entirely by Elydora', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
await writeJson(fixture.legacyPath, legacyManagedConfig(fixture));
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' });
|
||||||
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot commands block freezes and forward the native payload byte-for-byte', async () => {
|
||||||
|
const capturePath = path.join(os.tmpdir(), `elydora-copilot-event-${process.pid}-${Date.now()}.json`);
|
||||||
|
const fixture = await createFixture({
|
||||||
|
hookSource: `
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const chunks = [];
|
||||||
|
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const install = await fixture.install();
|
||||||
|
assert.equal(install.code, 0, install.stderr);
|
||||||
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
const guard = managedHandler(config, 'preToolUse', 'guard.js');
|
||||||
|
const audit = managedHandler(config, 'postToolUse', 'hook.js');
|
||||||
|
const prePayload = JSON.stringify({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
cwd: fixture.projectDir,
|
||||||
|
toolName: 'powershell',
|
||||||
|
toolArgs: { command: 'Get-ChildItem' },
|
||||||
|
});
|
||||||
|
const guardResult = await runHook(guard, prePayload);
|
||||||
|
assert.equal(guardResult.code, 2, guardResult.stderr);
|
||||||
|
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
|
||||||
|
|
||||||
|
const postPayload = JSON.stringify({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
cwd: fixture.projectDir,
|
||||||
|
toolName: 'powershell',
|
||||||
|
toolArgs: { command: 'Get-ChildItem' },
|
||||||
|
toolResult: { output: 'ok' },
|
||||||
|
});
|
||||||
|
const auditResult = await runHook(audit, postPayload);
|
||||||
|
assert.equal(auditResult.code, 0, auditResult.stderr);
|
||||||
|
assert.equal(await readFile(capturePath, 'utf-8'), postPayload);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
await rm(capturePath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot treats an empty home override as the official default', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
fixture.copilotHome = '';
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
const defaultPath = path.join(fixture.homeDir, '.copilot', 'hooks', 'elydora-audit.json');
|
||||||
|
const config = JSON.parse(await readFile(defaultPath, 'utf-8'));
|
||||||
|
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
|
||||||
|
const status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).configPath, defaultPath);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot status requires a complete pair, matching runtime identity, and both scripts', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
let status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(status.code, 0, status.stderr);
|
||||||
|
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||||
|
|
||||||
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
delete config.hooks.postToolUse;
|
||||||
|
await writeJson(fixture.configPath, config);
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
||||||
|
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed');
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(status.code, 1);
|
||||||
|
assert.match(status.stderr, /parse Elydora runtime config/i);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot uninstall removes exact ownership and preserves user entries', async () => {
|
||||||
|
const fixture = await createFixture({
|
||||||
|
userConfig: { version: 1, hooks: { notification: [{ type: 'command', command: 'keep' }] } },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
config.hooks.preToolUse.push({
|
||||||
|
type: 'command',
|
||||||
|
bash: `'${process.execPath}' '${path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js')}'`,
|
||||||
|
powershell: 'user-decoy',
|
||||||
|
timeoutSec: 10,
|
||||||
|
});
|
||||||
|
await writeJson(fixture.configPath, config);
|
||||||
|
|
||||||
|
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
assert.deepEqual(remaining.hooks.notification, [{ type: 'command', command: 'keep' }]);
|
||||||
|
assert.equal(remaining.hooks.postToolUse, undefined);
|
||||||
|
assert.equal(remaining.hooks.preToolUse.length, 1);
|
||||||
|
assert.match(remaining.hooks.preToolUse[0].bash, /agent-10/);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot uninstall leaves absent hook sources absent', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||||
|
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot preserves malformed and invalid configs for recovery', async () => {
|
||||||
|
for (const existing of [
|
||||||
|
'{ malformed',
|
||||||
|
{ hooks: {} },
|
||||||
|
{ version: 2, hooks: {} },
|
||||||
|
{ version: 1, hooks: null },
|
||||||
|
{ version: 1, hooks: { preToolUse: null } },
|
||||||
|
{ version: 1, hooks: { preToolUse: [null] } },
|
||||||
|
]) {
|
||||||
|
const fixture = await createFixture({ userConfig: existing });
|
||||||
|
try {
|
||||||
|
const before = await readFile(fixture.configPath, 'utf-8');
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot rejects missing runtimes before creating hook config', async () => {
|
||||||
|
const fixture = await createFixture({ createRuntimes: false });
|
||||||
|
try {
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /runtime is missing/i);
|
||||||
|
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copilot atomic writes leave no transaction files', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const files = await readdir(path.dirname(fixture.configPath));
|
||||||
|
assert.equal(files.some((name) => name.endsWith('.tmp') || name.endsWith('.rollback')), false);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user