Files
Elydora-Node-SDK/src/plugins/copilot-contract.ts
T
雷电芽衣 db4a6ffb62 chore(node-sdk): audit providers copilot droid augment cline
Providers use plugins/common.ts, shell-command.ts, and managed-runtime-status; isNodeExecutable recognizes the running executable or a node-named binary.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-02 22:00:34 -04:00

213 lines
7.1 KiB
TypeScript

import os from 'node:os';
import path from 'node:path';
import { sameAgentId, samePath } from './common.js';
import type { FileSnapshot } from './managed-files.js';
import {
isNodeExecutable,
parsePosixCommand,
parsePowerShellSource,
posixSource,
powerShellSource,
} from './shell-command.js';
import { validateHooks, type CopilotHooks } from './copilot-schema.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
export const AGENT_KEY = 'copilot';
export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10;
export const CONFIG_FILE = 'elydora-audit.json';
const MANAGED_EVENTS = [
['preToolUse', GUARD_SCRIPT],
['postToolUse', AUDIT_SCRIPT],
['postToolUseFailure', AUDIT_SCRIPT],
] as const;
export interface CopilotDocument {
readonly exists: boolean;
readonly filePath: string;
readonly root: JsonObject;
readonly hooks: CopilotHooks;
readonly hooksDisabled: boolean;
readonly raw?: string;
readonly snapshot?: FileSnapshot;
}
export interface CopilotSources {
readonly user: 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 {
readonly document: CopilotDocument;
readonly changed: boolean;
readonly next?: string;
}
export interface RuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
interface ManagedEntry {
readonly agentId: string;
readonly scriptPath: string;
}
function agentKey(agentId: string): string {
return process.platform === 'win32' ? agentId.toLowerCase() : agentId;
}
export function buildHandler(scriptPath: string): JsonObject {
return {
type: 'command',
bash: posixSource(scriptPath),
powershell: powerShellSource(scriptPath),
timeoutSec: HOOK_TIMEOUT_SECONDS,
};
}
function exactHandlerKeys(handler: JsonObject): boolean {
return Object.keys(handler).sort().join(',') === 'bash,powershell,timeoutSec,type';
}
function currentManagedScriptPath(handler: JsonObject): string | undefined {
if (!exactHandlerKeys(handler)
|| handler.type !== 'command'
|| handler.timeoutSec !== HOOK_TIMEOUT_SECONDS) return undefined;
if (typeof handler.bash !== 'string' || typeof handler.powershell !== 'string') return undefined;
const bash = parsePosixCommand(handler.bash);
const powershell = parsePowerShellSource(handler.powershell);
if (!bash || !powershell
|| !path.isAbsolute(bash[0])
|| !isNodeExecutable(bash[0])
|| !samePath(bash[0], powershell[0])
|| !samePath(bash[1], powershell[1])) return undefined;
return bash[1];
}
function parseLegacyCommand(command: unknown): string | undefined {
if (typeof command !== 'string') return undefined;
return /^node(?:\.exe)?\s+"([^"]+)"$/i.exec(command)?.[1];
}
function legacyManagedScriptPath(handler: JsonObject): string | undefined {
if (!exactHandlerKeys(handler)
|| handler.type !== 'command'
|| handler.timeoutSec !== 5) return undefined;
const bash = parseLegacyCommand(handler.bash);
const powershell = parseLegacyCommand(handler.powershell);
return bash && powershell && samePath(bash, powershell) ? bash : undefined;
}
function managedEntry(handler: JsonObject, scriptName: string): ManagedEntry | undefined {
const scriptPath = currentManagedScriptPath(handler) ?? legacyManagedScriptPath(handler);
if (!scriptPath || path.basename(scriptPath).toLowerCase() !== scriptName.toLowerCase()) {
return undefined;
}
const agentDirectory = path.dirname(scriptPath);
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
return undefined;
}
const agentId = path.basename(agentDirectory);
if (!agentId || agentId === '.' || agentId === '..') return undefined;
return { agentId, scriptPath };
}
export function parseDocument(
filePath: string,
snapshot: FileSnapshot,
label: string,
): CopilotDocument {
const root = parseStrictJsonObject(snapshot.contents, `${label} at ${filePath}`);
if (root.version !== 1) throw new Error(`${label} at ${filePath} must declare version 1`);
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
throw new Error(`${label} at ${filePath} field "disableAllHooks" must be a boolean`);
}
return {
exists: true,
filePath,
root,
hooks: validateHooks(root.hooks, `${label} at ${filePath}`),
hooksDisabled: root.disableAllHooks === true,
raw: snapshot.contents,
snapshot,
};
}
export function createDocument(filePath: string): CopilotDocument {
return { exists: false, filePath, root: {}, hooks: {}, hooksDisabled: false };
}
export function removeManagedHooks(hooks: CopilotHooks, agentId?: string): CopilotHooks {
const next: CopilotHooks = { ...hooks };
for (const [event, scriptName] of MANAGED_EVENTS) {
const handlers = (next[event] ?? []).filter((handler) => {
const owned = managedEntry(handler, scriptName);
return !owned || (agentId !== undefined && !sameAgentId(owned.agentId, agentId));
});
if (handlers.length > 0) next[event] = handlers;
else delete next[event];
}
return next;
}
function isEmptyOwnedDocument(root: JsonObject, hooks: CopilotHooks): boolean {
return Object.keys(hooks).length === 0
&& Object.keys(root).every((key) => key === 'version' || key === 'hooks');
}
export function renderDocument(
document: CopilotDocument,
hooks: CopilotHooks,
): RenderedDocument {
if (!document.exists && Object.keys(hooks).length === 0) {
return { document, changed: false };
}
if (document.exists && isEmptyOwnedDocument(document.root, hooks)) {
return { document, changed: true };
}
const root: JsonObject = { ...document.root, version: 1 };
if (Object.keys(hooks).length > 0) root.hooks = hooks;
else delete root.hooks;
const next = `${JSON.stringify(root, null, 2)}\n`;
return { document, changed: next !== document.raw, next };
}
function managedEntries(handlers: JsonObject[], scriptName: string): Map<string, ManagedEntry> {
const entries = new Map<string, ManagedEntry>();
for (const handler of handlers) {
const entry = managedEntry(handler, scriptName);
if (entry) entries.set(agentKey(entry.agentId), entry);
}
return entries;
}
export function runtimeContracts(hooks: CopilotHooks): RuntimeContract[] {
const guards = managedEntries(hooks.preToolUse ?? [], GUARD_SCRIPT);
const successes = managedEntries(hooks.postToolUse ?? [], AUDIT_SCRIPT);
const failures = managedEntries(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT);
const contracts: RuntimeContract[] = [];
for (const [key, guard] of guards) {
const success = successes.get(key);
const failure = failures.get(key);
if (!success || !failure || !samePath(success.scriptPath, failure.scriptPath)) continue;
contracts.push({
agentId: guard.agentId,
guardPath: guard.scriptPath,
auditPath: success.scriptPath,
});
}
return contracts;
}