fix: harden GitHub Copilot hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 22:32:17 -04:00
1 parent d2794c8fbb
commit d5a2dcbe53
6 files changed
+985 -138

No files matched your search

+61 -136
View File
@@ -1,164 +1,89 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
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';
const AGENT_KEY = 'copilot';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return path.join(process.cwd(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((h) => {
if (typeof h.bash === 'string' && isElydoraCommand(h.bash, agentId)) return false;
if (typeof h.powershell === 'string' && isElydoraCommand(h.powershell, agentId)) return false;
return true;
});
function mergedContracts(
userHooks: Parameters<typeof runtimeContracts>[0],
legacyHooks?: Parameters<typeof runtimeContracts>[0],
): ReturnType<typeof runtimeContracts> {
const contracts = [
...runtimeContracts(userHooks),
...runtimeContracts(legacyHooks ?? {}),
];
const unique = new Map(contracts.map((contract) => [contract.agentId, contract]));
return [...unique.values()];
}
export const copilotPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
if (!config.agentId) throw new Error('agentId is required');
const sources = await readSources();
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = { version: 1 };
const userHooks = removeManagedHooks(sources.user.hooks);
userHooks.preToolUse = [
...(userHooks.preToolUse ?? []),
buildHandler(config.guardScriptPath),
];
userHooks.postToolUse = [
...(userHooks.postToolUse ?? []),
buildHandler(config.hookScriptPath),
];
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// Start fresh
const rendered = [renderDocument(sources.user, userHooks)];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks),
));
}
settings.version = 1;
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');
await writeDocuments(rendered);
console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`);
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return;
const sources = await readSources();
const rendered = [
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)),
];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks, agentId),
));
}
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');
await writeDocuments(rendered);
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const check = (arr: unknown) =>
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
(h) =>
(typeof h.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
}
}
const sources = await readSources();
const contracts = mergedContracts(sources.user.hooks, sources.legacy?.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: sources.user.filePath,
};
},
};