fix(copilot): mirror transactional Node hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 12:09:24 -04:00
1 parent d0185887fa
commit af8d227b45
17 files changed
+1738 -651

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ This repository publishes the open-source server, console, integration catalog,
- All three SDKs commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Their status checks require exact runtime identity, canonical private keys, physical files, and exact wrapper sources. Installation validates matcher syntax with JavaScript `RegExp`; status and uninstall stay available for offline recovery. Verify the effective configuration with `auggie tools list`. - All three SDKs commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Their status checks require exact runtime identity, canonical private keys, physical files, and exact wrapper sources. Installation validates matcher syntax with JavaScript `RegExp`; status and uninstall stay available for offline recovery. Verify the effective configuration with `auggie tools list`.
- Treat Cline 3 CLI file hooks as the SDK adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload and use supported event filename extensions. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, and exact generated runtime and wrapper sources. Node and Go wrappers use `process.execPath`; Python wrappers resolve the runtime executable from an absolute shebang. Translate guard exit code `2` into one-line JSON stdout with `cancel: true`; hook errors, timeouts, and invalid control JSON fail open. Model Cline IDE as a separate variant with runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots, exact `.ps1` filenames on Windows, and executable extensionless filenames on macOS and Linux. - Treat Cline 3 CLI file hooks as the SDK adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload and use supported event filename extensions. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, and exact generated runtime and wrapper sources. Node and Go wrappers use `process.execPath`; Python wrappers resolve the runtime executable from an absolute shebang. Translate guard exit code `2` into one-line JSON stdout with `cancel: true`; hook errors, timeouts, and invalid control JSON fail open. Model Cline IDE as a separate variant with runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots, exact `.ps1` filenames on Windows, and executable extensionless filenames on macOS and Linux.
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes. - Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. - Resolve GitHub Copilot CLI user hooks through `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default, and treat an empty override as the default. Keep policy, repository, inline settings, cross-tool Claude, and plugin hook sources read-only. Evaluate `disableAllHooks` through legacy user config, user settings, Claude repository and local settings, then GitHub repository and local settings; the managed hook file flag remains independently authoritative. Register exact `preToolUse`, `postToolUse`, and `postToolUseFailure` command handlers with ten-second timeouts, preserve the complete native camelCase payload, and propagate frozen or revoked state through exit code `2`. Commit each SDK's user hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical source paths, strict hook schemas, exact runtime sources, canonical private keys, and matching runtime identity during status checks, then verify discovery with the current official Copilot native hook loader and restart active CLI sessions.
- Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control. - Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control.
- Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
+1 -1
View File
@@ -8,7 +8,7 @@ Grok Build adapters write exact `PreToolUse`, `PostToolUse`, and `PostToolUseFai
Cline CLI 3 and Cline IDE use separate file-hook loaders. SDK adapters target CLI 3, write only `$CLINE_DIR/hooks`, preserve its hybrid `tool_call`/`preToolUse` and `tool_result`/`postToolUse` payload, and emit pure JSON cancellation controls. The IDE variant discovers runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots; Windows selects exact `.ps1` files and macOS/Linux select executable extensionless files. Both runtimes continue after hook crashes, timeouts, and invalid controls while recording the failure. Cline CLI 3 and Cline IDE use separate file-hook loaders. SDK adapters target CLI 3, write only `$CLINE_DIR/hooks`, preserve its hybrid `tool_call`/`preToolUse` and `tool_result`/`postToolUse` payload, and emit pure JSON cancellation controls. The IDE variant discovers runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots; Windows selects exact `.ps1` files and macOS/Linux select executable extensionless files. Both runtimes continue after hook crashes, timeouts, and invalid controls while recording the failure.
GitHub Copilot command `preToolUse` hooks deny on crashes and every non-zero exit. Command timeouts continue through the normal permission flow, so `timeout_failure_mode` records that explicit exception when it differs from `failure_mode`. GitHub Copilot CLI loads user hook files from `$COPILOT_HOME/hooks` or `~/.copilot/hooks` and combines them with policy, repository, inline settings, cross-tool Claude, and plugin sources. Elydora owns one user file with exact `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers and preserves native camelCase fields, including `toolResult` and `error`. Effective settings precedence controls `disableAllHooks`; each managed file also retains its independent disable flag. Command crashes and non-zero exits deny pre-tool execution, while command timeouts continue through the normal permission flow.
Cursor uses the same `preToolUse` and `postToolUse` contract across its CLI and IDE. User, project, and enterprise hook sources merge by documented priority; command exit code `2` denies an action, while per-script `failClosed` controls crash, timeout, and invalid-output behavior. Cursor uses the same `preToolUse` and `postToolUse` contract across its CLI and IDE. User, project, and enterprise hook sources merge by documented priority; command exit code `2` denies an action, while per-script `failClosed` controls crash, timeout, and invalid-output behavior.
+12 -2
View File
@@ -163,8 +163,18 @@
"~/.copilot/hooks/*.json", "~/.copilot/hooks/*.json",
".github/hooks/*.json" ".github/hooks/*.json"
], ],
"events": { "before_tool": "preToolUse", "after_tool": "postToolUse" }, "events": {
"event_fields": { "name": "toolName", "input": "toolArgs", "session": "sessionId" }, "before_tool": "preToolUse",
"after_tool": "postToolUse",
"after_tool_failure": "postToolUseFailure"
},
"event_fields": {
"name": "toolName",
"input": "toolArgs",
"session": "sessionId",
"output": "toolResult",
"error": "error"
},
"blocking": { "blocking": {
"mechanism": "any_nonzero_exit", "mechanism": "any_nonzero_exit",
"failure_mode": "fail_closed", "failure_mode": "fail_closed",
+2
View File
@@ -57,6 +57,8 @@ Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes. Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
GitHub Copilot CLI installation writes exact `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `$COPILOT_HOME/hooks/elydora-audit.json` (`~/.copilot/hooks/elydora-audit.json` by default). Empty home overrides select the default. Policy, repository, inline settings, cross-tool Claude, and plugin hook sources remain unchanged and continue loading additively. Elydora evaluates the effective `disableAllHooks` value across Copilot's documented settings precedence before writing. The managed hook file, exact legacy migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed commands preserve the complete native camelCase payload and propagate freeze or revocation through exit code `2`; Copilot timeouts retain their documented fail-open behavior. Restart active Copilot CLI sessions after installation.
Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout. Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout.
Gemini CLI installation writes named, matchless `BeforeTool` and `AfterTool` command hooks to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Empty home overrides select the OS home; relative and literal-tilde values retain Gemini CLI's native path behavior. JSON comments and unrelated settings remain intact. Workspace, system-default, system-override, and extension hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve the complete native snake_case payload, emit valid JSON on successful execution, propagate freeze and revocation through exit code `2`, and use encoded PowerShell commands on Windows. `hooksConfig.enabled` and `hooksConfig.disabled` remain authoritative. Run `/hooks list` after installation to inspect effective hooks from every source. Gemini CLI installation writes named, matchless `BeforeTool` and `AfterTool` command hooks to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Empty home overrides select the OS home; relative and literal-tilde values retain Gemini CLI's native path behavior. JSON comments and unrelated settings remain intact. Workspace, system-default, system-override, and extension hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve the complete native snake_case payload, emit valid JSON on successful execution, propagate freeze and revocation through exit code `2`, and use encoded PowerShell commands on Windows. `hooksConfig.enabled` and `hooksConfig.disabled` remain authoritative. Run `/hooks list` after installation to inspect effective hooks from every source.
+111 -75
View File
@@ -1,5 +1,8 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import type { FileSnapshot } from './managed-files.js';
import { validateHooks, type CopilotHooks } from './copilot-schema.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
export const AGENT_KEY = 'copilot'; export const AGENT_KEY = 'copilot';
export const GUARD_SCRIPT = 'guard.js'; export const GUARD_SCRIPT = 'guard.js';
@@ -7,20 +10,33 @@ export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10; export const HOOK_TIMEOUT_SECONDS = 10;
export const CONFIG_FILE = 'elydora-audit.json'; export const CONFIG_FILE = 'elydora-audit.json';
export type JsonObject = Record<string, unknown>; const MANAGED_EVENTS = [
export type CopilotHooks = Record<string, JsonObject[]>; ['preToolUse', GUARD_SCRIPT],
['postToolUse', AUDIT_SCRIPT],
['postToolUseFailure', AUDIT_SCRIPT],
] as const;
export interface CopilotDocument { export interface CopilotDocument {
readonly exists: boolean; readonly exists: boolean;
readonly filePath: string; readonly filePath: string;
readonly root: JsonObject; readonly root: JsonObject;
readonly hooks: CopilotHooks; readonly hooks: CopilotHooks;
readonly hooksDisabled: boolean;
readonly raw?: string; readonly raw?: string;
readonly snapshot?: FileSnapshot;
} }
export interface CopilotSources { export interface CopilotSources {
readonly user: CopilotDocument; readonly user: CopilotDocument;
readonly legacy?: CopilotDocument; readonly legacy?: CopilotDocument;
readonly disabledBy?: string;
readonly settingsPreconditions: readonly CopilotSourcePrecondition[];
}
export interface CopilotSourcePrecondition {
readonly filePath: string;
readonly label: string;
readonly snapshot?: FileSnapshot;
} }
export interface RenderedDocument { export interface RenderedDocument {
@@ -40,11 +56,12 @@ interface ParsedArgument {
readonly next: number; readonly next: number;
} }
export function isObject(value: unknown): value is JsonObject { interface ManagedEntry {
return typeof value === 'object' && value !== null && !Array.isArray(value); readonly agentId: string;
readonly scriptPath: string;
} }
function samePath(left: string, right: string): boolean { export function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left); const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right); const normalizedRight = path.resolve(right);
return process.platform === 'win32' return process.platform === 'win32'
@@ -52,8 +69,14 @@ function samePath(left: string, right: string): boolean {
: normalizedLeft === normalizedRight; : normalizedLeft === normalizedRight;
} }
function sameAgentId(left: string, right: string): boolean { export function sameAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; return process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function agentKey(agentId: string): string {
return process.platform === 'win32' ? agentId.toLowerCase() : agentId;
} }
function quotePosix(value: string): string { function quotePosix(value: string): string {
@@ -126,82 +149,88 @@ function parseGeneratedPowerShell(command: unknown): readonly [string, string] |
return [executable.value, script.value]; return [executable.value, script.value];
} }
function exactHandlerKeys(handler: JsonObject): boolean {
return Object.keys(handler).sort().join(',') === 'bash,powershell,timeoutSec,type';
}
function nodeExecutable(filePath: string): boolean {
if (!path.isAbsolute(filePath)) return false;
const name = path.basename(filePath).toLowerCase();
return name === 'node' || name === 'node.exe';
}
function currentManagedScriptPath(handler: JsonObject): string | undefined {
if (!exactHandlerKeys(handler)
|| handler.type !== 'command'
|| handler.timeoutSec !== HOOK_TIMEOUT_SECONDS) return undefined;
const bash = parseGeneratedBash(handler.bash);
const powershell = parseGeneratedPowerShell(handler.powershell);
if (!bash || !powershell
|| !nodeExecutable(bash[0])
|| !samePath(bash[0], powershell[0])
|| !samePath(bash[1], powershell[1])) return undefined;
return bash[1];
}
function parseLegacyCommand(command: unknown): string | undefined { function parseLegacyCommand(command: unknown): string | undefined {
if (typeof command !== 'string') return undefined; if (typeof command !== 'string') return undefined;
return /^node(?:\.exe)?\s+"([^"]+)"$/i.exec(command)?.[1]; return /^node(?:\.exe)?\s+"([^"]+)"$/i.exec(command)?.[1];
} }
function managedScriptPath(handler: JsonObject): string | undefined { function legacyManagedScriptPath(handler: JsonObject): string | undefined {
if (handler.type !== 'command') return undefined; if (!exactHandlerKeys(handler)
const bash = parseGeneratedBash(handler.bash); || handler.type !== 'command'
const powershell = parseGeneratedPowerShell(handler.powershell); || handler.timeoutSec !== 5) return undefined;
if (handler.timeoutSec === HOOK_TIMEOUT_SECONDS && bash && powershell) { const bash = parseLegacyCommand(handler.bash);
if (samePath(bash[0], process.execPath) const powershell = parseLegacyCommand(handler.powershell);
&& samePath(powershell[0], process.execPath) return bash && powershell && samePath(bash, powershell) ? bash : undefined;
&& 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 { function managedEntry(handler: JsonObject, scriptName: string): ManagedEntry | undefined {
const scriptPath = managedScriptPath(handler); const scriptPath = currentManagedScriptPath(handler) ?? legacyManagedScriptPath(handler);
if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined; if (!scriptPath || path.basename(scriptPath).toLowerCase() !== scriptName.toLowerCase()) {
return undefined;
}
const agentDirectory = path.dirname(scriptPath); const agentDirectory = path.dirname(scriptPath);
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined; if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
return undefined;
}
const agentId = path.basename(agentDirectory); const agentId = path.basename(agentDirectory);
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; if (!agentId || agentId === '.' || agentId === '..') return undefined;
return { agentId, scriptPath };
} }
function validateHooks(value: unknown, label: string): CopilotHooks { export function parseDocument(
if (value === undefined) return {}; filePath: string,
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`); snapshot: FileSnapshot,
const hooks: CopilotHooks = {}; label: string,
for (const [event, handlers] of Object.entries(value)) { ): CopilotDocument {
if (!Array.isArray(handlers)) throw new Error(`${label} field "hooks.${event}" must be an array`); const root = parseStrictJsonObject(snapshot.contents, `${label} at ${filePath}`);
hooks[event] = handlers.map((handler, index) => { if (root.version !== 1) throw new Error(`${label} at ${filePath} must declare version 1`);
if (!isObject(handler)) throw new Error(`${label} handler hooks.${event}[${index}] must be an object`); if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
return handler; throw new Error(`${label} at ${filePath} field "disableAllHooks" must be a boolean`);
});
} }
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 { return {
exists: true, exists: true,
filePath, filePath,
root: value, root,
hooks: validateHooks(value.hooks, label), hooks: validateHooks(root.hooks, `${label} at ${filePath}`),
raw, hooksDisabled: root.disableAllHooks === true,
raw: snapshot.contents,
snapshot,
}; };
} }
export function createDocument(filePath: string): CopilotDocument { export function createDocument(filePath: string): CopilotDocument {
return { exists: false, filePath, root: {}, hooks: {} }; return { exists: false, filePath, root: {}, hooks: {}, hooksDisabled: false };
} }
export function removeManagedHooks(hooks: CopilotHooks, agentId?: string): CopilotHooks { export function removeManagedHooks(hooks: CopilotHooks, agentId?: string): CopilotHooks {
const next: CopilotHooks = { ...hooks }; const next: CopilotHooks = { ...hooks };
for (const [event, scriptName] of [ for (const [event, scriptName] of MANAGED_EVENTS) {
['preToolUse', GUARD_SCRIPT],
['postToolUse', AUDIT_SCRIPT],
] as const) {
const handlers = (next[event] ?? []).filter((handler) => { const handlers = (next[event] ?? []).filter((handler) => {
const managedId = managedAgentId(handler, scriptName); const owned = managedEntry(handler, scriptName);
return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId)); return !owned || (agentId !== undefined && !sameAgentId(owned.agentId, agentId));
}); });
if (handlers.length > 0) next[event] = handlers; if (handlers.length > 0) next[event] = handlers;
else delete next[event]; else delete next[event];
@@ -227,26 +256,33 @@ export function renderDocument(
const root: JsonObject = { ...document.root, version: 1 }; const root: JsonObject = { ...document.root, version: 1 };
if (Object.keys(hooks).length > 0) root.hooks = hooks; if (Object.keys(hooks).length > 0) root.hooks = hooks;
else delete root.hooks; else delete root.hooks;
const next = JSON.stringify(root, null, 2) + '\n'; const next = `${JSON.stringify(root, null, 2)}\n`;
return { document, changed: next !== document.raw, next }; return { document, changed: next !== document.raw, next };
} }
function managedIds(handlers: JsonObject[], scriptName: string): Set<string> { function managedEntries(handlers: JsonObject[], scriptName: string): Map<string, ManagedEntry> {
return new Set(handlers.flatMap((handler) => { const entries = new Map<string, ManagedEntry>();
const agentId = managedAgentId(handler, scriptName); for (const handler of handlers) {
return agentId ? [agentId] : []; const entry = managedEntry(handler, scriptName);
})); if (entry) entries.set(agentKey(entry.agentId), entry);
}
return entries;
} }
export function runtimeContracts(hooks: CopilotHooks): RuntimeContract[] { export function runtimeContracts(hooks: CopilotHooks): RuntimeContract[] {
const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT); const guards = managedEntries(hooks.preToolUse ?? [], GUARD_SCRIPT);
const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT); const successes = managedEntries(hooks.postToolUse ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), '.elydora'); const failures = managedEntries(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT);
return [...guards] const contracts: RuntimeContract[] = [];
.filter((agentId) => [...audits].some((auditId) => sameAgentId(agentId, auditId))) for (const [key, guard] of guards) {
.map((agentId) => ({ const success = successes.get(key);
agentId, const failure = failures.get(key);
guardPath: path.join(root, agentId, GUARD_SCRIPT), if (!success || !failure || !samePath(success.scriptPath, failure.scriptPath)) continue;
auditPath: path.join(root, agentId, AUDIT_SCRIPT), contracts.push({
})); agentId: guard.agentId,
guardPath: guard.scriptPath,
auditPath: success.scriptPath,
});
}
return contracts;
} }
@@ -0,0 +1,145 @@
import path from 'node:path';
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
type CopilotSources,
type RenderedDocument,
samePath,
} from './copilot-contract.js';
import { requireHooksEnabled } from './copilot-io.js';
import {
commitManagedInstallation,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import {
commitManagedTransaction,
prepareManagedFileChange,
type PreparedManagedTransaction,
} from './managed-transaction.js';
const DISPLAY_NAME = 'GitHub Copilot CLI';
const DIRECTORY_LABEL = 'GitHub Copilot hooks directory';
const SOURCE_LABEL = 'GitHub Copilot hook source';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
export type CopilotRuntimePaths = ManagedRuntimePaths;
export type PreparedCopilotInstallation = PreparedManagedInstallation;
export type { RenameFile };
function hookLocations(sources: CopilotSources) {
return [sources.user, ...(sources.legacy ? [sources.legacy] : [])].map((document) => ({
directoryLabel: DIRECTORY_LABEL,
filePath: document.filePath,
}));
}
export async function preflightCopilotInstallation(
config: InstallConfig,
sources: CopilotSources,
): Promise<CopilotRuntimePaths> {
requireHooksEnabled(sources);
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hookLocations: hookLocations(sources),
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
function installationDocuments(
sources: CopilotSources,
rendered: readonly RenderedDocument[],
): readonly RenderedDocument[] {
return rendered.filter((item) => item.changed
|| samePath(item.document.filePath, sources.user.filePath));
}
export async function prepareCopilotInstallation(
config: InstallConfig,
sources: CopilotSources,
rendered: readonly RenderedDocument[],
): Promise<PreparedCopilotInstallation> {
await preflightCopilotInstallation(config, sources);
const documents = installationDocuments(sources, rendered);
const prepared = await prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hookSources: documents.map((item) => ({
directoryLabel: DIRECTORY_LABEL,
label: SOURCE_LABEL,
filePath: item.document.filePath,
expectedSource: item.document.raw,
expectedSnapshot: item.document.snapshot,
source: item.changed ? item.next : item.document.raw,
})),
config,
auditOptions: { nativePayload: true },
}, GUARD_SCRIPT, AUDIT_SCRIPT);
const legacyPrecondition = sources.legacy
&& !documents.some((item) => samePath(item.document.filePath, sources.legacy!.filePath))
? [{
filePath: sources.legacy.filePath,
label: 'GitHub Copilot legacy project hooks',
maximumBytes: MAX_SOURCE_BYTES,
original: sources.legacy.snapshot,
}]
: [];
return {
...prepared,
transaction: {
...prepared.transaction,
preconditions: [
...sources.settingsPreconditions.map((condition) => ({
filePath: condition.filePath,
label: condition.label,
maximumBytes: MAX_SOURCE_BYTES,
original: condition.snapshot,
})),
...legacyPrecondition,
],
},
};
}
export async function commitCopilotInstallation(
prepared: PreparedCopilotInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
export async function prepareCopilotUninstall(
rendered: readonly RenderedDocument[],
): Promise<PreparedManagedTransaction> {
const changed = rendered.filter((item) => item.changed);
const changes = await Promise.all(changed.map((item) => prepareManagedFileChange({
filePath: item.document.filePath,
label: SOURCE_LABEL,
next: item.next,
mode: 0o600,
expectedSource: item.document.raw,
expectedSnapshot: item.document.snapshot,
verifyExpectedSource: true,
})));
return {
displayName: DISPLAY_NAME,
operation: 'uninstall',
directories: changed.map((item) => ({
path: path.dirname(item.document.filePath),
label: DIRECTORY_LABEL,
})),
changes: changes.filter((change) => change !== undefined),
};
}
export async function commitCopilotUninstall(
prepared: PreparedManagedTransaction,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedTransaction(prepared, renameFile);
}
+204 -233
View File
@@ -1,281 +1,252 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { import {
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT,
CONFIG_FILE, CONFIG_FILE,
GUARD_SCRIPT,
type CopilotDocument, type CopilotDocument,
type CopilotSources, type CopilotSources,
type JsonObject,
type RenderedDocument,
type RuntimeContract, type RuntimeContract,
createDocument, createDocument,
isObject,
parseDocument, parseDocument,
sameAgentId,
samePath,
} from './copilot-contract.js'; } from './copilot-contract.js';
import { generateGuardScript } from './guard-template.js';
import { generateHookScript } from './hook-template.js';
import {
inspectPhysicalDirectory,
readPhysicalFile,
type FileSnapshot,
} from './managed-files.js';
import {
parseStrictJsonObject,
parseStrictJsoncObject,
type JsonObject,
} from './strict-json.js';
interface FileChange { const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
const MAX_SETTINGS_BYTES = 2 * 1024 * 1024;
export interface CopilotPaths {
readonly copilotHome: string;
readonly userHooksDirectory: string;
readonly userHookPath: string;
readonly legacyHookPath: string;
readonly settingsLayers: readonly SettingsLayer[];
readonly inspectedDirectories: readonly DirectoryLocation[];
}
interface DirectoryLocation {
readonly path: string;
readonly label: string;
}
interface SettingsLayer {
readonly filePath: string; readonly filePath: string;
readonly label: string; readonly label: string;
readonly original?: string; readonly jsonc: boolean;
readonly next?: string;
} }
interface StagedChange { interface ParsedSettingsLayer extends SettingsLayer {
readonly change: FileChange; readonly disableAllHooks?: boolean;
readonly tempPath?: string; readonly snapshot?: FileSnapshot;
readonly rollbackPath?: string;
committed: boolean;
} }
function errorMessage(error: unknown): string { export function resolveCopilotPaths(): CopilotPaths {
return error instanceof Error ? error.message : String(error); const override = process.env.COPILOT_HOME;
} const copilotHome = override ? override : path.join(os.homedir(), '.copilot');
const project = process.cwd();
function asError(error: unknown): Error { const github = path.join(project, '.github');
return error instanceof Error ? error : new Error(String(error)); const githubCopilot = path.join(github, 'copilot');
} const githubHooks = path.join(github, 'hooks');
const claude = path.join(project, '.claude');
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 { return {
user: path.join(copilotHome, 'hooks', CONFIG_FILE), copilotHome,
legacy: path.join(process.cwd(), '.github', 'hooks', 'hooks.json'), userHooksDirectory: path.join(copilotHome, 'hooks'),
userHookPath: path.join(copilotHome, 'hooks', CONFIG_FILE),
legacyHookPath: path.join(githubHooks, 'hooks.json'),
settingsLayers: [
{ filePath: path.join(copilotHome, 'config.json'), label: 'legacy Copilot user config', jsonc: false },
{ filePath: path.join(copilotHome, 'settings.json'), label: 'Copilot user settings', jsonc: true },
{ filePath: path.join(claude, 'settings.json'), label: 'Claude repository settings', jsonc: true },
{ filePath: path.join(claude, 'settings.local.json'), label: 'Claude local settings', jsonc: true },
{ filePath: path.join(githubCopilot, 'settings.json'), label: 'Copilot repository settings', jsonc: true },
{ filePath: path.join(githubCopilot, 'settings.local.json'), label: 'Copilot local settings', jsonc: true },
],
inspectedDirectories: [
{ path: project, label: 'Copilot working directory' },
{ path: copilotHome, label: 'COPILOT_HOME' },
{ path: path.join(copilotHome, 'hooks'), label: 'Copilot user hooks directory' },
{ path: github, label: 'GitHub configuration directory' },
{ path: githubHooks, label: 'GitHub repository hooks directory' },
{ path: githubCopilot, label: 'Copilot repository settings directory' },
{ path: claude, label: 'Claude repository settings directory' },
],
}; };
} }
async function readOptional(filePath: string, label: string): Promise<string | undefined> { async function inspectDirectories(locations: readonly DirectoryLocation[]): Promise<void> {
try { for (const location of locations) {
return await fsp.readFile(filePath, 'utf-8'); await inspectPhysicalDirectory(location.path, location.label);
} 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> { async function readHookDocument(
const raw = await readOptional(filePath, label); filePath: string,
return raw === undefined ? undefined : parseDocument(filePath, raw, label); label: string,
): Promise<CopilotDocument | undefined> {
const snapshot = await readPhysicalFile(filePath, label, MAX_SETTINGS_BYTES);
return snapshot ? parseDocument(filePath, snapshot, label) : undefined;
}
function parseSettings(raw: string, layer: SettingsLayer): JsonObject {
if (raw.trim().length === 0) return {};
const label = `${layer.label} at ${layer.filePath}`;
return layer.jsonc
? parseStrictJsoncObject(raw, label)
: parseStrictJsonObject(raw, label);
}
async function readSettingsLayer(layer: SettingsLayer): Promise<ParsedSettingsLayer> {
const snapshot = await readPhysicalFile(layer.filePath, layer.label, MAX_SETTINGS_BYTES);
if (!snapshot) return layer;
const root = parseSettings(snapshot.contents, layer);
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
throw new Error(
`${layer.label} at ${layer.filePath} field "disableAllHooks" must be a boolean`,
);
}
return {
...layer,
disableAllHooks: root.disableAllHooks as boolean | undefined,
snapshot,
};
}
function effectiveDisabledSource(layers: readonly ParsedSettingsLayer[]): string | undefined {
let disabledBy: string | undefined;
for (const layer of layers) {
if (layer.disableAllHooks === true) disabledBy = `${layer.label} at ${layer.filePath}`;
else if (layer.disableAllHooks === false) disabledBy = undefined;
}
return disabledBy;
} }
export async function readSources(): Promise<CopilotSources> { export async function readSources(): Promise<CopilotSources> {
const configPaths = paths(); const paths = resolveCopilotPaths();
await inspectDirectories(paths.inspectedDirectories);
const [user, legacy] = await Promise.all([ const [user, legacy] = await Promise.all([
readDocument(configPaths.user, 'GitHub Copilot user hooks'), readHookDocument(paths.userHookPath, 'GitHub Copilot user hooks'),
readDocument(configPaths.legacy, 'GitHub Copilot legacy project hooks'), readHookDocument(paths.legacyHookPath, 'GitHub Copilot legacy project hooks'),
]); ]);
const layers = await Promise.all(paths.settingsLayers.map(readSettingsLayer));
const userDocument = user ?? createDocument(paths.userHookPath);
const disabledBy = userDocument.hooksDisabled
? `GitHub Copilot user hooks at ${paths.userHookPath}`
: effectiveDisabledSource(layers);
return { return {
user: user ?? createDocument(configPaths.user), user: userDocument,
legacy, legacy,
disabledBy,
settingsPreconditions: layers.map((layer) => ({
filePath: layer.filePath,
label: layer.label,
snapshot: layer.snapshot,
})),
}; };
} }
function changeFor(rendered: RenderedDocument): FileChange | undefined { export function requireHooksEnabled(sources: CopilotSources): void {
if (!rendered.changed) return undefined; if (sources.disabledBy) {
return { throw new Error(
filePath: rendered.document.filePath, `GitHub Copilot hooks are disabled by ${sources.disabledBy}; set disableAllHooks to false before installation`,
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> { function requireString(value: unknown, field: string, configPath: string): string {
let handle: FileHandle | undefined; if (typeof value !== 'string' || value.trim().length === 0) {
try { throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
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; return value;
} }
function validateRuntimeConfig(
config: JsonObject,
contract: RuntimeContract,
configPath: string,
): void {
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
const extra = Object.keys(config).find((key) => !supported.has(key));
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
requireString(config.org_id, 'org_id', configPath);
requireString(config.kid, 'kid', configPath);
const agentId = requireString(config.agent_id, 'agent_id', configPath);
if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) {
throw new Error(`Elydora runtime identity does not match Copilot hooks: ${configPath}`);
}
if (config.token !== undefined) requireString(config.token, 'token', configPath);
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
let baseUrl: URL;
try {
baseUrl = new URL(rawBaseUrl);
} catch (error) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
if (!['http:', 'https:'].includes(baseUrl.protocol)
|| !baseUrl.hostname
|| baseUrl.username
|| baseUrl.password
|| baseUrl.search
|| baseUrl.hash) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
}
}
function validatePrivateKey(contents: string, keyPath: string): void {
const bytes = Buffer.from(contents, 'base64url');
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
throw new Error(`Elydora private key is invalid: ${keyPath}`);
}
}
function validContractPaths(contract: RuntimeContract): boolean {
const agentDirectory = path.dirname(contract.guardPath);
return samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
&& samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
&& samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
}
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> { export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
for (const contract of contracts) { for (const contract of contracts) {
const runtimeConfig = await readRuntimeConfig( if (!validContractPaths(contract)) continue;
path.join(path.dirname(contract.guardPath), 'config.json'), const runtimeRoot = path.join(os.homedir(), '.elydora');
); const agentDirectory = path.dirname(contract.guardPath);
if (!runtimeConfig if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) continue;
|| runtimeConfig.agent_name !== AGENT_KEY if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) continue;
|| runtimeConfig.agent_id !== contract.agentId) continue; const configPath = path.join(agentDirectory, 'config.json');
const files = await Promise.all([ const keyPath = path.join(agentDirectory, 'private.key');
regularFileExists(contract.guardPath, 'Elydora guard runtime'), const [config, key, guard, audit] = await Promise.all([
regularFileExists(contract.auditPath, 'Elydora audit runtime'), readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
]); ]);
if (files.every(Boolean)) return true; if (!config || !key || !guard || !audit) continue;
const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`);
validateRuntimeConfig(parsed, contract, configPath);
validatePrivateKey(key.contents, keyPath);
if (guard.contents === generateGuardScript(AGENT_KEY, contract.agentId)
&& audit.contents === generateHookScript(
AGENT_KEY,
contract.agentId,
{ nativePayload: true },
)) return true;
} }
return false; return false;
} }
+162
View File
@@ -0,0 +1,162 @@
import { isObject, type JsonObject } from './strict-json.js';
export type CopilotHooks = Record<string, JsonObject[]>;
const SUPPORTED_EVENTS = new Set([
'agentStop',
'Stop',
'errorOccurred',
'ErrorOccurred',
'notification',
'Notification',
'permissionRequest',
'PermissionRequest',
'postToolUse',
'PostToolUse',
'postToolUseFailure',
'PostToolUseFailure',
'preCompact',
'PreCompact',
'preToolUse',
'PreToolUse',
'sessionEnd',
'SessionEnd',
'sessionStart',
'SessionStart',
'subagentStart',
'SubagentStart',
'subagentStop',
'SubagentStop',
'userPromptSubmitted',
'UserPromptSubmit',
'userPromptTransformed',
]);
function fieldLabel(label: string, field: string): string {
return `${label} field "${field}"`;
}
function requireString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`${label} must be a non-empty string`);
}
return value;
}
function validateOptionalString(value: unknown, label: string): void {
if (value !== undefined && typeof value !== 'string') {
throw new Error(`${label} must be a string`);
}
}
function validateTimeout(handler: JsonObject, label: string): void {
for (const field of ['timeout', 'timeoutSec']) {
const value = handler[field];
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)) {
throw new Error(`${fieldLabel(label, field)} must be a positive number`);
}
}
}
function validateMatcher(handler: JsonObject, event: string, label: string): void {
if (handler.matcher === undefined) return;
const matcher = requireString(handler.matcher, fieldLabel(label, 'matcher'));
if ((event === 'PreToolUse' || event === 'PermissionRequest')
&& (matcher === '*' || matcher === '**')) return;
try {
new RegExp(`^(?:${matcher})$`);
} catch (error) {
throw new Error(`${fieldLabel(label, 'matcher')} is not a valid regular expression`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
}
function validateStringMap(value: unknown, label: string): void {
if (!isObject(value)) throw new Error(`${label} must be an object`);
for (const [key, item] of Object.entries(value)) {
requireString(item, `${label}.${key}`);
}
}
function validateCommand(handler: JsonObject, event: string, label: string): void {
const commands = ['bash', 'powershell', 'command']
.map((field) => handler[field])
.filter((value) => value !== undefined);
if (commands.length === 0) {
throw new Error(`${label} must define bash, powershell, or command`);
}
for (const field of ['bash', 'powershell', 'command', 'cwd']) {
validateOptionalString(handler[field], fieldLabel(label, field));
}
if (handler.env !== undefined) validateStringMap(handler.env, fieldLabel(label, 'env'));
validateTimeout(handler, label);
validateMatcher(handler, event, label);
}
function isLoopback(hostname: string): boolean {
return hostname === 'localhost'
|| hostname === '::1'
|| hostname.startsWith('127.');
}
function validateHttp(handler: JsonObject, event: string, label: string): void {
const rawUrl = requireString(handler.url, fieldLabel(label, 'url'));
let url: URL;
try {
url = new URL(rawUrl);
} catch (error) {
throw new Error(`${fieldLabel(label, 'url')} is invalid`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const localhostAllowed = process.env.COPILOT_HOOK_ALLOW_LOCALHOST === '1'
&& url.protocol === 'http:'
&& isLoopback(url.hostname);
if (url.protocol !== 'https:' && !localhostAllowed) {
throw new Error(`${fieldLabel(label, 'url')} must use HTTPS`);
}
if (handler.headers !== undefined) {
validateStringMap(handler.headers, fieldLabel(label, 'headers'));
}
if (handler.allowedEnvVars !== undefined) {
if (!Array.isArray(handler.allowedEnvVars)
|| handler.allowedEnvVars.some((value) => typeof value !== 'string' || !value)) {
throw new Error(`${fieldLabel(label, 'allowedEnvVars')} must be an array of strings`);
}
}
validateTimeout(handler, label);
validateMatcher(handler, event, label);
}
function validatePrompt(handler: JsonObject, event: string, label: string): void {
if (event !== 'sessionStart' && event !== 'SessionStart') {
throw new Error(`${label} prompt hooks are supported only for sessionStart`);
}
requireString(handler.prompt, fieldLabel(label, 'prompt'));
}
function validateHandler(handler: JsonObject, event: string, label: string): void {
const type = handler.type ?? 'command';
if (type === 'command') validateCommand(handler, event, label);
else if (type === 'http') validateHttp(handler, event, label);
else if (type === 'prompt') validatePrompt(handler, event, label);
else throw new Error(`${fieldLabel(label, 'type')} is unsupported`);
}
export 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 (!SUPPORTED_EVENTS.has(event)) throw new Error(`${label} hook event "${event}" is unsupported`);
if (!Array.isArray(handlers)) throw new Error(`${label} field "hooks.${event}" must be an array`);
hooks[event] = handlers.map((handler, index) => {
const itemLabel = `${label} handler hooks.${event}[${index}]`;
if (!isObject(handler)) throw new Error(`${itemLabel} must be an object`);
validateHandler(handler, event, itemLabel);
return handler;
});
}
return hooks;
}
+68 -49
View File
@@ -1,81 +1,100 @@
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { import {
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
buildHandler, buildHandler,
removeManagedHooks, removeManagedHooks,
renderDocument, renderDocument,
runtimeContracts, runtimeContracts,
type CopilotSources,
type RenderedDocument,
} from './copilot-contract.js'; } from './copilot-contract.js';
import { import {
readSources, commitCopilotInstallation,
requireRuntime, commitCopilotUninstall,
runtimeFilesExist, preflightCopilotInstallation,
writeDocuments, prepareCopilotInstallation,
} from './copilot-io.js'; prepareCopilotUninstall,
} from './copilot-installation.js';
import { readSources, runtimeFilesExist } from './copilot-io.js';
import { SUPPORTED_AGENTS } from './registry.js'; import { SUPPORTED_AGENTS } from './registry.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function mergedContracts( function renderInstallation(
userHooks: Parameters<typeof runtimeContracts>[0], sources: CopilotSources,
legacyHooks?: Parameters<typeof runtimeContracts>[0], guardPath: string,
): ReturnType<typeof runtimeContracts> { auditPath: string,
const contracts = [ ): RenderedDocument[] {
...runtimeContracts(userHooks), const userHooks = removeManagedHooks(sources.user.hooks);
...runtimeContracts(legacyHooks ?? {}), userHooks.preToolUse = [...(userHooks.preToolUse ?? []), buildHandler(guardPath)];
userHooks.postToolUse = [...(userHooks.postToolUse ?? []), buildHandler(auditPath)];
userHooks.postToolUseFailure = [
...(userHooks.postToolUseFailure ?? []),
buildHandler(auditPath),
]; ];
const unique = new Map(contracts.map((contract) => [contract.agentId, contract])); const rendered = [renderDocument(sources.user, userHooks)];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks),
));
}
return rendered;
}
function renderUninstall(sources: CopilotSources, agentId?: string): RenderedDocument[] {
const rendered = [
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)),
];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks, agentId),
));
}
return rendered;
}
function mergedContracts(sources: CopilotSources) {
const contracts = [
...runtimeContracts(sources.user.hooks),
...runtimeContracts(sources.legacy?.hooks ?? {}),
];
const unique = new Map(contracts.map((contract) => [
process.platform === 'win32' ? contract.agentId.toLowerCase() : contract.agentId,
contract,
]));
return [...unique.values()]; return [...unique.values()];
} }
export const copilotPlugin: AgentPlugin = { export const copilotPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> { managesRuntime: true,
if (!config.agentId) throw new Error('agentId is required');
async preflightInstall(config: InstallConfig): Promise<void> {
const sources = await readSources(); const sources = await readSources();
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); await preflightCopilotInstallation(config, sources);
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); },
const userHooks = removeManagedHooks(sources.user.hooks); async install(config: InstallConfig): Promise<void> {
userHooks.preToolUse = [ const sources = await readSources();
...(userHooks.preToolUse ?? []), const paths = await preflightCopilotInstallation(config, sources);
buildHandler(config.guardScriptPath), const rendered = renderInstallation(sources, paths.guardPath, paths.auditPath);
]; const prepared = await prepareCopilotInstallation(config, sources, rendered);
userHooks.postToolUse = [ await commitCopilotInstallation(prepared);
...(userHooks.postToolUse ?? []),
buildHandler(config.hookScriptPath),
];
const rendered = [renderDocument(sources.user, userHooks)];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks),
));
}
await writeDocuments(rendered);
console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`); console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`);
console.log(' GitHub Copilot CLI: restart active sessions to load the updated hooks.');
}, },
async uninstall(agentId?: string): Promise<void> { async uninstall(agentId?: string): Promise<void> {
const sources = await readSources(); const sources = await readSources();
const rendered = [ const prepared = await prepareCopilotUninstall(renderUninstall(sources, agentId));
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)), await commitCopilotUninstall(prepared);
];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks, agentId),
));
}
await writeDocuments(rendered);
}, },
async status(): Promise<PluginStatus> { async status(): Promise<PluginStatus> {
const sources = await readSources(); const sources = await readSources();
const contracts = mergedContracts(sources.user.hooks, sources.legacy?.hooks); const contracts = mergedContracts(sources);
const hookConfigured = contracts.length > 0; const hookConfigured = sources.disabledBy === undefined && contracts.length > 0;
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts); const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
return { return {
installed: hookConfigured && hookScriptExists, installed: hookConfigured && hookScriptExists,
@@ -4,7 +4,11 @@ import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime
import type { InstallConfig } from './base.js'; import type { InstallConfig } from './base.js';
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js'; import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
import { generateHookScript, type HookScriptOptions } from './hook-template.js'; import { generateHookScript, type HookScriptOptions } from './hook-template.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; import {
inspectPhysicalDirectory,
readPhysicalFile,
type FileSnapshot,
} from './managed-files.js';
import { import {
commitManagedTransaction, commitManagedTransaction,
prepareManagedFileChange, prepareManagedFileChange,
@@ -35,7 +39,8 @@ export interface ManagedHookLocation {
export interface ManagedHookSource extends ManagedHookLocation { export interface ManagedHookSource extends ManagedHookLocation {
readonly label: string; readonly label: string;
readonly expectedSource?: string; readonly expectedSource?: string;
readonly source: string; readonly expectedSnapshot?: FileSnapshot;
readonly source?: string;
} }
export interface ManagedRuntimeFile { export interface ManagedRuntimeFile {
@@ -292,6 +297,7 @@ export async function prepareManagedInstallation(
next: source.source, next: source.source,
mode: 0o600, mode: 0o600,
expectedSource: source.expectedSource, expectedSource: source.expectedSource,
expectedSnapshot: source.expectedSnapshot,
verifyExpectedSource: true, verifyExpectedSource: true,
})), })),
]); ]);
+44 -1
View File
@@ -17,6 +17,7 @@ export interface ManagedChangeSpec {
readonly mode: number; readonly mode: number;
readonly maximumBytes?: number; readonly maximumBytes?: number;
readonly expectedSource?: string; readonly expectedSource?: string;
readonly expectedSnapshot?: FileSnapshot;
readonly verifyExpectedSource?: boolean; readonly verifyExpectedSource?: boolean;
} }
@@ -34,11 +35,19 @@ export interface ManagedDirectory {
readonly label: string; readonly label: string;
} }
export interface ManagedFilePrecondition {
readonly filePath: string;
readonly label: string;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
export interface PreparedManagedTransaction { export interface PreparedManagedTransaction {
readonly displayName: string; readonly displayName: string;
readonly operation?: 'install' | 'uninstall'; readonly operation?: 'install' | 'uninstall';
readonly directories: readonly ManagedDirectory[]; readonly directories: readonly ManagedDirectory[];
readonly changes: readonly ManagedFileChange[]; readonly changes: readonly ManagedFileChange[];
readonly preconditions?: readonly ManagedFilePrecondition[];
} }
interface StagedChange { interface StagedChange {
@@ -88,6 +97,11 @@ export async function prepareManagedFileChange(
if (spec.verifyExpectedSource && original?.contents !== spec.expectedSource) { if (spec.verifyExpectedSource && original?.contents !== spec.expectedSource) {
throw new Error(`${spec.label} changed before installation: ${spec.filePath}`); throw new Error(`${spec.label} changed before installation: ${spec.filePath}`);
} }
if (spec.expectedSnapshot && (!original
|| original.device !== spec.expectedSnapshot.device
|| original.inode !== spec.expectedSnapshot.inode)) {
throw new Error(`${spec.label} changed before installation: ${spec.filePath}`);
}
if (!original && spec.next === undefined) return undefined; if (!original && spec.next === undefined) return undefined;
return { return {
filePath: spec.filePath, filePath: spec.filePath,
@@ -112,6 +126,28 @@ async function assertUnchanged(change: ManagedFileChange, operationName: string)
} }
} }
async function assertPreconditions(
transaction: PreparedManagedTransaction,
operationName: string,
): Promise<void> {
for (const condition of transaction.preconditions ?? []) {
const current = await readPhysicalFile(
condition.filePath,
condition.label,
condition.maximumBytes,
);
if ((!current && condition.original)
|| (current && !condition.original)
|| (current && condition.original && (
current.contents !== condition.original.contents
|| current.device !== condition.original.device
|| current.inode !== condition.original.inode
))) {
throw new Error(`${condition.label} changed during ${operationName}: ${condition.filePath}`);
}
}
}
async function writeExclusive( async function writeExclusive(
filePath: string, filePath: string,
contents: string, contents: string,
@@ -336,13 +372,20 @@ export async function commitManagedTransaction(
} }
targets.add(key); targets.add(key);
} }
await assertPreconditions(transaction, operationName);
for (const directory of transactionDirectories(transaction)) { for (const directory of transactionDirectories(transaction)) {
await ensureManagedDirectory(directory.path, directory.label); await ensureManagedDirectory(directory.path, directory.label);
} }
await assertPreconditions(transaction, operationName);
const staged: StagedChange[] = []; const staged: StagedChange[] = [];
try { try {
for (const change of transaction.changes) staged.push(await stage(change, operationName)); for (const change of transaction.changes) staged.push(await stage(change, operationName));
for (const item of staged) await commit(item, operationName, renameFile); await assertPreconditions(transaction, operationName);
for (const item of staged) {
await assertPreconditions(transaction, operationName);
await commit(item, operationName, renameFile);
}
await assertPreconditions(transaction, operationName);
} catch (error) { } catch (error) {
const failures = [asError(error)]; const failures = [asError(error)];
for (const item of [...staged].reverse()) { for (const item of [...staged].reverse()) {
+26 -9
View File
@@ -8,6 +8,11 @@ import {
export type JsonObject = Record<string, unknown>; export type JsonObject = Record<string, unknown>;
interface JsonObjectParseOptions {
readonly allowTrailingComma: boolean;
readonly disallowComments: boolean;
}
export function isObject(value: unknown): value is JsonObject { export function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
@@ -36,12 +41,13 @@ function rejectDuplicateKeys(
} }
} }
export function parseStrictJsonObject(raw: string, label: string): JsonObject { function parseJsonObject(
raw: string,
label: string,
options: JsonObjectParseOptions,
): JsonObject {
const errors: ParseError[] = []; const errors: ParseError[] = [];
const value: unknown = parse(raw, errors, { const value: unknown = parse(raw, errors, options);
allowTrailingComma: false,
disallowComments: true,
});
if (errors.length > 0) { if (errors.length > 0) {
const details = errors const details = errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
@@ -49,9 +55,20 @@ export function parseStrictJsonObject(raw: string, label: string): JsonObject {
throw new Error(`Failed to parse ${label}: ${details}`); throw new Error(`Failed to parse ${label}: ${details}`);
} }
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
rejectDuplicateKeys(parseTree(raw, [], { rejectDuplicateKeys(parseTree(raw, [], options), label);
allowTrailingComma: false,
disallowComments: true,
}), label);
return value; return value;
} }
export function parseStrictJsonObject(raw: string, label: string): JsonObject {
return parseJsonObject(raw, label, {
allowTrailingComma: false,
disallowComments: true,
});
}
export function parseStrictJsoncObject(raw: string, label: string): JsonObject {
return parseJsonObject(raw, label, {
allowTrailingComma: true,
disallowComments: false,
});
}
@@ -0,0 +1,227 @@
import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
export const VALID_PRIVATE_KEY = Buffer.alloc(32, 11).toString('base64url');
export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot.js')).href;
export const contractModuleUrl = pathToFileURL(
path.resolve('dist/plugins/copilot-contract.js'),
).href;
export const installationModuleUrl = pathToFileURL(
path.resolve('dist/plugins/copilot-installation.js'),
).href;
export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot-io.js')).href;
export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
export const cliPath = path.resolve('dist/cli.js');
export function runProcess(command, args, env, cwd, input = '') {
return new Promise((resolve, reject) => {
const childEnvironment = { ...process.env, ...env };
for (const [name, value] of Object.entries(env)) {
if (value === undefined) delete childEnvironment[name];
}
const child = spawn(command, args, {
cwd,
env: childEnvironment,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
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);
});
}
export function runNode(args, env, cwd, input = '') {
return runProcess(process.execPath, args, env, cwd, input);
}
export function environment(fixture, copilotHome = fixture.copilotHome) {
return {
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
COPILOT_HOME: copilotHome === null ? undefined : copilotHome,
};
}
export function installConfig(fixture, overrides = {}) {
return {
agentName: 'copilot',
orgId: 'org-1',
agentId: fixture.agentId,
privateKey: VALID_PRIVATE_KEY,
kid: 'kid-1',
token: 'token-1',
baseUrl: fixture.baseUrl,
guardScriptPath: fixture.guardScriptPath,
hookScriptPath: fixture.hookScriptPath,
...overrides,
};
}
export async function runPlugin(fixture, method, argument, copilotHome = fixture.copilotHome) {
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],
{
...environment(fixture, copilotHome),
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
ELYDORA_TEST_METHOD: method,
},
fixture.projectDir,
);
}
export function runHook(handler, fixture, input) {
const command = process.platform === 'win32'
? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', handler.powershell]]
: ['/bin/sh', ['-c', handler.bash]];
return runProcess(command[0], command[1], environment(fixture), fixture.projectDir, input);
}
export async function writeJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`;
await writeFile(filePath, source, { mode: 0o600 });
}
async function writeOptional(filePath, value) {
if (value !== undefined) await writeJson(filePath, value);
}
export async function createFixture({
agentId = 'agent-1',
baseUrl = 'http://127.0.0.1:9',
userConfig,
legacyConfig,
userSettings,
legacyUserConfig,
claudeSettings,
claudeLocalSettings,
repositorySettings,
localSettings,
} = {}) {
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-copilot-'));
const homeDir = path.join(rootDir, "home with spaces and 'quote %COPILOT%");
const projectDir = path.join(rootDir, 'project with spaces');
const copilotHome = path.join(rootDir, "custom Copilot 'home");
const hooksDir = path.join(copilotHome, 'hooks');
const configPath = path.join(hooksDir, 'elydora-audit.json');
const legacyPath = path.join(projectDir, '.github', 'hooks', 'hooks.json');
const agentDir = path.join(homeDir, '.elydora', agentId);
const guardScriptPath = path.join(agentDir, 'guard.js');
const hookScriptPath = path.join(agentDir, 'hook.js');
await mkdir(projectDir, { recursive: true });
await Promise.all([
writeOptional(configPath, userConfig),
writeOptional(legacyPath, legacyConfig),
writeOptional(path.join(copilotHome, 'settings.json'), userSettings),
writeOptional(path.join(copilotHome, 'config.json'), legacyUserConfig),
writeOptional(path.join(projectDir, '.claude', 'settings.json'), claudeSettings),
writeOptional(path.join(projectDir, '.claude', 'settings.local.json'), claudeLocalSettings),
writeOptional(path.join(projectDir, '.github', 'copilot', 'settings.json'), repositorySettings),
writeOptional(path.join(projectDir, '.github', 'copilot', 'settings.local.json'), localSettings),
]);
return {
agentDir,
agentId,
baseUrl,
configPath,
copilotHome,
guardScriptPath,
homeDir,
hookScriptPath,
hooksDir,
legacyPath,
projectDir,
rootDir,
install(overrides = {}, copilotHomeOverride = this.copilotHome) {
return runPlugin(this, 'install', installConfig(this, overrides), copilotHomeOverride);
},
async close() {
await rm(rootDir, { recursive: true, force: true });
},
};
}
export function managedHandler(config, event, scriptName) {
return config.hooks?.[event]?.find(
(handler) => handler.bash?.includes(scriptName) || handler.powershell?.includes(scriptName),
);
}
export function assertNativeHandler(handler) {
if (!handler) throw new Error('Managed handler is missing');
const keys = ['bash', 'powershell', 'timeoutSec', 'type'];
if (JSON.stringify(Object.keys(handler).sort()) !== JSON.stringify(keys)) {
throw new Error(`Unexpected handler fields: ${Object.keys(handler).join(', ')}`);
}
if (handler.type !== 'command' || handler.timeoutSec !== 10) {
throw new Error(`Unexpected handler contract: ${JSON.stringify(handler)}`);
}
}
export 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,
},
};
}
export async function readJson(filePath) {
return JSON.parse(await readFile(filePath, 'utf-8'));
}
export async function startApiServer() {
const requests = [];
const server = http.createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf-8');
requests.push({ method: request.method, url: request.url, raw });
response.writeHead(request.method === 'POST' ? 201 : 200, {
'Content-Type': 'application/json',
});
response.end(request.method === 'POST'
? '{"operation":{"accepted":true}}'
: '{"agent":{"status":"active"}}');
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
close: () => new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
}),
};
}
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import {
createFixture,
environment,
runNode,
} from '../test-support/copilot-test-helpers.mjs';
const copilotEntry = process.env.ELYDORA_COPILOT_ENTRY;
const copilotRuntimeEntry = process.env.ELYDORA_COPILOT_RUNTIME_ENTRY;
test('official GitHub Copilot CLI 1.0.71 loads all three managed hooks', {
skip: copilotEntry && copilotRuntimeEntry
? false
: 'set ELYDORA_COPILOT_ENTRY and ELYDORA_COPILOT_RUNTIME_ENTRY to official package files',
}, async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const version = await runNode(
[copilotEntry, '--version'],
environment(fixture),
fixture.projectDir,
);
assert.equal(version.code, 0, version.stderr);
assert.match(version.stdout, /^GitHub Copilot CLI 1\.0\.71\.$/m);
const source = `
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const runtime = require(process.env.ELYDORA_COPILOT_RUNTIME_ENTRY);
const session = await runtime.hookSessionCreate({
cwd: process.env.ELYDORA_PROJECT,
repoRoot: process.env.ELYDORA_PROJECT,
sessionId: 'elydora-official-test',
settingsJson: '{}',
userHooksDir: process.env.ELYDORA_HOOKS,
allowLocalhost: false,
allowHttpAuthHooks: false,
discoverPolicies: false,
});
try {
const snapshot = JSON.parse(await runtime.hookSessionSnapshot(session.handle));
console.log(JSON.stringify({ load: session.load, snapshot }));
} finally {
runtime.hookSessionDispose(session.handle);
}
`;
const load = await runNode(
['--input-type=module', '--eval', source],
{
...environment(fixture),
ELYDORA_COPILOT_RUNTIME_ENTRY: copilotRuntimeEntry,
ELYDORA_HOOKS: fixture.hooksDir,
ELYDORA_PROJECT: fixture.projectDir,
},
fixture.projectDir,
);
assert.equal(load.code, 0, load.stderr);
const result = JSON.parse(load.stdout);
assert.equal(result.load.hookCount, 3);
assert.deepEqual(result.load.errors, []);
assert.deepEqual(result.load.warnings, []);
assert.deepEqual(
result.snapshot.hooks.map((hook) => hook.eventName).sort(),
['postToolUse', 'postToolUseFailure', 'preToolUse'],
);
for (const hook of result.snapshot.hooks) {
assert.match(hook.source, /hooks[\\/]elydora-audit\.json$/);
const spec = JSON.parse(hook.specJson);
assert.equal(spec.config.type, 'command');
assert.equal(spec.config.timeoutSec, 10);
assert.match(spec.config.bash, /node(?:\.exe)?/i);
assert.match(spec.config.powershell, /^& /);
assert.equal(path.basename(spec.source), 'elydora-audit.json');
}
} finally {
await fixture.close();
}
});
+315 -278
View File
@@ -1,161 +1,49 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import {
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; lstat,
import os from 'node:os'; mkdir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { pathToFileURL } from 'node:url'; import {
VALID_PRIVATE_KEY,
assertNativeHandler,
cliPath,
createFixture,
environment,
legacyManagedConfig,
managedHandler,
readJson,
registryModuleUrl,
runHook,
runNode,
runPlugin,
startApiServer,
writeJson,
} from '../test-support/copilot-test-helpers.mjs';
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot.js')).href; async function assertMissing(filePath) {
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; await assert.rejects(lstat(filePath), { code: 'ENOENT' });
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) { async function assertNoTransactionFiles(fixture) {
const command = process.platform === 'win32' const names = [];
? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', handler.powershell]] for (const directory of [fixture.agentDir, fixture.hooksDir, path.dirname(fixture.legacyPath)]) {
: ['/bin/sh', ['-c', handler.bash]]; try {
return new Promise((resolve, reject) => { names.push(...await readdir(directory));
const child = spawn(command[0], command[1], { stdio: ['pipe', 'pipe', 'pipe'] }); } catch (error) {
let stdout = ''; if (error?.code !== 'ENOENT') throw error;
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); assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
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) { test('GitHub Copilot CLI is registered with its native user hook directory', async () => {
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); const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
assert.deepEqual(SUPPORTED_AGENTS.get('copilot'), { assert.deepEqual(SUPPORTED_AGENTS.get('copilot'), {
name: 'GitHub Copilot CLI', name: 'GitHub Copilot CLI',
@@ -164,44 +52,77 @@ test('GitHub Copilot CLI is registered with the native user hook file', async ()
}); });
}); });
test('Copilot install preserves user hooks, migrates legacy entries, and is idempotent', async () => { test('Copilot installs five managed files, preserves valid hooks, and migrates legacy entries', async () => {
const fixture = await createFixture({ const fixture = await createFixture({
userConfig: { userConfig: {
version: 1, version: 1,
disableAllHooks: false, disableAllHooks: false,
owner: 'user',
hooks: { hooks: {
sessionStart: [{ type: 'command', command: 'user-session-hook' }], sessionStart: [{ type: 'prompt', prompt: '/compact' }],
preToolUse: [{ type: 'command', command: 'user-pre-hook' }], preToolUse: [{ type: 'command', command: 'user-pre-hook', timeout: 3 }],
postToolUse: [{ type: 'command', bash: 'user-post-hook' }],
postToolUseFailure: [{ command: 'user-failure-hook', matcher: 'powershell' }],
}, },
}, },
}); });
try { try {
await writeJson(fixture.legacyPath, legacyManagedConfig(fixture, { await writeJson(fixture.legacyPath, legacyManagedConfig(fixture, {
notification: [{ type: 'command', command: 'user-notification-hook' }], notification: [{
type: 'command',
command: 'user-notification-hook',
matcher: 'agent_idle|permission_prompt',
}],
})); }));
const first = await fixture.install(); const first = await fixture.install();
assert.equal(first.code, 0, first.stderr); assert.equal(first.code, 0, first.stderr);
const second = await fixture.install(); const managedFiles = [
assert.equal(second.code, 0, second.stderr); fixture.guardScriptPath,
path.join(fixture.agentDir, 'config.json'),
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); path.join(fixture.agentDir, 'private.key'),
assert.equal(config.version, 1); fixture.hookScriptPath,
fixture.configPath,
];
const snapshot = new Map(await Promise.all(managedFiles.map(async (filePath) => [
filePath,
await readFile(filePath, 'utf-8'),
])));
const config = JSON.parse(snapshot.get(fixture.configPath));
assert.equal(config.owner, 'user');
assert.equal(config.disableAllHooks, false); assert.equal(config.disableAllHooks, false);
assert.deepEqual(config.hooks.sessionStart, [{ type: 'command', command: 'user-session-hook' }]); assert.deepEqual(config.hooks.sessionStart, [{ type: 'prompt', prompt: '/compact' }]);
assert.deepEqual(config.hooks.preToolUse[0], { type: 'command', command: 'user-pre-hook' });
assert.equal(config.hooks.preToolUse.length, 2); assert.equal(config.hooks.preToolUse.length, 2);
assert.equal(config.hooks.postToolUse.length, 1); assert.equal(config.hooks.postToolUse.length, 2);
assert.equal(config.hooks.postToolUseFailure.length, 2);
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js')); assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js')); assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js'));
assertNativeHandler(managedHandler(config, 'postToolUseFailure', 'hook.js'));
const legacy = JSON.parse(await readFile(fixture.legacyPath, 'utf-8')); assert.deepEqual(await readJson(path.join(fixture.agentDir, 'config.json')), {
assert.deepEqual(legacy, { org_id: 'org-1',
agent_id: fixture.agentId,
kid: 'kid-1',
base_url: fixture.baseUrl,
token: 'token-1',
agent_name: 'copilot',
});
assert.equal(await readFile(path.join(fixture.agentDir, 'private.key'), 'utf-8'), VALID_PRIVATE_KEY);
assert.deepEqual(await readJson(fixture.legacyPath), {
version: 1, version: 1,
hooks: { hooks: {
notification: [{ type: 'command', command: 'user-notification-hook' }], notification: [{
type: 'command',
command: 'user-notification-hook',
matcher: 'agent_idle|permission_prompt',
}],
}, },
}); });
const second = await fixture.install();
assert.equal(second.code, 0, second.stderr);
for (const [filePath, source] of snapshot) {
assert.equal(await readFile(filePath, 'utf-8'), source, filePath);
}
await assertNoTransactionFiles(fixture);
} finally { } finally {
await fixture.close(); await fixture.close();
} }
@@ -213,105 +134,214 @@ test('Copilot migration removes a legacy file owned entirely by Elydora', async
await writeJson(fixture.legacyPath, legacyManagedConfig(fixture)); await writeJson(fixture.legacyPath, legacyManagedConfig(fixture));
const result = await fixture.install(); const result = await fixture.install();
assert.equal(result.code, 0, result.stderr); assert.equal(result.code, 0, result.stderr);
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); await assertMissing(fixture.legacyPath);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); assertNativeHandler(managedHandler(await readJson(fixture.configPath), 'preToolUse', 'guard.js'));
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
} finally { } finally {
await fixture.close(); await fixture.close();
} }
}); });
test('Copilot commands block freezes and forward the native payload byte-for-byte', async () => { test('Copilot guard blocks frozen agents and audit records success and failure payloads', async () => {
const capturePath = path.join(os.tmpdir(), `elydora-copilot-event-${process.pid}-${Date.now()}.json`); const api = await startApiServer();
const fixture = await createFixture({ const fixture = await createFixture({ baseUrl: api.baseUrl });
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 { try {
const install = await fixture.install(); assert.equal((await fixture.install()).code, 0);
assert.equal(install.code, 0, install.stderr); const config = await readJson(fixture.configPath);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'preToolUse', 'guard.js'); const guard = managedHandler(config, 'preToolUse', 'guard.js');
const audit = managedHandler(config, 'postToolUse', 'hook.js'); const successAudit = managedHandler(config, 'postToolUse', 'hook.js');
const prePayload = JSON.stringify({ const failureAudit = managedHandler(config, 'postToolUseFailure', 'hook.js');
const prePayload = {
sessionId: 'session-1', sessionId: 'session-1',
timestamp: Date.now(), timestamp: 1784486400000,
cwd: fixture.projectDir, cwd: fixture.projectDir,
toolName: 'powershell', toolName: 'powershell',
toolArgs: { command: 'Get-ChildItem' }, toolArgs: { command: 'Get-ChildItem' },
};
await writeJson(path.join(fixture.agentDir, 'status-cache.json'), {
status: 'frozen',
cached_at: Date.now(),
}); });
const guardResult = await runHook(guard, prePayload); let result = await runHook(guard, fixture, JSON.stringify(prePayload));
assert.equal(guardResult.code, 2, guardResult.stderr); assert.equal(result.code, 2, result.stderr);
assert.match(guardResult.stderr, /Agent is frozen by Elydora/); assert.equal(result.stdout, '');
assert.match(result.stderr, /Agent "copilot" is frozen/);
const postPayload = JSON.stringify({ await writeJson(path.join(fixture.agentDir, 'status-cache.json'), {
sessionId: 'session-1', status: 'active',
timestamp: Date.now(), cached_at: Date.now(),
cwd: fixture.projectDir,
toolName: 'powershell',
toolArgs: { command: 'Get-ChildItem' },
toolResult: { output: 'ok' },
}); });
const auditResult = await runHook(audit, postPayload); result = await runHook(guard, fixture, JSON.stringify(prePayload));
assert.equal(auditResult.code, 0, auditResult.stderr); assert.equal(result.code, 0, result.stderr);
assert.equal(await readFile(capturePath, 'utf-8'), postPayload); assert.equal(result.stdout, '');
const successPayload = {
...prePayload,
toolResult: { resultType: 'success', textResultForLlm: 'ok' },
};
result = await runHook(successAudit, fixture, JSON.stringify(successPayload));
assert.equal(result.code, 0, result.stderr);
const failurePayload = { ...prePayload, error: 'command failed' };
result = await runHook(failureAudit, fixture, JSON.stringify(failurePayload));
assert.equal(result.code, 0, result.stderr);
const operations = api.requests
.filter((request) => request.method === 'POST')
.map((request) => JSON.parse(request.raw));
assert.equal(operations.length, 2);
assert.deepEqual(operations[0].payload, successPayload);
assert.deepEqual(operations[1].payload, failurePayload);
assert.deepEqual(operations[1].subject, { session_id: 'session-1' });
assert.deepEqual(operations[1].action, { tool: 'powershell' });
} finally { } finally {
await api.close();
await fixture.close(); await fixture.close();
await rm(capturePath, { force: true });
} }
}); });
test('Copilot treats an empty home override as the official default', async () => { test('Copilot uses the official default for an empty COPILOT_HOME', async () => {
const fixture = await createFixture(); const fixture = await createFixture();
try { try {
fixture.copilotHome = ''; const result = await fixture.install({}, '');
const result = await fixture.install();
assert.equal(result.code, 0, result.stderr); assert.equal(result.code, 0, result.stderr);
const defaultPath = path.join(fixture.homeDir, '.copilot', 'hooks', 'elydora-audit.json'); const defaultPath = path.join(fixture.homeDir, '.copilot', 'hooks', 'elydora-audit.json');
const config = JSON.parse(await readFile(defaultPath, 'utf-8')); assertNativeHandler(managedHandler(await readJson(defaultPath), 'preToolUse', 'guard.js'));
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js')); const status = await runPlugin(fixture, 'status', null, '');
const status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).configPath, defaultPath); assert.equal(JSON.parse(status.stdout).configPath, defaultPath);
} finally { } finally {
await fixture.close(); await fixture.close();
} }
}); });
test('Copilot status requires a complete pair, matching runtime identity, and both scripts', async () => { test('Copilot status requires the complete active contract and exact runtime sources', async (t) => {
const fixture = await createFixture(); const cases = [
try { ['missing failure event', 'config', /"hookConfigured":false/],
assert.equal((await fixture.install()).code, 0); ['tampered guard', 'guard', /"installed":false/],
let status = await runPlugin(fixture, 'status', null); ['malformed runtime config', 'runtime', /parse Elydora runtime config/i],
assert.equal(status.code, 0, status.stderr); ['invalid private key', 'key', /private key is invalid/i],
assert.equal(JSON.parse(status.stdout).installed, true); ];
for (const [label, target, expected] of cases) {
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); await t.test(label, async () => {
delete config.hooks.postToolUse; const fixture = await createFixture();
await writeJson(fixture.configPath, config); try {
status = await runPlugin(fixture, 'status', null); assert.equal((await fixture.install()).code, 0);
assert.equal(JSON.parse(status.stdout).hookConfigured, false); const healthy = await runPlugin(fixture, 'status', null);
assert.deepEqual(JSON.parse(healthy.stdout), {
assert.equal((await fixture.install()).code, 0); installed: true,
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); agentName: 'copilot',
status = await runPlugin(fixture, 'status', null); displayName: 'GitHub Copilot CLI',
assert.equal(status.code, 1); hookConfigured: true,
assert.match(status.stderr, /parse Elydora runtime config/i); hookScriptExists: true,
} finally { configPath: fixture.configPath,
await fixture.close(); });
if (target === 'config') {
const config = await readJson(fixture.configPath);
delete config.hooks.postToolUseFailure;
await writeJson(fixture.configPath, config);
} else if (target === 'guard') {
await writeFile(fixture.guardScriptPath, 'tampered\n');
} else if (target === 'runtime') {
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed');
} else {
await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid');
}
const status = await runPlugin(fixture, 'status', null);
assert.match(`${status.stdout}\n${status.stderr}`, expected);
} finally {
await fixture.close();
}
});
} }
}); });
test('Copilot uninstall removes exact ownership and preserves user entries', async () => { test('Copilot resolves disableAllHooks through the official settings precedence', async (t) => {
await t.test('managed file flag blocks installation', async () => {
const fixture = await createFixture({
userConfig: { version: 1, disableAllHooks: true, hooks: {} },
localSettings: { disableAllHooks: false },
});
try {
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /hooks are disabled/i);
await assertMissing(fixture.agentDir);
} finally {
await fixture.close();
}
});
await t.test('local repository settings re-enable user-disabled hooks', async () => {
const fixture = await createFixture({
userSettings: '{\n // user pause\n "disableAllHooks": true,\n}\n',
localSettings: { disableAllHooks: false },
});
try {
const result = await fixture.install();
assert.equal(result.code, 0, result.stderr);
} finally {
await fixture.close();
}
});
await t.test('repository settings disable every non-policy source', async () => {
const fixture = await createFixture({
userSettings: { disableAllHooks: false },
repositorySettings: { disableAllHooks: true },
});
try {
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /Copilot repository settings/);
await assertMissing(fixture.agentDir);
} finally {
await fixture.close();
}
});
await t.test('GitHub repository settings override Claude local settings', async () => {
const fixture = await createFixture({
claudeLocalSettings: { disableAllHooks: true },
repositorySettings: { disableAllHooks: false },
});
try {
const result = await fixture.install();
assert.equal(result.code, 0, result.stderr);
} finally {
await fixture.close();
}
});
});
test('Copilot rejects malformed current schemas and preserves every source', async () => {
const cases = [
'{ malformed',
'{"version":1,"version":1,"hooks":{}}',
{ version: 2, hooks: {} },
{ version: 1, hooks: { futureEvent: [] } },
{ version: 1, hooks: { preToolUse: [{ type: 'unknown' }] } },
{ version: 1, hooks: { postToolUse: [{ type: 'http', url: 'http://example.com' }] } },
{ version: 1, hooks: { sessionEnd: [{ type: 'prompt', prompt: 'continue' }] } },
];
for (const userConfig of cases) {
const fixture = await createFixture({ userConfig });
try {
const before = await readFile(fixture.configPath, 'utf-8');
const result = await fixture.install();
assert.equal(result.code, 1, `${JSON.stringify(userConfig)}\n${result.stderr}`);
assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
await assertMissing(fixture.agentDir);
} finally {
await fixture.close();
}
}
});
test('Copilot uninstall removes exact ownership and preserves adjacent handlers', async () => {
const fixture = await createFixture({ const fixture = await createFixture({
userConfig: { version: 1, hooks: { notification: [{ type: 'command', command: 'keep' }] } }, userConfig: { version: 1, hooks: { notification: [{ command: 'keep' }] } },
}); });
try { try {
assert.equal((await fixture.install()).code, 0); assert.equal((await fixture.install()).code, 0);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); const config = await readJson(fixture.configPath);
config.hooks.preToolUse.push({ config.hooks.preToolUse.push({
type: 'command', type: 'command',
bash: `'${process.execPath}' '${path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js')}'`, bash: `'${process.execPath}' '${path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js')}'`,
@@ -319,12 +349,15 @@ test('Copilot uninstall removes exact ownership and preserves user entries', asy
timeoutSec: 10, timeoutSec: 10,
}); });
await writeJson(fixture.configPath, config); await writeJson(fixture.configPath, config);
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-10')).code, 0);
assert.equal((await readJson(fixture.configPath)).hooks.postToolUseFailure.length, 1);
const result = await runPlugin(fixture, 'uninstall', 'agent-1'); const result = await runPlugin(fixture, 'uninstall', fixture.agentId);
assert.equal(result.code, 0, result.stderr); assert.equal(result.code, 0, result.stderr);
const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); const remaining = await readJson(fixture.configPath);
assert.deepEqual(remaining.hooks.notification, [{ type: 'command', command: 'keep' }]); assert.deepEqual(remaining.hooks.notification, [{ command: 'keep' }]);
assert.equal(remaining.hooks.postToolUse, undefined); assert.equal(remaining.hooks.postToolUse, undefined);
assert.equal(remaining.hooks.postToolUseFailure, undefined);
assert.equal(remaining.hooks.preToolUse.length, 1); assert.equal(remaining.hooks.preToolUse.length, 1);
assert.match(remaining.hooks.preToolUse[0].bash, /agent-10/); assert.match(remaining.hooks.preToolUse[0].bash, /agent-10/);
} finally { } finally {
@@ -332,57 +365,61 @@ test('Copilot uninstall removes exact ownership and preserves user entries', asy
} }
}); });
test('Copilot uninstall leaves absent hook sources absent', async () => { test('Copilot rejects linked hook and runtime paths before writes', async (t) => {
const fixture = await createFixture(); for (const kind of ['home', 'hook', 'runtime']) {
try { await t.test(kind, async () => {
const result = await runPlugin(fixture, 'uninstall', 'agent-1'); const fixture = await createFixture();
assert.equal(result.code, 0, result.stderr); try {
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); const target = path.join(fixture.rootDir, `${kind}-target`);
await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); await mkdir(target, { recursive: true });
} finally { if (kind === 'home') {
await fixture.close(); await rm(fixture.copilotHome, { recursive: true, force: true });
await symlink(target, fixture.copilotHome, 'junction');
} else if (kind === 'hook') {
await mkdir(fixture.hooksDir, { recursive: true });
const targetFile = path.join(target, 'config.json');
await writeJson(targetFile, { version: 1, hooks: {} });
await symlink(targetFile, fixture.configPath, 'file');
} else {
await mkdir(fixture.homeDir, { recursive: true });
await symlink(target, path.join(fixture.homeDir, '.elydora'), 'junction');
}
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /physical (directory|file)/i);
} catch (error) {
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
} finally {
await fixture.close();
}
});
} }
}); });
test('Copilot preserves malformed and invalid configs for recovery', async () => { test('Copilot CLI preflight blocks disabled hooks before runtime creation', async () => {
for (const existing of [ const fixture = await createFixture({ repositorySettings: { disableAllHooks: true } });
'{ malformed', const privateKeyFile = path.join(fixture.rootDir, 'private-key');
{ 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 { try {
const result = await fixture.install(); await writeFile(privateKeyFile, VALID_PRIVATE_KEY, { mode: 0o600 });
const result = await runNode([
'--no-warnings',
cliPath,
'install',
'--agent', 'copilot',
'--org_id', 'org-1',
'--agent_id', fixture.agentId,
'--kid', 'kid-1',
'--private_key_file', privateKeyFile,
'--base_url', fixture.baseUrl,
], environment(fixture), fixture.projectDir);
assert.equal(result.code, 1); assert.equal(result.code, 1);
assert.match(result.stderr, /runtime is missing/i); assert.match(result.stderr, /hooks are disabled/i);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); await assertMissing(path.join(fixture.homeDir, '.elydora'));
} finally { await assertMissing(fixture.configPath);
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 { } finally {
await fixture.close(); await fixture.close();
} }
+328
View File
@@ -0,0 +1,328 @@
import assert from 'node:assert/strict';
import {
lstat,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import {
contractModuleUrl,
createFixture,
environment,
installConfig,
installationModuleUrl,
ioModuleUrl,
legacyManagedConfig,
runNode,
writeJson,
} from '../test-support/copilot-test-helpers.mjs';
const managedFiles = ['guard.js', 'config.json', 'private.key', 'hook.js'];
async function assertMissing(filePath) {
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
}
async function snapshotInstallation(fixture, includeLegacy = false) {
const paths = [
...managedFiles.map((name) => path.join(fixture.agentDir, name)),
fixture.configPath,
...(includeLegacy ? [fixture.legacyPath] : []),
];
return new Map(await Promise.all(paths.map(async (filePath) => [
filePath,
await readFile(filePath, 'utf-8'),
])));
}
async function assertSnapshot(snapshot) {
for (const [filePath, source] of snapshot) {
assert.equal(await readFile(filePath, 'utf-8'), source, filePath);
}
}
async function assertNoTransactionFiles(fixture) {
const names = [];
for (const directory of [fixture.agentDir, fixture.hooksDir, path.dirname(fixture.legacyPath)]) {
try {
names.push(...await readdir(directory));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
}
function transactionEnvironment(fixture, overrides = {}) {
return {
...environment(fixture),
ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, overrides)),
};
}
const readAndRenderSource = `
import {
buildHandler,
removeManagedHooks,
renderDocument,
} from ${JSON.stringify(contractModuleUrl)};
import { readSources } from ${JSON.stringify(ioModuleUrl)};
import {
preflightCopilotInstallation,
prepareCopilotInstallation,
} from ${JSON.stringify(installationModuleUrl)};
const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG);
const sources = await readSources();
`;
const prepareSource = `${readAndRenderSource}
const paths = await preflightCopilotInstallation(config, sources);
const hooks = removeManagedHooks(sources.user.hooks);
hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(paths.guardPath)];
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(paths.auditPath)];
hooks.postToolUseFailure = [
...(hooks.postToolUseFailure ?? []),
buildHandler(paths.auditPath),
];
const rendered = [renderDocument(sources.user, hooks)];
if (sources.legacy) {
rendered.push(renderDocument(sources.legacy, removeManagedHooks(sources.legacy.hooks)));
}
const prepared = await prepareCopilotInstallation(config, sources, rendered);
`;
test('Copilot rolls back runtime, user hooks, and legacy migration after a late failure', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const installed = await readFile(fixture.configPath, 'utf-8');
await writeJson(fixture.legacyPath, installed);
const before = await snapshotInstallation(fixture, true);
const source = `${prepareSource}
import { commitCopilotInstallation } from ${JSON.stringify(installationModuleUrl)};
import { rename } from 'node:fs/promises';
let calls = 0;
await commitCopilotInstallation(prepared, async (from, to) => {
calls += 1;
if (calls === 6) throw new Error('injected Copilot rename failure');
await rename(from, to);
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
transactionEnvironment(fixture, { orgId: 'org-updated', token: 'token-updated' }),
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /injected Copilot rename failure/i);
await assertSnapshot(before);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Copilot detects concurrent hook replacement before committing runtime files', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const concurrent = '{"version":1,"hooks":{"preToolUse":[{"command":"concurrent"}]}}\n';
const source = `${prepareSource}
import { writeFile } from 'node:fs/promises';
import { commitCopilotInstallation } from ${JSON.stringify(installationModuleUrl)};
await writeFile(process.env.ELYDORA_CONCURRENT_PATH, process.env.ELYDORA_CONCURRENT);
await commitCopilotInstallation(prepared);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
...transactionEnvironment(fixture, { orgId: 'org-updated' }),
ELYDORA_CONCURRENT_PATH: fixture.configPath,
ELYDORA_CONCURRENT: concurrent,
},
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /changed during GitHub Copilot CLI installation/i);
for (const [filePath, sourceBefore] of before) {
if (filePath !== fixture.configPath) {
assert.equal(await readFile(filePath, 'utf-8'), sourceBefore, filePath);
}
}
assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Copilot rolls back when effective settings change during installation', async () => {
const fixture = await createFixture({ repositorySettings: { disableAllHooks: false } });
const settingsPath = path.join(
fixture.projectDir,
'.github',
'copilot',
'settings.json',
);
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const concurrent = '{"disableAllHooks":true}\n';
const source = `${prepareSource}
import { rename, writeFile } from 'node:fs/promises';
import { commitCopilotInstallation } from ${JSON.stringify(installationModuleUrl)};
let calls = 0;
await commitCopilotInstallation(prepared, async (from, to) => {
calls += 1;
await rename(from, to);
if (calls === 3) {
await writeFile(process.env.ELYDORA_SETTINGS_PATH, process.env.ELYDORA_CONCURRENT);
}
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
...transactionEnvironment(fixture, { orgId: 'org-updated' }),
ELYDORA_SETTINGS_PATH: settingsPath,
ELYDORA_CONCURRENT: concurrent,
},
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /settings changed during GitHub Copilot CLI installation/i);
await assertSnapshot(before);
assert.equal(await readFile(settingsPath, 'utf-8'), concurrent);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Copilot rejects same-content stale snapshots by physical file identity', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const source = `${readAndRenderSource}
import { readFile, rm, writeFile } from 'node:fs/promises';
const original = await readFile(sources.user.filePath, 'utf-8');
await rm(sources.user.filePath);
await writeFile(sources.user.filePath, original);
const paths = await preflightCopilotInstallation(config, sources);
const hooks = removeManagedHooks(sources.user.hooks);
hooks.preToolUse = [buildHandler(paths.guardPath)];
hooks.postToolUse = [buildHandler(paths.auditPath)];
hooks.postToolUseFailure = [buildHandler(paths.auditPath)];
const rendered = [renderDocument(sources.user, hooks)];
await prepareCopilotInstallation(config, sources, rendered);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
transactionEnvironment(fixture),
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /changed before installation/i);
await assertSnapshot(before);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Copilot preserves orphaned runtime artifacts without a verifiable identity', async () => {
const fixture = await createFixture();
try {
await mkdir(fixture.agentDir, { recursive: true });
await writeFile(fixture.guardScriptPath, 'orphaned guard\n');
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /identity cannot be verified/i);
assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n');
await assertMissing(fixture.configPath);
} finally {
await fixture.close();
}
});
test('Copilot uninstall restores both hook documents when the second removal fails', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const installed = await readFile(fixture.configPath, 'utf-8');
await writeJson(fixture.legacyPath, installed);
const before = await snapshotInstallation(fixture, true);
const source = `
import { removeManagedHooks, renderDocument } from ${JSON.stringify(contractModuleUrl)};
import { readSources } from ${JSON.stringify(ioModuleUrl)};
import {
commitCopilotUninstall,
prepareCopilotUninstall,
} from ${JSON.stringify(installationModuleUrl)};
import { rename } from 'node:fs/promises';
const sources = await readSources();
const rendered = [
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, 'agent-1')),
renderDocument(sources.legacy, removeManagedHooks(sources.legacy.hooks, 'agent-1')),
];
const prepared = await prepareCopilotUninstall(rendered);
let calls = 0;
await commitCopilotUninstall(prepared, async (from, to) => {
calls += 1;
if (calls === 2) throw new Error('injected Copilot uninstall failure');
await rename(from, to);
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
environment(fixture),
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /injected Copilot uninstall failure/i);
await assertSnapshot(before);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Copilot detects legacy migration snapshots that change before preparation', async () => {
const fixture = await createFixture();
try {
await writeJson(fixture.legacyPath, legacyManagedConfig(fixture));
const original = await readFile(fixture.legacyPath, 'utf-8');
const source = `${readAndRenderSource}
import { writeFile } from 'node:fs/promises';
await writeFile(sources.legacy.filePath, process.env.ELYDORA_CHANGED);
const paths = await preflightCopilotInstallation(config, sources);
const hooks = removeManagedHooks(sources.user.hooks);
hooks.preToolUse = [buildHandler(paths.guardPath)];
hooks.postToolUse = [buildHandler(paths.auditPath)];
hooks.postToolUseFailure = [buildHandler(paths.auditPath)];
const rendered = [
renderDocument(sources.user, hooks),
renderDocument(sources.legacy, removeManagedHooks(sources.legacy.hooks)),
];
await prepareCopilotInstallation(config, sources, rendered);
`;
const changed = original.replace('"version": 1', '"version": 1,\n "owner": "concurrent"');
const result = await runNode(
['--input-type=module', '--eval', source],
{ ...transactionEnvironment(fixture), ELYDORA_CHANGED: changed },
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /changed before installation/i);
assert.equal(await readFile(fixture.legacyPath, 'utf-8'), changed);
await assertMissing(fixture.agentDir);
} finally {
await fixture.close();
}
});
@@ -120,11 +120,14 @@ test('high-drift providers retain their verified hook contracts', async () => {
assert.deepEqual(providers.get('copilot').events, { assert.deepEqual(providers.get('copilot').events, {
before_tool: 'preToolUse', before_tool: 'preToolUse',
after_tool: 'postToolUse', after_tool: 'postToolUse',
after_tool_failure: 'postToolUseFailure',
}); });
assert.deepEqual(providers.get('copilot').event_fields, { assert.deepEqual(providers.get('copilot').event_fields, {
name: 'toolName', name: 'toolName',
input: 'toolArgs', input: 'toolArgs',
session: 'sessionId', session: 'sessionId',
output: 'toolResult',
error: 'error',
}); });
assert.deepEqual(providers.get('copilot').blocking, { assert.deepEqual(providers.get('copilot').blocking, {
mechanism: 'any_nonzero_exit', mechanism: 'any_nonzero_exit',