From 81ecb372ba3c928a08793ad56478974096959596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 2 Sep 2026 21:41:26 -0400 Subject: [PATCH] chore(node-sdk): audit providers claudecode codex cursor gemini Add plugins/shell-command.ts for the shared quoting and command parsers; runtime status checks go through managed-runtime-status; Codex and Cursor write through managed-transaction. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- src/plugins/claudecode-contract.ts | 15 +- src/plugins/claudecode-installation.ts | 15 +- src/plugins/claudecode-io.ts | 89 +--------- src/plugins/codex-contract.ts | 142 ++------------- src/plugins/codex-installation.ts | 15 +- src/plugins/codex-io.ts | 163 +++-------------- src/plugins/cursor-contract.ts | 109 +++--------- src/plugins/cursor-installation.ts | 29 +-- src/plugins/cursor-io.ts | 235 +++---------------------- src/plugins/gemini-command.ts | 135 ++------------ src/plugins/gemini-contract.ts | 8 +- src/plugins/gemini-installation.ts | 16 +- src/plugins/gemini-io.ts | 86 +-------- src/plugins/managed-runtime-status.ts | 21 +++ src/plugins/shell-command.ts | 121 +++++++++++++ 15 files changed, 285 insertions(+), 914 deletions(-) create mode 100644 src/plugins/shell-command.ts diff --git a/src/plugins/claudecode-contract.ts b/src/plugins/claudecode-contract.ts index 9727343..a96f766 100644 --- a/src/plugins/claudecode-contract.ts +++ b/src/plugins/claudecode-contract.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import path from 'node:path'; +import { sameAgentId, samePath } from './common.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'claudecode'; @@ -76,20 +77,6 @@ interface RuntimeReference { readonly scriptPath: string; } -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - function requireKnownKeys(value: JsonObject, allowed: readonly string[], label: string): void { const keys = new Set(allowed); const extra = Object.keys(value).find((key) => !keys.has(key)); diff --git a/src/plugins/claudecode-installation.ts b/src/plugins/claudecode-installation.ts index 03215a3..153c225 100644 --- a/src/plugins/claudecode-installation.ts +++ b/src/plugins/claudecode-installation.ts @@ -7,7 +7,6 @@ import { } from './claudecode-contract.js'; import { commitManagedInstallation, - managedRuntimePaths, preflightManagedInstallation, prepareManagedInstallation, type ManagedRuntimePaths, @@ -19,18 +18,10 @@ const DISPLAY_NAME = 'Claude Code'; const SETTINGS_DIRECTORY_LABEL = 'Claude Code configuration directory'; const SETTINGS_LABEL = 'Claude Code user settings'; -export type ClaudeRuntimePaths = ManagedRuntimePaths; -export type PreparedClaudeInstallation = PreparedManagedInstallation; -export type { RenameFile }; - -export function claudeRuntimePaths(config: InstallConfig): ClaudeRuntimePaths { - return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); -} - export async function preflightClaudeInstallation( config: InstallConfig, settingsPath: string, -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }], @@ -41,7 +32,7 @@ export async function preflightClaudeInstallation( export async function prepareClaudeInstallation( config: InstallConfig, rendered: RenderedClaudeDocument, -): Promise { +): Promise { if (!rendered.changed && rendered.document.raw === undefined) { throw new Error('Claude Code hook installation did not produce a settings document'); } @@ -63,7 +54,7 @@ export async function prepareClaudeInstallation( } export async function commitClaudeInstallation( - prepared: PreparedClaudeInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/claudecode-io.ts b/src/plugins/claudecode-io.ts index 8a97556..1945308 100644 --- a/src/plugins/claudecode-io.ts +++ b/src/plugins/claudecode-io.ts @@ -10,11 +10,11 @@ import { type RenderedClaudeDocument, } from './claudecode-contract.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; import { commitManagedTransaction, prepareManagedFileChange, } from './managed-transaction.js'; -import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; export function claudeConfigDirectory(): string { const configured = process.env.CLAUDE_CONFIG_DIR; @@ -58,96 +58,11 @@ export async function writeClaudeDocument(rendered: RenderedClaudeDocument): Pro }); } -function requireNonEmptyString(value: unknown, field: string, configPath: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); - } - return value; -} - -function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -function validateRuntimeConfig( - config: JsonObject, - contract: ClaudeRuntimeContract, - 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}`); - requireNonEmptyString(config.org_id, 'org_id', configPath); - requireNonEmptyString(config.kid, 'kid', configPath); - const agentId = requireNonEmptyString(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 Claude Code hooks: ${configPath}`); - } - if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath); - const rawBaseUrl = requireNonEmptyString(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}`); - } -} - -async function runtimeContractExists(contract: ClaudeRuntimeContract): Promise { - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!samePath(path.dirname(agentDirectory), runtimeRoot) - || !samePath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) return false; - if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; - if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false; - - const configPath = path.join(agentDirectory, 'config.json'); - const keyPath = path.join(agentDirectory, 'private.key'); - const [config, key, guard, audit] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config'), - readPhysicalFile(keyPath, 'Elydora private key'), - readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), - readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), - ]); - if (!config || !key || !guard || !audit) return false; - const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); - validateRuntimeConfig(parsed, contract, configPath); - validatePrivateKey(key.contents, keyPath); - return guard.contents.length > 0 && audit.contents.length > 0; -} - export async function claudeRuntimeFilesExist( contracts: readonly ClaudeRuntimeContract[], ): Promise { for (const contract of contracts) { - if (await runtimeContractExists(contract)) return true; + if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true; } return false; } diff --git a/src/plugins/codex-contract.ts b/src/plugins/codex-contract.ts index 699fd14..affb1dd 100644 --- a/src/plugins/codex-contract.ts +++ b/src/plugins/codex-contract.ts @@ -1,5 +1,14 @@ import os from 'node:os'; import path from 'node:path'; +import { sameAgentId, samePath } from './common.js'; +import { + encodedWindowsCommand, + isNodeExecutable, + parseEncodedWindowsCommand, + parseLegacyWindowsCommand, + parsePosixCommand, + quotePosix, +} from './shell-command.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'codex'; @@ -33,142 +42,16 @@ export interface RuntimeContract { readonly auditPath: string; } -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function windowsPowerShellPath(): string { - const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined; - const systemRoot = configuredRoot - && path.win32.isAbsolute(configuredRoot) - && !/["%\r\n]/.test(configuredRoot) - ? configuredRoot - : 'C:\\Windows'; - return path.win32.join( - systemRoot, - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe', - ); -} - -function windowsCommand(scriptPath: string): string { - const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; - const encoded = Buffer.from(source, 'utf16le').toString('base64'); - return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; -} - export function buildHandler(scriptPath: string, statusMessage: string): JsonObject { return { type: 'command', command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`, - commandWindows: windowsCommand(scriptPath), + commandWindows: encodedWindowsCommand(scriptPath), timeout: HOOK_TIMEOUT_SECONDS, statusMessage, }; } -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - const apostrophe = `'"'"'`; - let value = ''; - for (let index = start + 1; index < command.length;) { - if (command.startsWith(apostrophe, index)) { - value += "'"; - index += apostrophe.length; - continue; - } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; - } - return undefined; -} - -function parsePosixCommand(command: unknown): readonly [string, string] | undefined { - if (typeof command !== 'string') return undefined; - const executable = readPosixArgument(command, 0); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readPosixArgument(command, executable.next + 1); - if (!script || script.next !== command.length) return undefined; - return [executable.value, script.value]; -} - -function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length; index += 1) { - if (command[index] !== "'") { - value += command[index]; - continue; - } - if (command[index + 1] === "'") { - value += "'"; - index += 1; - continue; - } - return { value, next: index + 1 }; - } - return undefined; -} - -function parsePowerShellSource(source: string): readonly [string, string] | undefined { - if (!source.startsWith('& ')) return undefined; - const executable = readPowerShellArgument(source, 2); - if (!executable || source[executable.next] !== ' ') return undefined; - const script = readPowerShellArgument(source, executable.next + 1); - if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; -} - -function parseWindowsCommand(command: unknown): readonly [string, string] | undefined { - if (typeof command !== 'string') return undefined; - const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); - if (!match - || !path.win32.isAbsolute(match[1]) - || path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined; - const encoded = match[2]; - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined; - const buffer = Buffer.from(encoded, 'base64'); - if (buffer.toString('base64') !== encoded || buffer.length % 2 !== 0) return undefined; - return parsePowerShellSource(buffer.toString('utf16le')); -} - -function parseLegacyWindowsCommand(command: unknown): readonly [string, string] | undefined { - if (typeof command !== 'string') return undefined; - const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command); - return match ? [match[1], match[2]] : undefined; -} - -function isNodeExecutable(filePath: string): boolean { - const basename = path.basename(filePath); - return basename === 'node' || basename.toLowerCase() === 'node.exe'; -} - function exactHandlerKeys(handler: JsonObject): boolean { return Object.keys(handler).sort().join('|') === 'command|commandWindows|statusMessage|timeout|type'; @@ -179,8 +62,11 @@ function managedScriptPath(handler: JsonObject, statusMessage: string): string | || handler.type !== 'command' || handler.timeout !== HOOK_TIMEOUT_SECONDS || handler.statusMessage !== statusMessage) return undefined; + if (typeof handler.command !== 'string' || typeof handler.commandWindows !== 'string') { + return undefined; + } const posix = parsePosixCommand(handler.command); - const windows = parseWindowsCommand(handler.commandWindows) + const windows = parseEncodedWindowsCommand(handler.commandWindows) ?? parseLegacyWindowsCommand(handler.commandWindows); if (!posix || !windows || !path.isAbsolute(posix[0]) diff --git a/src/plugins/codex-installation.ts b/src/plugins/codex-installation.ts index e576d7f..c9b75e0 100644 --- a/src/plugins/codex-installation.ts +++ b/src/plugins/codex-installation.ts @@ -7,7 +7,6 @@ import { } from './codex-contract.js'; import { commitManagedInstallation, - managedRuntimePaths, preflightManagedInstallation, prepareManagedInstallation, type ManagedRuntimePaths, @@ -19,18 +18,10 @@ const DISPLAY_NAME = 'Codex'; const HOOKS_DIRECTORY_LABEL = 'Codex hooks directory'; const HOOKS_LABEL = 'Codex user hooks'; -export type CodexRuntimePaths = ManagedRuntimePaths; -export type PreparedCodexInstallation = PreparedManagedInstallation; -export type { RenameFile }; - -export function codexRuntimePaths(config: InstallConfig): CodexRuntimePaths { - return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); -} - export async function preflightCodexInstallation( config: InstallConfig, hooksPath: string, -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }], @@ -41,7 +32,7 @@ export async function preflightCodexInstallation( export async function prepareCodexInstallation( config: InstallConfig, rendered: RenderedDocument, -): Promise { +): Promise { if (!rendered.changed && rendered.document.raw === undefined) { throw new Error('Codex hook installation did not produce a configuration document'); } @@ -63,7 +54,7 @@ export async function prepareCodexInstallation( } export async function commitCodexInstallation( - prepared: PreparedCodexInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/codex-io.ts b/src/plugins/codex-io.ts index a98b25e..12b1b4c 100644 --- a/src/plugins/codex-io.ts +++ b/src/plugins/codex-io.ts @@ -1,6 +1,4 @@ -import { randomUUID } from 'node:crypto'; import fsp from 'node:fs/promises'; -import type { FileHandle } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { @@ -12,20 +10,13 @@ import { createDocument, parseDocument, } from './codex-contract.js'; +import { asError, errorMessage } from './common.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; -import { parseStrictJsonObject } from './strict-json.js'; - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function hasCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code; -} +import { managedRuntimePresent } from './managed-runtime-status.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, +} from './managed-transaction.js'; async function codexHomePath(): Promise { const configured = process.env.CODEX_HOME; @@ -63,135 +54,31 @@ export async function readDocument(): Promise { return snapshot ? parseDocument(filePath, snapshot.contents) : createDocument(filePath); } -async function unlinkOptional(filePath: string): Promise { - try { - await fsp.unlink(filePath); - } catch (error) { - if (!hasCode(error, 'ENOENT')) throw error; - } -} - -async function writeExclusive(filePath: string, contents: string): Promise { - let handle: FileHandle | undefined; - try { - handle = await fsp.open(filePath, 'wx', 0o600); - await handle.writeFile(contents, 'utf-8'); - await handle.sync(); - await handle.close(); - handle = undefined; - if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600); - } 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 Codex user hooks'); - throw new Error(`Stage Codex user hooks: ${errorMessage(error)}`, { cause: failures[0] }); - } -} - -async function assertUnchanged(document: CodexDocument): Promise { - const current = await readPhysicalFile(document.filePath, 'Codex user hooks'); - if (current?.contents !== document.raw) { - throw new Error(`Codex user hooks changed during update: ${document.filePath}`); - } -} - -async function ensureHooksDirectory(directory: string): Promise { - try { - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - } catch (error) { - throw new Error(`Create Codex hooks directory at ${directory}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!await inspectPhysicalDirectory(directory, 'Codex hooks directory')) { - throw new Error(`Codex hooks directory is missing: ${directory}`); - } -} - export async function writeDocument(rendered: RenderedDocument): Promise { if (!rendered.changed) return; - await assertUnchanged(rendered.document); - const directory = path.dirname(rendered.document.filePath); - await ensureHooksDirectory(directory); - if (rendered.next === undefined) { - try { - await fsp.unlink(rendered.document.filePath); - } catch (error) { - if (!hasCode(error, 'ENOENT')) { - throw new Error( - `Remove Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, - { cause: asError(error) }, - ); - } - } - return; - } - - const temporaryPath = path.join( - directory, - `.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`, - ); - let failure: Error | undefined; - try { - await writeExclusive(temporaryPath, rendered.next); - await assertUnchanged(rendered.document); - await fsp.rename(temporaryPath, rendered.document.filePath); - } catch (error) { - failure = new Error( - `Write Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, - { cause: asError(error) }, - ); - } - let cleanupFailure: Error | undefined; - try { - await unlinkOptional(temporaryPath); - } catch (error) { - cleanupFailure = new Error( - `Clean Codex hook staging file at ${temporaryPath}: ${errorMessage(error)}`, - { cause: asError(error) }, - ); - } - if (failure && cleanupFailure) { - throw new AggregateError([failure, cleanupFailure], 'Write Codex user hooks'); - } - if (failure) throw failure; - if (cleanupFailure) throw cleanupFailure; -} - -async function physicalFileExists(filePath: string, label: string): Promise { - return Boolean(await readPhysicalFile(filePath, label)); -} - -function sameAgentId(left: unknown, right: string): boolean { - return typeof left === 'string' && (process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right); + const change = await prepareManagedFileChange({ + filePath: rendered.document.filePath, + label: 'Codex user hooks', + next: rendered.next, + mode: 0o600, + expectedSource: rendered.document.raw, + verifyExpectedSource: true, + }); + if (!change) return; + await commitManagedTransaction({ + displayName: 'Codex', + operation: 'uninstall', + directories: [{ + path: path.dirname(rendered.document.filePath), + label: 'Codex hooks directory', + }], + changes: [change], + }); } export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { for (const contract of contracts) { - const directory = path.dirname(contract.guardPath); - const configPath = path.join(directory, 'config.json'); - const snapshot = await readPhysicalFile(configPath, 'Elydora runtime config'); - if (!snapshot) continue; - const config = parseStrictJsonObject( - snapshot.contents, - `Elydora runtime config at ${configPath}`, - ); - if (config.agent_name !== AGENT_KEY || !sameAgentId(config.agent_id, contract.agentId)) { - continue; - } - const files = await Promise.all([ - physicalFileExists(contract.guardPath, 'Elydora guard runtime'), - physicalFileExists(contract.auditPath, 'Elydora audit runtime'), - physicalFileExists(path.join(directory, 'private.key'), 'Elydora private key'), - ]); - if (files.every(Boolean)) return true; + if (await managedRuntimePresent(contract, AGENT_KEY)) return true; } return false; } diff --git a/src/plugins/cursor-contract.ts b/src/plugins/cursor-contract.ts index 5b92f74..1d7050b 100644 --- a/src/plugins/cursor-contract.ts +++ b/src/plugins/cursor-contract.ts @@ -1,15 +1,32 @@ import os from 'node:os'; import path from 'node:path'; +import { sameAgentId, samePath } from './common.js'; +import type { GuardScriptOptions } from './guard-template.js'; +import type { HookScriptOptions } from './hook-template.js'; +import { + isNodeExecutable, + parsePosixCommand, + parsePowerShellSource, + quotePosix, + quotePowerShell, +} from './shell-command.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; -export { isObject, parseStrictJsonObject }; -export type { JsonObject }; - export const AGENT_KEY = 'cursor'; export const CONFIG_FILE = 'hooks.json'; export const GUARD_SCRIPT = 'guard.js'; export const AUDIT_SCRIPT = 'hook.js'; export const HOOK_TIMEOUT_SECONDS = 10; +export const GUARD_OPTIONS: GuardScriptOptions = { + failClosed: true, + successOutput: '{"permission":"allow"}\n', + denyProtocol: 'cursor', +}; +export const AUDIT_OPTIONS: HookScriptOptions = { + failClosed: true, + nativePayload: true, + successOutput: '{}\n', +}; export type CursorHooks = Record; @@ -33,33 +50,6 @@ export interface RuntimeContract { readonly auditPath: string; } -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -export function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - export function buildHandler(scriptPath: string): JsonObject { const command = process.platform === 'win32' ? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE` @@ -71,68 +61,11 @@ export function buildHandler(scriptPath: string): JsonObject { }; } -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - const apostrophe = `'"'"'`; - let value = ''; - for (let index = start + 1; index < command.length;) { - if (command.startsWith(apostrophe, index)) { - value += "'"; - index += apostrophe.length; - continue; - } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; - } - return undefined; -} - -function parsePosixCommand(command: string): readonly [string, string] | undefined { - const executable = readPosixArgument(command, 0); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readPosixArgument(command, executable.next + 1); - if (!script || script.next !== command.length) return undefined; - return [executable.value, script.value]; -} - -function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length; index += 1) { - if (command[index] !== "'") { - value += command[index]; - continue; - } - if (command[index + 1] === "'") { - value += "'"; - index += 1; - continue; - } - return { value, next: index + 1 }; - } - return undefined; -} - -function parsePowerShellCommand(command: string): readonly [string, string] | undefined { - if (!command.startsWith('& ')) return undefined; - const executable = readPowerShellArgument(command, 2); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readPowerShellArgument(command, executable.next + 1); - if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; -} - function parseLegacyCommand(command: string): readonly [string, string] | undefined { const match = /^(node(?:\.exe)?)\s+"([^"\r\n]+)"$/i.exec(command); return match ? [match[1], match[2]] : undefined; } -function isNodeExecutable(filePath: string): boolean { - const basename = path.basename(filePath); - return basename === 'node' || basename.toLowerCase() === 'node.exe'; -} - function managedScriptPath(handler: JsonObject): string | undefined { const keys = Object.keys(handler); if (keys.length === 3 @@ -140,7 +73,7 @@ function managedScriptPath(handler: JsonObject): string | undefined { && handler.timeout === HOOK_TIMEOUT_SECONDS && handler.failClosed === true) { const parsed = process.platform === 'win32' - ? parsePowerShellCommand(handler.command) + ? parsePowerShellSource(handler.command) : parsePosixCommand(handler.command); if (parsed && path.isAbsolute(parsed[0]) diff --git a/src/plugins/cursor-installation.ts b/src/plugins/cursor-installation.ts index 358dc8f..c117137 100644 --- a/src/plugins/cursor-installation.ts +++ b/src/plugins/cursor-installation.ts @@ -1,7 +1,6 @@ import type { InstallConfig } from './base.js'; import { commitManagedInstallation, - managedRuntimePaths, preflightManagedInstallation, prepareManagedInstallation, type ManagedRuntimePaths, @@ -10,7 +9,9 @@ import { } from './managed-installation.js'; import { AGENT_KEY, + AUDIT_OPTIONS, AUDIT_SCRIPT, + GUARD_OPTIONS, GUARD_SCRIPT, type RenderedDocument, } from './cursor-contract.js'; @@ -20,17 +21,9 @@ const DISPLAY_NAME = 'Cursor'; const HOOKS_DIRECTORY_LABEL = 'Cursor hooks directory'; const HOOKS_LABEL = 'Cursor user hooks'; -export type CursorRuntimePaths = ManagedRuntimePaths; -export type PreparedCursorInstallation = PreparedManagedInstallation; -export type { RenameFile }; - -export function cursorRuntimePaths(config: InstallConfig): CursorRuntimePaths { - return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); -} - export async function preflightCursorInstallation( config: InstallConfig, -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: [{ @@ -44,7 +37,7 @@ export async function preflightCursorInstallation( export async function prepareCursorInstallation( config: InstallConfig, rendered: RenderedDocument, -): Promise { +): Promise { if (!rendered.changed && rendered.document.raw === undefined) { throw new Error('Cursor hook installation did not produce a configuration document'); } @@ -61,21 +54,13 @@ export async function prepareCursorInstallation( source: hooksSource, }], config, - guardOptions: { - failClosed: true, - successOutput: '{"permission":"allow"}\n', - denyProtocol: 'cursor', - }, - auditOptions: { - failClosed: true, - nativePayload: true, - successOutput: '{}\n', - }, + guardOptions: GUARD_OPTIONS, + auditOptions: AUDIT_OPTIONS, }, GUARD_SCRIPT, AUDIT_SCRIPT); } export async function commitCursorInstallation( - prepared: PreparedCursorInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/cursor-io.ts b/src/plugins/cursor-io.ts index 84bfe89..f92ca11 100644 --- a/src/plugins/cursor-io.ts +++ b/src/plugins/cursor-io.ts @@ -1,237 +1,56 @@ -import { randomUUID } from 'node:crypto'; -import fsp from 'node:fs/promises'; -import type { FileHandle } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { AGENT_KEY, CONFIG_FILE, type CursorDocument, - type JsonObject, type RenderedDocument, type RuntimeContract, createDocument, - isObject, parseDocument, - parseStrictJsonObject, } from './cursor-contract.js'; - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function hasErrorCode(error: unknown, code: string): boolean { - return isObject(error) && error.code === code; -} +import { readPhysicalFile } from './managed-files.js'; +import { managedRuntimePresent } from './managed-runtime-status.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, +} from './managed-transaction.js'; export function cursorConfigPath(): string { return path.join(os.homedir(), '.cursor', CONFIG_FILE); } -async function readOptionalPhysical(filePath: string, label: string): Promise { - let metadata; - try { - metadata = await fsp.lstat(filePath); - } catch (error) { - if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return undefined; - throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!metadata.isFile() || metadata.isSymbolicLink()) { - throw new Error(`${label} path is not a physical file: ${filePath}`); - } - try { - return await fsp.readFile(filePath, 'utf-8'); - } catch (error) { - throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } -} - export async function readDocument(): Promise { const filePath = cursorConfigPath(); - const raw = await readOptionalPhysical(filePath, 'Cursor user hooks'); - return raw === undefined ? createDocument(filePath) : parseDocument(filePath, raw); -} - -async function ensurePhysicalDirectory(directory: string): Promise { - try { - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - } catch (error) { - throw new Error(`Create Cursor hooks directory at ${directory}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - let metadata; - try { - metadata = await fsp.lstat(directory); - } catch (error) { - throw new Error(`Inspect Cursor hooks directory at ${directory}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`Cursor hooks directory is not a physical directory: ${directory}`); - } -} - -async function unlinkOptional(filePath: string): Promise { - try { - await fsp.unlink(filePath); - } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) throw error; - } -} - -async function writeExclusive(filePath: string, contents: string): Promise { - let handle: FileHandle | undefined; - try { - handle = await fsp.open(filePath, 'wx', 0o600); - await handle.writeFile(contents, 'utf-8'); - await handle.sync(); - await handle.close(); - handle = undefined; - } catch (error) { - const failures = [asError(error)]; - if (handle) { - try { - await handle.close(); - } catch (closeError) { - failures.push(asError(closeError)); - } - } - try { - await unlinkOptional(filePath); - } catch (cleanupError) { - failures.push(asError(cleanupError)); - } - if (failures.length > 1) { - throw new AggregateError(failures, 'Stage Cursor user hooks'); - } - throw new Error(`Stage Cursor user hooks: ${errorMessage(error)}`, { cause: failures[0] }); - } -} - -async function assertUnchanged(document: CursorDocument): Promise { - const current = await readOptionalPhysical(document.filePath, 'Cursor user hooks'); - if (current !== document.raw) { - throw new Error(`Cursor user hooks changed during update: ${document.filePath}`); - } + const snapshot = await readPhysicalFile(filePath, 'Cursor user hooks'); + return snapshot ? parseDocument(filePath, snapshot.contents) : createDocument(filePath); } export async function writeDocument(rendered: RenderedDocument): Promise { if (!rendered.changed) return; - await assertUnchanged(rendered.document); - if (rendered.next === undefined) { - try { - await fsp.unlink(rendered.document.filePath); - } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) { - throw new Error( - `Remove Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, - { cause: asError(error) }, - ); - } - } - return; - } - - const directory = path.dirname(rendered.document.filePath); - await ensurePhysicalDirectory(directory); - const temporaryPath = path.join( - directory, - `.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`, - ); - let committed = false; - let failure: Error | undefined; - try { - await writeExclusive(temporaryPath, rendered.next); - await assertUnchanged(rendered.document); - await fsp.rename(temporaryPath, rendered.document.filePath); - committed = true; - } catch (error) { - failure = new Error( - `Write Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, - { cause: asError(error) }, - ); - } - let cleanupFailure: Error | undefined; - if (!committed) { - try { - await unlinkOptional(temporaryPath); - } catch (error) { - cleanupFailure = new Error( - `Clean Cursor hook staging file at ${temporaryPath}: ${errorMessage(error)}`, - { cause: asError(error) }, - ); - } - } - if (failure && cleanupFailure) { - throw new AggregateError([failure, cleanupFailure], 'Write Cursor user hooks'); - } - if (failure) throw failure; - if (cleanupFailure) throw cleanupFailure; -} - -export async function physicalFileExists(filePath: string, label: string): Promise { - let metadata; - try { - metadata = await fsp.lstat(filePath); - } catch (error) { - if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; - throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!metadata.isFile() || metadata.isSymbolicLink()) { - throw new Error(`${label} path is not a physical file: ${filePath}`); - } - return true; -} - -export async function requireRuntime(filePath: string, label: string): Promise { - if (!filePath) throw new Error(`${label} path is required`); - if (!await physicalFileExists(filePath, label)) { - throw new Error(`${label} is missing: ${filePath}`); - } -} - -async function readRuntimeConfig(filePath: string): Promise { - const raw = await readOptionalPhysical(filePath, 'Elydora runtime config'); - if (raw === undefined) return undefined; - return parseStrictJsonObject(raw, `Elydora runtime config at ${filePath}`); -} - -function sameAgentId(left: unknown, right: string): boolean { - if (typeof left !== 'string') return false; - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; + const change = await prepareManagedFileChange({ + filePath: rendered.document.filePath, + label: 'Cursor user hooks', + next: rendered.next, + mode: 0o600, + expectedSource: rendered.document.raw, + verifyExpectedSource: true, + }); + if (!change) return; + await commitManagedTransaction({ + displayName: 'Cursor', + operation: 'uninstall', + directories: [{ + path: path.dirname(rendered.document.filePath), + label: 'Cursor hooks directory', + }], + changes: [change], + }); } export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { for (const contract of contracts) { - const runtimeConfig = await readRuntimeConfig( - path.join(path.dirname(contract.guardPath), 'config.json'), - ); - if (!runtimeConfig - || runtimeConfig.agent_name !== AGENT_KEY - || !sameAgentId(runtimeConfig.agent_id, contract.agentId)) continue; - const files = await Promise.all([ - physicalFileExists(contract.guardPath, 'Elydora guard runtime'), - physicalFileExists(contract.auditPath, 'Elydora audit runtime'), - physicalFileExists( - path.join(path.dirname(contract.guardPath), 'private.key'), - 'Elydora private key', - ), - ]); - if (files.every(Boolean)) return true; + if (await managedRuntimePresent(contract, AGENT_KEY)) return true; } return false; } diff --git a/src/plugins/gemini-command.ts b/src/plugins/gemini-command.ts index c9e891f..c5e0d8f 100644 --- a/src/plugins/gemini-command.ts +++ b/src/plugins/gemini-command.ts @@ -1,58 +1,25 @@ import os from 'node:os'; import path from 'node:path'; - -interface ParsedArgument { - readonly value: string; - readonly next: number; -} +import { samePath } from './common.js'; +import { + decodePowerShellSource, + encodePowerShellSource, + isNodeExecutable, + isPowerShellExecutable, + parsePosixCommand, + parsePowerShellSource, + quotePosix, + quotePowerShell, + windowsPowerShellPath, +} from './shell-command.js'; export interface GeminiRuntimeReference { readonly agentId: string; readonly scriptPath: string; } -export function sameGeminiPath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -export function sameGeminiAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function windowsPowerShellPath(): string { - const configuredRoot = process.env.SystemRoot; - const systemRoot = configuredRoot - && path.win32.isAbsolute(configuredRoot) - && !/["%\r\n]/.test(configuredRoot) - ? configuredRoot - : 'C:\\Windows'; - return path.win32.join( - systemRoot, - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe', - ); -} - function windowsCommand(scriptPath: string): string { - const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; - const encoded = Buffer.from(source, 'utf16le').toString('base64'); - return `& ${quotePowerShell(windowsPowerShellPath())} -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; + return `& ${quotePowerShell(windowsPowerShellPath())} -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellSource(scriptPath)}`; } export function buildGeminiCommand(scriptPath: string): string { @@ -64,71 +31,12 @@ export function buildGeminiCommand(scriptPath: string): string { : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; } -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - const apostrophe = `'"'"'`; - let value = ''; - for (let index = start + 1; index < command.length;) { - if (command.startsWith(apostrophe, index)) { - value += "'"; - index += apostrophe.length; - continue; - } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; - } - return undefined; -} - -function parsePosixCommand(command: string): readonly [string, string] | undefined { - const executable = readPosixArgument(command, 0); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readPosixArgument(command, executable.next + 1); - if (!script || script.next !== command.length) return undefined; - return [executable.value, script.value]; -} - -function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length; index += 1) { - if (command[index] !== "'") { - value += command[index]; - continue; - } - if (command[index + 1] === "'") { - value += "'"; - index += 1; - continue; - } - return { value, next: index + 1 }; - } - return undefined; -} - -function parsePowerShellSource(source: string): readonly [string, string] | undefined { - if (!source.startsWith('& ')) return undefined; - const executable = readPowerShellArgument(source, 2); - if (!executable || source[executable.next] !== ' ') return undefined; - const script = readPowerShellArgument(source, executable.next + 1); - if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; -} - function parseWindowsCommand(command: string): readonly [string, string] | undefined { - if (!command.startsWith('& ')) return undefined; - const powershell = readPowerShellArgument(command, 2); - if (!powershell - || !path.win32.isAbsolute(powershell.value) - || path.win32.basename(powershell.value).toLowerCase() !== 'powershell.exe') return undefined; - const prefix = ' -NoLogo -NoProfile -NonInteractive -EncodedCommand '; - if (!command.startsWith(prefix, powershell.next)) return undefined; - const encoded = command.slice(powershell.next + prefix.length); - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined; - const buffer = Buffer.from(encoded, 'base64'); - if (buffer.length % 2 !== 0 || buffer.toString('base64') !== encoded) return undefined; - return parsePowerShellSource(buffer.toString('utf16le')); + const match = /^& ('(?:[^']|'')+') -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); + if (!match) return undefined; + const powershell = parsePowerShellSource(`& ${match[1]} ''; exit $LASTEXITCODE`); + if (!powershell || !isPowerShellExecutable(powershell[0])) return undefined; + return decodePowerShellSource(match[2]); } function parseLegacyCommand(command: string): readonly [string, string] | undefined { @@ -136,11 +44,6 @@ function parseLegacyCommand(command: string): readonly [string, string] | undefi return match ? ['node', match[1]] : undefined; } -function isNodeExecutable(filePath: string): boolean { - const basename = path.basename(filePath).toLowerCase(); - return basename === 'node' || basename === 'node.exe'; -} - export function geminiRuntimeReference( command: string, scriptName: string, @@ -155,7 +58,7 @@ export function geminiRuntimeReference( || path.basename(parsed[1]) !== scriptName) return undefined; if (parsed[0] !== 'node' && !path.isAbsolute(parsed[0])) return undefined; const agentDirectory = path.dirname(parsed[1]); - if (!sameGeminiPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { return undefined; } const agentId = path.basename(agentDirectory); diff --git a/src/plugins/gemini-contract.ts b/src/plugins/gemini-contract.ts index c521fb8..8975d34 100644 --- a/src/plugins/gemini-contract.ts +++ b/src/plugins/gemini-contract.ts @@ -1,9 +1,11 @@ +import { sameAgentId } from './common.js'; import { buildGeminiCommand, geminiRuntimeReference, - sameGeminiAgentId, type GeminiRuntimeReference, } from './gemini-command.js'; +import type { GuardScriptOptions } from './guard-template.js'; +import type { HookScriptOptions } from './hook-template.js'; import { isObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'gemini'; @@ -14,6 +16,8 @@ export const GUARD_HOOK_NAME = 'elydora-guard'; export const AUDIT_HOOK_NAME = 'elydora-audit'; export const HOOK_TIMEOUT_MS = 10_000; export const MANAGED_EVENTS = ['BeforeTool', 'AfterTool'] as const; +export const GUARD_OPTIONS: GuardScriptOptions = { successOutput: '{}\n' }; +export const AUDIT_OPTIONS: HookScriptOptions = { nativePayload: true, successOutput: '{}\n' }; const KNOWN_EVENTS = new Set([ 'BeforeTool', @@ -248,7 +252,7 @@ export function managedGeminiRemovals( const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => { const reference = managedReference(handler, scriptName, hookName, true); return reference - && (agentId === undefined || sameGeminiAgentId(reference.agentId, agentId)) + && (agentId === undefined || sameAgentId(reference.agentId, agentId)) ? [handlerIndex] : []; }); diff --git a/src/plugins/gemini-installation.ts b/src/plugins/gemini-installation.ts index a46587b..fb4bb9d 100644 --- a/src/plugins/gemini-installation.ts +++ b/src/plugins/gemini-installation.ts @@ -1,7 +1,9 @@ import type { InstallConfig } from './base.js'; import { AGENT_KEY, + AUDIT_OPTIONS, AUDIT_SCRIPT, + GUARD_OPTIONS, GUARD_SCRIPT, } from './gemini-contract.js'; import type { RenderedGeminiDocument } from './gemini-config.js'; @@ -18,14 +20,10 @@ const DISPLAY_NAME = 'Gemini CLI'; const SETTINGS_DIRECTORY_LABEL = 'Gemini CLI configuration directory'; const SETTINGS_LABEL = 'Gemini CLI user settings'; -export type GeminiRuntimePaths = ManagedRuntimePaths; -export type PreparedGeminiInstallation = PreparedManagedInstallation; -export type { RenameFile }; - export async function preflightGeminiInstallation( config: InstallConfig, settingsPath: string, -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }], @@ -36,7 +34,7 @@ export async function preflightGeminiInstallation( export async function prepareGeminiInstallation( config: InstallConfig, rendered: RenderedGeminiDocument, -): Promise { +): Promise { const settingsSource = rendered.next ?? rendered.document.raw; if (settingsSource === undefined) throw new Error('Gemini CLI hook settings are missing'); return prepareManagedInstallation({ @@ -50,13 +48,13 @@ export async function prepareGeminiInstallation( source: settingsSource, }], config, - guardOptions: { successOutput: '{}\n' }, - auditOptions: { nativePayload: true, successOutput: '{}\n' }, + guardOptions: GUARD_OPTIONS, + auditOptions: AUDIT_OPTIONS, }, GUARD_SCRIPT, AUDIT_SCRIPT); } export async function commitGeminiInstallation( - prepared: PreparedGeminiInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/gemini-io.ts b/src/plugins/gemini-io.ts index 97469ba..ad97117 100644 --- a/src/plugins/gemini-io.ts +++ b/src/plugins/gemini-io.ts @@ -2,7 +2,9 @@ import os from 'node:os'; import path from 'node:path'; import { AGENT_KEY, + AUDIT_OPTIONS, CONFIG_FILE, + GUARD_OPTIONS, type GeminiRuntimeContract, } from './gemini-contract.js'; import { @@ -11,13 +13,12 @@ import { type GeminiDocument, type RenderedGeminiDocument, } from './gemini-config.js'; -import { sameGeminiAgentId, sameGeminiPath } from './gemini-command.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; import { commitManagedTransaction, prepareManagedFileChange, } from './managed-transaction.js'; -import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; export function geminiConfigurationDirectory(): string { const configuredHome = process.env.GEMINI_CLI_HOME; @@ -64,86 +65,15 @@ export async function writeGeminiDocument(rendered: RenderedGeminiDocument): Pro }); } -function requireNonEmptyString(value: unknown, field: string, configPath: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); - } - return value; -} - -function validateRuntimeConfig( - config: JsonObject, - contract: GeminiRuntimeContract, - 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}`); - } - requireNonEmptyString(config.org_id, 'org_id', configPath); - requireNonEmptyString(config.kid, 'kid', configPath); - const agentId = requireNonEmptyString(config.agent_id, 'agent_id', configPath); - if (!sameGeminiAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Gemini CLI hooks: ${configPath}`); - } - if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath); - const rawBaseUrl = requireNonEmptyString(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}`); - } -} - -async function runtimeContractExists(contract: GeminiRuntimeContract): Promise { - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!sameGeminiPath(path.dirname(agentDirectory), runtimeRoot) - || !sameGeminiPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) return false; - if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; - if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) { - return false; - } - - const configPath = path.join(agentDirectory, 'config.json'); - const keyPath = path.join(agentDirectory, 'private.key'); - const [config, key, guard, audit] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config'), - readPhysicalFile(keyPath, 'Elydora private key'), - readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), - readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), - ]); - if (!config || !key || !guard || !audit) return false; - const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); - validateRuntimeConfig(parsed, contract, configPath); - validatePrivateKey(key.contents, keyPath); - return guard.contents.length > 0 && audit.contents.length > 0; -} - export async function geminiRuntimeFilesExist( contracts: readonly GeminiRuntimeContract[], ): Promise { for (const contract of contracts) { - if (await runtimeContractExists(contract)) return true; + const exists = await managedRuntimeFilesExist(contract, AGENT_KEY, { + guardOptions: GUARD_OPTIONS, + auditOptions: AUDIT_OPTIONS, + }); + if (exists) return true; } return false; } diff --git a/src/plugins/managed-runtime-status.ts b/src/plugins/managed-runtime-status.ts index 0cc8089..08ccdd9 100644 --- a/src/plugins/managed-runtime-status.ts +++ b/src/plugins/managed-runtime-status.ts @@ -111,3 +111,24 @@ export async function managedRuntimeFilesExist( options.auditOptions ?? { nativePayload: true }, ); } + +// Presence-only check: identity mismatch reports false; parse failures still throw. +export async function managedRuntimePresent( + contract: ManagedRuntimeContract, + agentKey: string, +): Promise { + const agentDirectory = path.dirname(contract.guardPath); + const configPath = path.join(agentDirectory, 'config.json'); + const snapshot = await readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES); + if (!snapshot) return false; + const config = parseStrictJsonObject(snapshot.contents, `Elydora runtime config at ${configPath}`); + if (config.agent_name !== agentKey || !sameAgentId(config.agent_id, contract.agentId)) { + return false; + } + const files = await Promise.all([ + readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), + readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), + readPhysicalFile(path.join(agentDirectory, 'private.key'), 'Elydora private key', MAX_SECRET_BYTES), + ]); + return files.every(Boolean); +} diff --git a/src/plugins/shell-command.ts b/src/plugins/shell-command.ts new file mode 100644 index 0000000..55baa78 --- /dev/null +++ b/src/plugins/shell-command.ts @@ -0,0 +1,121 @@ +import path from 'node:path'; + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export type CommandParts = readonly [executable: string, script: string]; + +const POSIX_APOSTROPHE = `'"'"'`; +const POWERSHELL_EXIT_SUFFIX = '; exit $LASTEXITCODE'; +const ENCODED_COMMAND_FLAGS = '-NoLogo -NoProfile -NonInteractive -EncodedCommand'; + +export function quotePosix(value: string): string { + return `'${value.replaceAll("'", POSIX_APOSTROPHE)}'`; +} + +export function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function windowsPowerShellPath(): string { + const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined; + const systemRoot = configuredRoot + && path.win32.isAbsolute(configuredRoot) + && !/["%\r\n]/.test(configuredRoot) + ? configuredRoot + : 'C:\\Windows'; + return path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +} + +export function powerShellSource(scriptPath: string): string { + return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}${POWERSHELL_EXIT_SUFFIX}`; +} + +export function encodePowerShellSource(scriptPath: string): string { + return Buffer.from(powerShellSource(scriptPath), 'utf16le').toString('base64'); +} + +export function encodedWindowsCommand(scriptPath: string): string { + return `"${windowsPowerShellPath()}" ${ENCODED_COMMAND_FLAGS} ${encodePowerShellSource(scriptPath)}`; +} + +export function isNodeExecutable(filePath: string): boolean { + const basename = path.basename(filePath); + return basename === 'node' || basename.toLowerCase() === 'node.exe'; +} + +export function isPowerShellExecutable(filePath: string): boolean { + return path.win32.isAbsolute(filePath) + && path.win32.basename(filePath).toLowerCase() === 'powershell.exe'; +} + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(POSIX_APOSTROPHE, index)) { + value += "'"; + index += POSIX_APOSTROPHE.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +export function parsePosixCommand(command: string): CommandParts | undefined { + const executable = readPosixArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPosixArgument(command, executable.next + 1); + if (!script || script.next !== command.length) return undefined; + return [executable.value, script.value]; +} + +function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] !== "'") { + value += command[index]; + continue; + } + if (command[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return { value, next: index + 1 }; + } + return undefined; +} + +export function parsePowerShellSource(source: string): CommandParts | undefined { + if (!source.startsWith('& ')) return undefined; + const executable = readPowerShellArgument(source, 2); + if (!executable || source[executable.next] !== ' ') return undefined; + const script = readPowerShellArgument(source, executable.next + 1); + if (!script || source.slice(script.next) !== POWERSHELL_EXIT_SUFFIX) return undefined; + return [executable.value, script.value]; +} + +export function decodePowerShellSource(encoded: string): CommandParts | undefined { + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined; + const buffer = Buffer.from(encoded, 'base64'); + if (buffer.toString('base64') !== encoded || buffer.length % 2 !== 0) return undefined; + return parsePowerShellSource(buffer.toString('utf16le')); +} + +export function parseEncodedWindowsCommand(command: string): CommandParts | undefined { + const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); + if (!match || !isPowerShellExecutable(match[1])) return undefined; + return decodePowerShellSource(match[2]); +} + +export function parseLegacyWindowsCommand(command: string): CommandParts | undefined { + const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command); + return match ? [match[1], match[2]] : undefined; +}