fix: harden GitHub Copilot hooks
This commit is contained in:
1 parent
d2794c8fbb
commit
d5a2dcbe53
6 files changed
+985
-138
No files matched your search
+61
-136
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user