diff --git a/AGENTS.md b/AGENTS.md index 040356e..dca56a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Resolve Codex user hooks through `CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Preserve user TOML, project, plugin, and managed hook sources because Codex composes them additively. Register exact `PreToolUse` and `PostToolUse` matcher groups with ten-second command handlers, preserve the complete native payload, propagate freeze and revocation through exit code `2`, and keep guard lookup and audit delivery fail-open with observable errors. Commit Codex's user hooks, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Use `/hooks` to approve both definition hashes after installation. - Resolve stable Kimi hooks through `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and treat an empty override as the default. Activate `~/.kimi/config.toml` only when the legacy home exists. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules with ten-second commands; stable Kimi supports its current sixteen-event schema and legacy kimi-cli supports its thirteen-event schema. Preserve the native snake_case payload, propagate exit code `2`, and keep audit delivery fail-open with owner-only error logs. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit every detected config, generated runtime, runtime config, and private key through one rollback-capable transaction. - Resolve Grok Build user hooks through `$GROK_HOME/hooks/*.json` with `~/.grok/hooks/*.json` as the default, and treat an empty override as the default. Keep Claude Code, Cursor, project `.grok/hooks`, plugin, and `hooks-paths` sources read-only. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second command handlers, preserve the complete native camelCase payload, and emit Grok's documented deny JSON plus exit code `2` for frozen or revoked agents. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit Grok's hook file, generated runtimes, runtime config, and private key through one rollback-capable transaction. +- Resolve Claude Code user hooks through `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` when the variable is absent. Match Claude Code's path resolution exactly: relative and empty values resolve from the current working directory, and literal tildes remain path segments. Keep project, local, managed, plugin, skill, and agent hook sources read-only. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second exec-form command handlers. Preserve the complete native snake_case payload and propagate freeze and revocation through exit code `2`. Commit Claude Code's user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject installation while the user source sets `disableAllHooks`, then require `/hooks` and `claude doctor` verification for higher-scope policy effects. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte. - Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler and propagate guard exit code `2` through the host shell. diff --git a/README.md b/README.md index 506de22..d3dafdc 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ The CLI reads the private key and optional API token through hidden terminal pro | GitHub Copilot CLI | `copilot` | | Letta Code | `letta` | +Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups to `$CLAUDE_CONFIG_DIR/settings.json`, with `~/.claude/settings.json` selected when the variable is absent. Relative and empty overrides resolve from the current working directory, matching Claude Code's native path behavior. Each handler uses Claude Code's cross-platform exec form with the current Node.js executable, a single script argument, and a ten-second timeout. Managed runtimes preserve the complete native snake_case event payload and enforce frozen or revoked state through exit code `2`. Project, local, managed, plugin, skill, and agent hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. `disableAllHooks` blocks installation; managed hook policy and safe mode can suppress user hooks. Run `/hooks` and `claude doctor` after installation. + 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. 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. diff --git a/src/plugins/claudecode-contract.ts b/src/plugins/claudecode-contract.ts new file mode 100644 index 0000000..21e97c9 --- /dev/null +++ b/src/plugins/claudecode-contract.ts @@ -0,0 +1,409 @@ +import os from 'node:os'; +import path from 'node:path'; +import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; + +export const AGENT_KEY = 'claudecode'; +export const CONFIG_FILE = 'settings.json'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const HOOK_TIMEOUT_SECONDS = 10; +export const GUARD_STATUS = 'Checking Elydora agent state'; +export const AUDIT_STATUS = 'Recording Elydora tool use'; + +const HOOK_EVENTS = new Set([ + 'SessionStart', 'Setup', 'InstructionsLoaded', 'UserPromptSubmit', + 'UserPromptExpansion', 'MessageDisplay', 'PreToolUse', 'PermissionRequest', + 'PostToolUse', 'PostToolUseFailure', 'PostToolBatch', 'PermissionDenied', + 'Notification', 'SubagentStart', 'SubagentStop', 'TaskCreated', 'TaskCompleted', + 'Stop', 'StopFailure', 'TeammateIdle', 'ConfigChange', 'CwdChanged', + 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', 'PreCompact', 'PostCompact', + 'SessionEnd', 'Elicitation', 'ElicitationResult', +]); + +const COMMON_HANDLER_KEYS = ['if', 'once', 'statusMessage', 'timeout']; +const HANDLER_KEYS: Readonly> = { + command: [...COMMON_HANDLER_KEYS, 'args', 'async', 'asyncRewake', 'command', 'shell', 'type'], + prompt: [...COMMON_HANDLER_KEYS, 'continueOnBlock', 'model', 'prompt', 'type'], + agent: [...COMMON_HANDLER_KEYS, 'model', 'prompt', 'type'], + http: [...COMMON_HANDLER_KEYS, 'allowedEnvVars', 'headers', 'type', 'url'], + mcp_tool: [...COMMON_HANDLER_KEYS, 'input', 'server', 'tool', 'type'], +}; + +export interface ClaudeHandler extends JsonObject { + readonly type: 'command' | 'prompt' | 'agent' | 'http' | 'mcp_tool'; +} + +export interface ClaudeGroup extends JsonObject { + readonly matcher?: string; + readonly hooks: ClaudeHandler[]; +} + +export type ClaudeHooks = Record; + +export interface ClaudeDocument { + readonly exists: boolean; + readonly filePath: string; + readonly root: JsonObject; + readonly hooks: ClaudeHooks; + readonly hooksDisabled: boolean; + readonly raw?: string; +} + +export interface RenderedClaudeDocument { + readonly document: ClaudeDocument; + readonly changed: boolean; + readonly next?: string; +} + +export interface ClaudeRuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +interface RuntimeReference { + readonly agentId: string; + 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)); + if (extra) throw new Error(`${label} contains unsupported field "${extra}"`); +} + +function requireNonEmptyString(value: unknown, field: string, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${label} field "${field}" must be a non-empty string`); + } + return value; +} + +function optionalString(value: JsonObject, field: string, label: string): void { + if (value[field] !== undefined && typeof value[field] !== 'string') { + throw new Error(`${label} field "${field}" must be a string`); + } +} + +function optionalBoolean(value: JsonObject, field: string, label: string): void { + if (value[field] !== undefined && typeof value[field] !== 'boolean') { + throw new Error(`${label} field "${field}" must be a boolean`); + } +} + +function validateCommonHandlerFields(value: JsonObject, label: string): void { + optionalString(value, 'if', label); + optionalString(value, 'statusMessage', label); + optionalBoolean(value, 'once', label); + if (value.timeout !== undefined && ( + typeof value.timeout !== 'number' + || !Number.isFinite(value.timeout) + || value.timeout <= 0 + )) { + throw new Error(`${label} timeout must be a positive finite number`); + } +} + +function validateStringArray(value: unknown, field: string, label: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`${label} field "${field}" must be an array of strings`); + } + return value as string[]; +} + +function validateHandler(value: unknown, event: string, group: number, index: number): ClaudeHandler { + const label = `Claude Code settings handler hooks.${event}[${group}].hooks[${index}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (typeof value.type !== 'string' || !HANDLER_KEYS[value.type]) { + throw new Error(`${label} has unsupported type "${String(value.type)}"`); + } + requireKnownKeys(value, HANDLER_KEYS[value.type], label); + validateCommonHandlerFields(value, label); + if (value.type === 'command') { + requireNonEmptyString(value.command, 'command', label); + if (value.args !== undefined) validateStringArray(value.args, 'args', label); + optionalBoolean(value, 'async', label); + optionalBoolean(value, 'asyncRewake', label); + if (value.shell !== undefined && value.shell !== 'bash' && value.shell !== 'powershell') { + throw new Error(`${label} field "shell" must be "bash" or "powershell"`); + } + } else if (value.type === 'prompt' || value.type === 'agent') { + requireNonEmptyString(value.prompt, 'prompt', label); + optionalString(value, 'model', label); + if (value.type === 'prompt') optionalBoolean(value, 'continueOnBlock', label); + } else if (value.type === 'http') { + const rawUrl = requireNonEmptyString(value.url, 'url', label); + let url: URL; + try { + url = new URL(rawUrl); + } catch (error) { + throw new Error(`${label} field "url" must be a valid URL`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) { + throw new Error(`${label} field "url" must use HTTP or HTTPS`); + } + if (value.headers !== undefined && ( + !isObject(value.headers) + || Object.values(value.headers).some((item) => typeof item !== 'string') + )) { + throw new Error(`${label} field "headers" must map names to strings`); + } + if (value.allowedEnvVars !== undefined) { + const allowedEnvVars = validateStringArray(value.allowedEnvVars, 'allowedEnvVars', label); + if (allowedEnvVars.some((item) => item.length === 0)) { + throw new Error(`${label} field "allowedEnvVars" must contain non-empty strings`); + } + } + } else { + requireNonEmptyString(value.server, 'server', label); + requireNonEmptyString(value.tool, 'tool', label); + if (value.input !== undefined && !isObject(value.input)) { + throw new Error(`${label} field "input" must be an object`); + } + } + return value as ClaudeHandler; +} + +function validateGroup(value: unknown, event: string, index: number): ClaudeGroup { + const label = `Claude Code settings matcher group hooks.${event}[${index}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + requireKnownKeys(value, ['hooks', 'matcher'], label); + if (value.matcher !== undefined) { + if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`); + } + if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`); + return { + ...value, + hooks: value.hooks.map((handler, handlerIndex) => ( + validateHandler(handler, event, index, handlerIndex) + )), + } as ClaudeGroup; +} + +function readHooks(value: unknown): ClaudeHooks { + if (value === undefined) return {}; + if (!isObject(value)) throw new Error('Claude Code settings field "hooks" must be an object'); + const hooks: ClaudeHooks = {}; + for (const [event, groups] of Object.entries(value)) { + if (!HOOK_EVENTS.has(event)) { + throw new Error(`Claude Code settings contains unsupported hook event "${event}"`); + } + if (!Array.isArray(groups)) { + throw new Error(`Claude Code settings field "hooks.${event}" must be an array`); + } + hooks[event] = groups.map((group, index) => validateGroup(group, event, index)); + } + return hooks; +} + +export function parseClaudeDocument(filePath: string, raw: string): ClaudeDocument { + const root = parseStrictJsonObject(raw, `Claude Code user settings at ${filePath}`); + if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') { + throw new Error('Claude Code settings field "disableAllHooks" must be a boolean'); + } + return { + exists: true, + filePath, + root, + hooks: readHooks(root.hooks), + hooksDisabled: root.disableAllHooks === true, + raw, + }; +} + +export function createClaudeDocument(filePath: string): ClaudeDocument { + return { exists: false, filePath, root: {}, hooks: {}, hooksDisabled: false }; +} + +export function buildClaudeGroup(scriptPath: string, statusMessage: string): ClaudeGroup { + return { + hooks: [{ + type: 'command', + command: process.execPath, + args: [scriptPath], + timeout: HOOK_TIMEOUT_SECONDS, + statusMessage, + }], + }; +} + +function exactManagedGroup(group: ClaudeGroup): boolean { + return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks'); +} + +function runtimeReference(scriptPath: string, scriptName: string): RuntimeReference | undefined { + if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined; + const agentDirectory = path.dirname(scriptPath); + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined; + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? { agentId, scriptPath } : undefined; +} + +function managedReference( + handler: ClaudeHandler, + scriptName: string, + statusMessage: string, + includeLegacy = false, +): RuntimeReference | undefined { + const keys = Object.keys(handler).sort().join('|'); + if (keys === 'args|command|statusMessage|timeout|type' + && handler.type === 'command' + && handler.timeout === HOOK_TIMEOUT_SECONDS + && handler.statusMessage === statusMessage + && typeof handler.command === 'string' + && samePath(handler.command, process.execPath) + && Array.isArray(handler.args) + && handler.args.length === 1 + && typeof handler.args[0] === 'string') { + return runtimeReference(handler.args[0], scriptName); + } + if (!includeLegacy || keys !== 'command|type' || handler.type !== 'command') return undefined; + const match = typeof handler.command === 'string' + ? /^node "([^"\r\n]+)"$/.exec(handler.command) + : undefined; + return match ? runtimeReference(match[1], scriptName) : undefined; +} + +function removeFromGroups( + groups: readonly ClaudeGroup[], + scriptName: string, + statusMessage: string, + agentId?: string, +): { groups: ClaudeGroup[]; removed: boolean } { + const result: ClaudeGroup[] = []; + let removed = false; + for (const group of groups) { + const handlers = group.hooks.filter((handler) => { + const reference = managedReference(handler, scriptName, statusMessage, true); + const owned = reference !== undefined + && (agentId === undefined || sameAgentId(reference.agentId, agentId)); + if (owned) removed = true; + return !owned; + }); + if (handlers.length > 0) result.push({ ...group, hooks: handlers }); + else if (!exactManagedGroup(group)) result.push({ ...group, hooks: [] }); + } + return { groups: result, removed }; +} + +export function removeManagedClaudeHooks(hooks: ClaudeHooks, agentId?: string): ClaudeHooks { + const next: ClaudeHooks = Object.fromEntries( + Object.entries(hooks).map(([event, groups]) => [event, [...groups]]), + ); + for (const [event, scriptName, statusMessage] of [ + ['PreToolUse', GUARD_SCRIPT, GUARD_STATUS], + ['PostToolUse', AUDIT_SCRIPT, AUDIT_STATUS], + ['PostToolUseFailure', AUDIT_SCRIPT, AUDIT_STATUS], + ] as const) { + if (!next[event]) continue; + const result = removeFromGroups(next[event], scriptName, statusMessage, agentId); + if (!result.removed) continue; + if (result.groups.length > 0) next[event] = result.groups; + else delete next[event]; + } + return next; +} + +function managedEvent(event: string): readonly [string, string] | undefined { + if (event === 'PreToolUse') return [GUARD_SCRIPT, GUARD_STATUS]; + if (event === 'PostToolUse' || event === 'PostToolUseFailure') { + return [AUDIT_SCRIPT, AUDIT_STATUS]; + } + return undefined; +} + +function entirelyManaged(document: ClaudeDocument): boolean { + if (!document.exists || !Object.keys(document.root).every((key) => key === 'hooks')) return false; + const events = Object.entries(document.hooks); + if (events.length === 0) return false; + let handlers = 0; + for (const [event, groups] of events) { + const contract = managedEvent(event); + if (!contract || groups.length === 0) return false; + for (const group of groups) { + if (!exactManagedGroup(group) + || group.hooks.length === 0 + || group.hooks.some((handler) => ( + !managedReference(handler, contract[0], contract[1], true) + ))) return false; + handlers += group.hooks.length; + } + } + return handlers > 0; +} + +export function renderClaudeDocument( + document: ClaudeDocument, + hooks: ClaudeHooks, +): RenderedClaudeDocument { + if (!document.exists && Object.keys(hooks).length === 0) { + return { document, changed: false }; + } + if (Object.keys(hooks).length === 0 && entirelyManaged(document)) { + return { document, changed: true }; + } + const root: JsonObject = { ...document.root }; + 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 referencesForEvent( + groups: readonly ClaudeGroup[], + scriptName: string, + statusMessage: string, +): Map { + const references = new Map(); + for (const group of groups) { + if (!exactManagedGroup(group)) continue; + for (const handler of group.hooks) { + const reference = managedReference(handler, scriptName, statusMessage); + if (!reference) continue; + const key = process.platform === 'win32' + ? reference.agentId.toLowerCase() + : reference.agentId; + references.set(key, [...(references.get(key) ?? []), reference]); + } + } + return references; +} + +export function claudeRuntimeContracts(hooks: ClaudeHooks): ClaudeRuntimeContract[] { + const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_STATUS); + const successes = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_STATUS); + const failures = referencesForEvent( + hooks.PostToolUseFailure ?? [], + AUDIT_SCRIPT, + AUDIT_STATUS, + ); + const contracts: ClaudeRuntimeContract[] = []; + for (const [key, guard] of guards) { + const success = successes.get(key); + const failure = failures.get(key); + if (guard.length !== 1 || success?.length !== 1 || failure?.length !== 1) continue; + if (!samePath(success[0].scriptPath, failure[0].scriptPath)) continue; + contracts.push({ + agentId: guard[0].agentId, + guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, + }); + } + return contracts; +} diff --git a/src/plugins/claudecode-installation.ts b/src/plugins/claudecode-installation.ts new file mode 100644 index 0000000..03215a3 --- /dev/null +++ b/src/plugins/claudecode-installation.ts @@ -0,0 +1,70 @@ +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + type RenderedClaudeDocument, +} from './claudecode-contract.js'; +import { + commitManagedInstallation, + managedRuntimePaths, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; + +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 { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }], + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function prepareClaudeInstallation( + config: InstallConfig, + rendered: RenderedClaudeDocument, +): Promise { + if (!rendered.changed && rendered.document.raw === undefined) { + throw new Error('Claude Code hook installation did not produce a settings document'); + } + const settingsSource = rendered.next ?? rendered.document.raw; + if (settingsSource === undefined) throw new Error('Claude Code hook settings are missing'); + return prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: [{ + directoryLabel: SETTINGS_DIRECTORY_LABEL, + label: SETTINGS_LABEL, + filePath: rendered.document.filePath, + expectedSource: rendered.document.raw, + source: settingsSource, + }], + config, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function commitClaudeInstallation( + prepared: PreparedClaudeInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} diff --git a/src/plugins/claudecode-io.ts b/src/plugins/claudecode-io.ts new file mode 100644 index 0000000..8a97556 --- /dev/null +++ b/src/plugins/claudecode-io.ts @@ -0,0 +1,153 @@ +import os from 'node:os'; +import path from 'node:path'; +import { + AGENT_KEY, + CONFIG_FILE, + createClaudeDocument, + parseClaudeDocument, + type ClaudeDocument, + type ClaudeRuntimeContract, + type RenderedClaudeDocument, +} from './claudecode-contract.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.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; + return configured === undefined + ? path.join(os.homedir(), '.claude') + : path.resolve(configured); +} + +export function claudeSettingsPath(): string { + return path.join(claudeConfigDirectory(), CONFIG_FILE); +} + +export async function readClaudeDocument(): Promise { + const filePath = claudeSettingsPath(); + await inspectPhysicalDirectory(path.dirname(filePath), 'Claude Code configuration directory'); + const snapshot = await readPhysicalFile(filePath, 'Claude Code user settings'); + return snapshot + ? parseClaudeDocument(filePath, snapshot.contents) + : createClaudeDocument(filePath); +} + +export async function writeClaudeDocument(rendered: RenderedClaudeDocument): Promise { + if (!rendered.changed) return; + const change = await prepareManagedFileChange({ + filePath: rendered.document.filePath, + label: 'Claude Code user settings', + next: rendered.next, + mode: 0o600, + expectedSource: rendered.document.raw, + verifyExpectedSource: true, + }); + if (!change) return; + await commitManagedTransaction({ + displayName: 'Claude Code', + operation: 'uninstall', + directories: [{ + path: path.dirname(rendered.document.filePath), + label: 'Claude Code configuration directory', + }], + changes: [change], + }); +} + +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; + } + return false; +} diff --git a/src/plugins/claudecode.ts b/src/plugins/claudecode.ts index 9c79d48..f26cbda 100644 --- a/src/plugins/claudecode.ts +++ b/src/plugins/claudecode.ts @@ -1,171 +1,86 @@ -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + AUDIT_STATUS, + GUARD_STATUS, + buildClaudeGroup, + claudeRuntimeContracts, + removeManagedClaudeHooks, + renderClaudeDocument, +} from './claudecode-contract.js'; +import { + commitClaudeInstallation, + preflightClaudeInstallation, + prepareClaudeInstallation, +} from './claudecode-installation.js'; +import { + claudeRuntimeFilesExist, + readClaudeDocument, + writeClaudeDocument, +} from './claudecode-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; -const AGENT_KEY = 'claudecode'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -function resolveConfigDir(): string { - return entry.configDir.replace(/^~/, os.homedir()); -} - -function resolveConfigPath(): string { - return path.join(resolveConfigDir(), entry.configFile); -} - -function buildHookCommand(scriptPath: string): string { - return `node "${scriptPath}"`; -} - -function isElydoraCommand(cmd: string, agentId?: string): boolean { - if (!cmd.includes('elydora')) return false; - if (agentId) { - return cmd.includes(agentId); +function requireEnabledHooks(disabled: boolean, filePath: string): void { + if (disabled) { + throw new Error(`Claude Code hooks are disabled by disableAllHooks: ${filePath}`); } - return true; -} - -function filterElydoraEntries(arr: Array>, agentId?: string): Array> { - return arr.filter((entry) => { - if (Array.isArray(entry.hooks)) { - const cmds = entry.hooks as Array>; - return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId)); - } - if (typeof entry.command === 'string') { - return !isElydoraCommand(entry.command, agentId); - } - return true; - }); } export const claudecodePlugin: AgentPlugin = { + managesRuntime: true, + + async preflightInstall(config: InstallConfig): Promise { + const document = await readClaudeDocument(); + requireEnabledHooks(document.hooksDisabled, document.filePath); + await preflightClaudeInstallation(config, document.filePath); + }, + async install(config: InstallConfig): Promise { - const configDir = resolveConfigDir(); - await fsp.mkdir(configDir, { recursive: true }); - - const configPath = resolveConfigPath(); - let settings: Record = {}; - - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - // File doesn't exist or isn't valid JSON — start fresh - } - - if (!settings.hooks || typeof settings.hooks !== 'object') { - settings.hooks = {}; - } - const hooks = settings.hooks as Record; - - // --- PreToolUse (guard — freeze enforcement) --- - if (!Array.isArray(hooks.PreToolUse)) { - hooks.PreToolUse = []; - } - const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array>); - preFiltered.push({ - hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }], - }); - hooks.PreToolUse = preFiltered; - - // --- PostToolUse (audit logging) --- - if (!Array.isArray(hooks.PostToolUse)) { - hooks.PostToolUse = []; - } - const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array>); - postFiltered.push({ - hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }], - }); - hooks.PostToolUse = postFiltered; - - settings.hooks = hooks; - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + const document = await readClaudeDocument(); + requireEnabledHooks(document.hooksDisabled, document.filePath); + const paths = await preflightClaudeInstallation(config, document.filePath); + const hooks = removeManagedClaudeHooks(document.hooks); + hooks.PreToolUse = [ + ...(hooks.PreToolUse ?? []), + buildClaudeGroup(paths.guardPath, GUARD_STATUS), + ]; + hooks.PostToolUse = [ + ...(hooks.PostToolUse ?? []), + buildClaudeGroup(paths.auditPath, AUDIT_STATUS), + ]; + hooks.PostToolUseFailure = [ + ...(hooks.PostToolUseFailure ?? []), + buildClaudeGroup(paths.auditPath, AUDIT_STATUS), + ]; + const rendered = renderClaudeDocument(document, hooks); + const prepared = await prepareClaudeInstallation(config, rendered); + await commitClaudeInstallation(prepared); + console.log(` Claude Code hooks: ${document.filePath}`); + console.log(' Claude Code verification: run /hooks and claude doctor.'); }, async uninstall(agentId?: string): Promise { - const configPath = resolveConfigPath(); - - let settings: Record; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - return; // Nothing to uninstall - } - - const hooks = settings.hooks as Record | undefined; - if (!hooks) return; - - if (Array.isArray(hooks.PreToolUse)) { - hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>, agentId); - } - if (Array.isArray(hooks.PostToolUse)) { - hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>, agentId); - } - - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + const document = await readClaudeDocument(); + const hooks = removeManagedClaudeHooks(document.hooks, agentId); + await writeClaudeDocument(renderClaudeDocument(document, hooks)); }, async status(): Promise { - const configPath = resolveConfigPath(); - - let hookConfigured = false; - let hookScriptPath = ''; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - const settings = JSON.parse(raw); - const hooks = settings.hooks as Record | undefined; - if (hooks) { - const checkArr = (arr: unknown) => { - if (!Array.isArray(arr)) return false; - return (arr as Array>).some((entry) => { - if (Array.isArray(entry.hooks)) { - return (entry.hooks as Array>).some( - (h) => typeof h.command === 'string' && h.command.includes('elydora'), - ); - } - return typeof entry.command === 'string' && entry.command.includes('elydora'); - }); - }; - hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse); - - // Extract hook script path from PostToolUse command - if (hookConfigured && Array.isArray(hooks.PostToolUse)) { - for (const e of hooks.PostToolUse as Array>) { - if (Array.isArray(e.hooks)) { - for (const h of e.hooks as Array>) { - const cmd = h.command as string; - if (cmd && cmd.includes('elydora')) { - hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); - } - } - } - } - } - } - } catch { - // Config not readable - } - - let hookScriptExists = false; - if (hookScriptPath) { - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist - } - } - + const document = await readClaudeDocument(); + const contracts = claudeRuntimeContracts(document.hooks); + const hookConfigured = !document.hooksDisabled && contracts.length > 0; + const hookScriptExists = hookConfigured + ? await claudeRuntimeFilesExist(contracts) + : false; return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, displayName: entry.name, hookConfigured, hookScriptExists, - configPath, + configPath: document.filePath, }; }, }; diff --git a/test-support/claudecode-test-helpers.mjs b/test-support/claudecode-test-helpers.mjs new file mode 100644 index 0000000..4f99404 --- /dev/null +++ b/test-support/claudecode-test-helpers.mjs @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +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).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/claudecode.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/claudecode-io.js')).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/claudecode-installation.js'), +).href; +export const cliPath = path.resolve('dist/cli.js'); + +export function runProcess(command, args, env, cwd, input = '', unset = []) { + return new Promise((resolve, reject) => { + const childEnv = { ...process.env, ...env }; + for (const key of unset) delete childEnv[key]; + const child = spawn(command, args, { + cwd, + env: childEnv, + 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 = '', unset = []) { + return runProcess(process.execPath, args, env, cwd, input, unset); +} + +export function runClaudeHook(handler, input, fixture, environment = {}) { + return runProcess( + handler.command, + handler.args, + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + CLAUDE_CONFIG_DIR: fixture.claudeConfigOverride ?? '', + ...environment, + }, + fixture.projectDir, + input, + ); +} + +export async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'claudecode', + orgId: 'org-1', + agentId: 'agent-1', + 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, environment = {}) { + const source = ` + import { claudecodePlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await claudecodePlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + const env = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + ...environment, + }; + const unset = []; + if (fixture.claudeConfigOverride === undefined) unset.push('CLAUDE_CONFIG_DIR'); + else env.CLAUDE_CONFIG_DIR = fixture.claudeConfigOverride; + return runNode( + ['--input-type=module', '--eval', source], + env, + fixture.projectDir, + '', + unset, + ); +} + +export async function createFixture({ + baseUrl = 'http://127.0.0.1:9', + settings, + explicitClaudeConfig = true, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-claude-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %CLAUDE_HOOK_EVENT%"); + const projectDir = path.join(rootDir, 'project with spaces'); + const claudeConfigDir = explicitClaudeConfig + ? path.join(homeDir, 'custom claude') + : path.join(homeDir, '.claude'); + const settingsPath = path.join(claudeConfigDir, 'settings.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 (settings !== undefined) await writeJson(settingsPath, settings); + const fixture = { + agentDir, + baseUrl, + claudeConfigDir, + claudeConfigOverride: explicitClaudeConfig ? claudeConfigDir : undefined, + guardScriptPath, + homeDir, + hookScriptPath, + projectDir, + rootDir, + settingsPath, + install(overrides = {}) { + return runPlugin(this, 'install', installConfig(this, overrides)); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; + return fixture; +} + +export async function readSettings(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, settings: JSON.parse(raw) }; +} + +export function managedHandler(settings, event) { + for (const group of settings.hooks?.[event] ?? []) { + if (Object.keys(group).join('|') !== 'hooks' || !Array.isArray(group.hooks)) continue; + const handler = group.hooks.find((candidate) => ( + Object.keys(candidate).sort().join('|') === 'args|command|statusMessage|timeout|type' + && candidate.type === 'command' + && candidate.timeout === 10 + )); + if (handler) return handler; + } + return undefined; +} + +export function assertManagedHandler(handler, scriptPath, statusMessage) { + assert(handler); + assert.deepEqual( + Object.keys(handler).sort(), + ['args', 'command', 'statusMessage', 'timeout', 'type'], + ); + assert.equal(handler.type, 'command'); + assert.equal(handler.command, process.execPath); + assert.deepEqual(handler.args, [scriptPath]); + assert.equal(handler.timeout, 10); + assert.equal(handler.statusMessage, statusMessage); +} + +export function legacyHandler(scriptPath) { + return { type: 'command', command: `node "${scriptPath}"` }; +} + +export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { + 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({ headers: request.headers, method: request.method, url: request.url, raw }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + return; + } + response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); + response.end(operationStatus >= 200 && operationStatus < 300 + ? JSON.stringify({ operation: { accepted: true } }) + : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); + }); + 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()); + }), + }; +} diff --git a/test/claudecode-official.test.mjs b/test/claudecode-official.test.mjs new file mode 100644 index 0000000..4f9b27a --- /dev/null +++ b/test/claudecode-official.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createFixture, + runProcess, +} from '../test-support/claudecode-test-helpers.mjs'; + +const claudeBinary = process.env.ELYDORA_CLAUDE_BINARY; + +test('official Claude Code accepts the installed user hook contract', { + skip: claudeBinary ? false : 'set ELYDORA_CLAUDE_BINARY to the official Claude Code executable', +}, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const environment = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + CLAUDE_CONFIG_DIR: fixture.claudeConfigDir, + DISABLE_AUTOUPDATER: '1', + DISABLE_TELEMETRY: '1', + }; + const version = await runProcess( + claudeBinary, + ['--version'], + environment, + fixture.projectDir, + ); + assert.equal(version.code, 0, version.stderr); + assert.match(version.stdout, /Claude Code/i); + + const doctor = await runProcess( + claudeBinary, + ['doctor'], + environment, + fixture.projectDir, + ); + assert.equal(doctor.code, 0, doctor.stderr); + const output = `${doctor.stdout}\n${doctor.stderr}`; + assert.doesNotMatch(output, /invalid settings|settings validation|failed to parse/i); + } finally { + await fixture.close(); + } +}); diff --git a/test/claudecode-plugin.test.mjs b/test/claudecode-plugin.test.mjs new file mode 100644 index 0000000..abca808 --- /dev/null +++ b/test/claudecode-plugin.test.mjs @@ -0,0 +1,368 @@ +import assert from 'node:assert/strict'; +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + assertManagedHandler, + cliPath, + createFixture, + legacyHandler, + managedHandler, + readSettings, + registryModuleUrl, + runNode, + runPlugin, + VALID_PRIVATE_KEY, + writeJson, +} from '../test-support/claudecode-test-helpers.mjs'; + +const GUARD_STATUS = 'Checking Elydora agent state'; +const AUDIT_STATUS = 'Recording Elydora tool use'; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +function assertManagedTriple(settings, fixture) { + assertManagedHandler( + managedHandler(settings, 'PreToolUse'), + fixture.guardScriptPath, + GUARD_STATUS, + ); + assertManagedHandler( + managedHandler(settings, 'PostToolUse'), + fixture.hookScriptPath, + AUDIT_STATUS, + ); + assertManagedHandler( + managedHandler(settings, 'PostToolUseFailure'), + fixture.hookScriptPath, + AUDIT_STATUS, + ); +} + +test('Claude Code is registered in the SDK and CLI', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('claudecode'), { + name: 'Claude Code', + configDir: '~/.claude', + configFile: 'settings.json', + }); + const fixture = await createFixture(); + try { + const result = await runNode(['--no-warnings', cliPath, 'status'], { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + CLAUDE_CONFIG_DIR: fixture.claudeConfigDir, + }, fixture.projectDir); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Claude Code \(claudecode\)/); + } finally { + await fixture.close(); + } +}); + +test('Claude installs one exact managed triple and preserves user settings', async () => { + const existing = { + $schema: 'https://json.schemastore.org/claude-code-settings.json', + model: 'sonnet', + disableAllHooks: false, + hooks: { + Notification: [{ + matcher: 'permission_prompt', + hooks: [{ type: 'http', url: 'https://example.test/hook', timeout: 1 }], + }], + PreToolUse: [{ + matcher: 'Bash', + hooks: [{ type: 'command', command: 'existing-command', timeout: 5 }], + }], + }, + }; + const fixture = await createFixture({ settings: existing }); + try { + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + assert.match(first.stdout, /run \/hooks and claude doctor/i); + const installed = await readSettings(fixture.settingsPath); + assert.equal(installed.settings.model, 'sonnet'); + assert.deepEqual(installed.settings.hooks.Notification, existing.hooks.Notification); + assert.deepEqual(installed.settings.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); + assertManagedTriple(installed.settings, fixture); + + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), installed.raw); + } finally { + await fixture.close(); + } +}); + +test('Claude resolves absolute, relative, empty, and absent CLAUDE_CONFIG_DIR values', async () => { + const custom = await createFixture(); + try { + assert.equal((await custom.install()).code, 0); + assert.equal((await readSettings(custom.settingsPath)).settings.hooks !== undefined, true); + await assertMissing(path.join(custom.homeDir, '.claude', 'settings.json')); + } finally { + await custom.close(); + } + + const fallback = await createFixture({ explicitClaudeConfig: false }); + try { + assert.equal((await fallback.install()).code, 0); + assert.equal((await readSettings(fallback.settingsPath)).settings.hooks !== undefined, true); + } finally { + await fallback.close(); + } + + const relative = await createFixture(); + try { + relative.claudeConfigOverride = 'relative claude'; + assert.equal((await relative.install()).code, 0); + const relativePath = path.join(relative.projectDir, 'relative claude', 'settings.json'); + assert.equal((await readSettings(relativePath)).settings.hooks !== undefined, true); + } finally { + await relative.close(); + } + + const empty = await createFixture(); + try { + empty.claudeConfigOverride = ''; + assert.equal((await empty.install()).code, 0); + const currentDirectoryPath = path.join(empty.projectDir, 'settings.json'); + assert.equal((await readSettings(currentDirectoryPath)).settings.hooks !== undefined, true); + } finally { + await empty.close(); + } +}); + +test('Claude parses settings before creating runtime files', async () => { + const fixture = await createFixture({ settings: '{ malformed' }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /parse Claude Code user settings/i); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), '{ malformed'); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } +}); + +test('Claude rejects invalid official hook shapes before writes', async (t) => { + const cases = [ + ['duplicate', '{"hooks":{},"hooks":{}}', /duplicate field "hooks"/i], + ['disabled type', JSON.stringify({ disableAllHooks: 'yes' }), /must be a boolean/i], + ['hooks shape', JSON.stringify({ hooks: null }), /field "hooks" must be an object/i], + ['unknown event', JSON.stringify({ hooks: { MadeUp: [] } }), /unsupported hook event/i], + ['event shape', JSON.stringify({ hooks: { PreToolUse: null } }), /must be an array/i], + ['group shape', JSON.stringify({ hooks: { PreToolUse: [null] } }), /group.*must be an object/i], + ['group field', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [], label: 'x' }] } }), /unsupported field "label"/i], + ['matcher shape', JSON.stringify({ hooks: { PreToolUse: [{ matcher: 1, hooks: [] }] } }), /matcher must be a string/i], + ['hooks missing', JSON.stringify({ hooks: { PreToolUse: [{}] } }), /must contain a hooks array/i], + ['handler shape', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [null] }] } }), /handler.*must be an object/i], + ['handler type', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'file' }] }] } }), /unsupported type/i], + ['handler field', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', invented: true }] }] } }), /unsupported field "invented"/i], + ['command value', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: '' }] }] } }), /non-empty string/i], + ['argument type', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', args: [1] }] }] } }), /array of strings/i], + ['timeout zero', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 0 }] }] } }), /positive finite number/i], + ['headers type', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'http', url: 'https://example.test', headers: { A: 1 } }] }] } }), /map names to strings/i], + ['input type', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'mcp_tool', server: 's', tool: 't', input: [] }] }] } }), /input.*must be an object/i], + ]; + for (const [name, settings, pattern] of cases) { + await t.test(name, async () => { + const fixture = await createFixture({ settings }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, pattern); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), settings); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + } +}); + +test('Claude refuses installation while user hooks are disabled', async () => { + const fixture = await createFixture({ settings: { disableAllHooks: true } }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /disabled by disableAllHooks/i); + await assertMissing(fixture.agentDir); + assert.deepEqual((await readSettings(fixture.settingsPath)).settings, { + disableAllHooks: true, + }); + } finally { + await fixture.close(); + } +}); + +test('Claude migrates exact legacy hooks and preserves ownership lookalikes', async () => { + const fixture = await createFixture(); + const lookalike = { ...legacyHandler(fixture.guardScriptPath) }; + lookalike.command += ' --inspect'; + const legacy = { + hooks: { + PreToolUse: [{ hooks: [legacyHandler(fixture.guardScriptPath)] }, { hooks: [lookalike] }], + PostToolUse: [{ hooks: [legacyHandler(fixture.hookScriptPath)] }], + }, + }; + try { + await writeJson(fixture.settingsPath, legacy); + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + let { settings } = await readSettings(fixture.settingsPath); + assertManagedTriple(settings, fixture); + assert(settings.hooks.PreToolUse.some((group) => group.hooks[0].command === lookalike.command)); + + const managed = settings.hooks.PreToolUse.at(-1); + managed.hooks.push({ type: 'command', command: 'user-command', timeout: 5 }); + await writeJson(fixture.settingsPath, settings); + const uninstall = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(uninstall.code, 0, uninstall.stderr); + settings = (await readSettings(fixture.settingsPath)).settings; + assert.deepEqual(settings.hooks.PreToolUse.flatMap((group) => group.hooks), [ + lookalike, + { type: 'command', command: 'user-command', timeout: 5 }, + ]); + assert.equal(settings.hooks.PostToolUse, undefined); + assert.equal(settings.hooks.PostToolUseFailure, undefined); + } finally { + await fixture.close(); + } +}); + +test('Claude uninstall preserves user sources and removes a fully managed file', async () => { + const user = await createFixture({ settings: { model: 'sonnet', hooks: { Notification: [] } } }); + try { + assert.equal((await user.install()).code, 0); + const result = await runPlugin(user, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual((await readSettings(user.settingsPath)).settings, { + model: 'sonnet', + hooks: { Notification: [] }, + }); + } finally { + await user.close(); + } + + const managed = await createFixture(); + try { + assert.equal((await managed.install()).code, 0); + assert.equal((await runPlugin(managed, 'uninstall', 'agent-1')).code, 0); + await assertMissing(managed.settingsPath); + } finally { + await managed.close(); + } +}); + +test('Claude status requires one enabled complete triple and strict runtime identity', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + let status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, true); + + let { settings } = await readSettings(fixture.settingsPath); + delete settings.hooks.PostToolUseFailure; + await writeJson(fixture.settingsPath, settings); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + + assert.equal((await fixture.install()).code, 0); + settings = (await readSettings(fixture.settingsPath)).settings; + settings.hooks.PostToolUseFailure.push(settings.hooks.PostToolUseFailure.at(-1)); + await writeJson(fixture.settingsPath, settings); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + + assert.equal((await fixture.install()).code, 0); + settings = (await readSettings(fixture.settingsPath)).settings; + settings.disableAllHooks = true; + await writeJson(fixture.settingsPath, settings); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + + settings.disableAllHooks = false; + await writeJson(fixture.settingsPath, settings); + await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid'); + status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /private key is invalid/i); + } finally { + await fixture.close(); + } +}); + +test('Claude leaves project and local settings unchanged', async () => { + const fixture = await createFixture(); + const projectSettings = path.join(fixture.projectDir, '.claude', 'settings.json'); + const localSettings = path.join(fixture.projectDir, '.claude', 'settings.local.json'); + const projectSource = '{"hooks":{"PreToolUse":[]}}\n'; + const localSource = '{"model":"haiku"}\n'; + try { + await mkdir(path.dirname(projectSettings), { recursive: true }); + await writeFile(projectSettings, projectSource); + await writeFile(localSettings, localSource); + assert.equal((await fixture.install()).code, 0); + assert.equal(await readFile(projectSettings, 'utf-8'), projectSource); + assert.equal(await readFile(localSettings, 'utf-8'), localSource); + } finally { + await fixture.close(); + } +}); + +test('Claude CLI completes install, status, and uninstall end to end', async () => { + const fixture = await createFixture(); + const privateKeyFile = path.join(fixture.rootDir, 'install-private.key'); + const tokenFile = path.join(fixture.rootDir, 'install-token.txt'); + const environment = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + CLAUDE_CONFIG_DIR: fixture.claudeConfigDir, + }; + try { + await writeFile(privateKeyFile, `${VALID_PRIVATE_KEY}\n`, { mode: 0o600 }); + await writeFile(tokenFile, 'token-1\n', { mode: 0o600 }); + const install = await runNode([ + '--no-warnings', + cliPath, + 'install', + '--agent', 'claudecode', + '--org_id', 'org-1', + '--agent_id', 'agent-1', + '--kid', 'kid-1', + '--private_key_file', privateKeyFile, + '--token_file', tokenFile, + '--base_url', fixture.baseUrl, + ], environment, fixture.projectDir); + assert.equal(install.code, 0, install.stderr); + assert.match(install.stdout, /installed for Claude Code/i); + assertManagedTriple((await readSettings(fixture.settingsPath)).settings, fixture); + + const status = await runNode( + ['--no-warnings', cliPath, 'status'], + environment, + fixture.projectDir, + ); + assert.equal(status.code, 0, status.stderr); + assert.match(status.stdout, /Claude Code \(claudecode\) \[installed\]/); + + const uninstall = await runNode([ + '--no-warnings', + cliPath, + 'uninstall', + '--agent', 'claudecode', + '--agent_id', 'agent-1', + ], environment, fixture.projectDir); + assert.equal(uninstall.code, 0, uninstall.stderr); + assert.match(uninstall.stdout, /uninstalled for Claude Code/i); + await assertMissing(fixture.settingsPath); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } +}); diff --git a/test/claudecode-runtime.test.mjs b/test/claudecode-runtime.test.mjs new file mode 100644 index 0000000..c7e8334 --- /dev/null +++ b/test/claudecode-runtime.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + managedHandler, + readSettings, + runClaudeHook, + startApiServer, +} from '../test-support/claudecode-test-helpers.mjs'; + +function officialPayload(event, overrides = {}) { + return { + session_id: 'session-1', + prompt_id: '302d811d-0d17-41ad-a359-d2cb618fd42b', + transcript_path: '/tmp/session-1.jsonl', + cwd: '/tmp/project', + permission_mode: 'default', + effort: { level: 'high' }, + hook_event_name: event, + tool_name: 'Bash', + tool_input: { command: 'npm test', description: 'Run tests' }, + tool_use_id: 'toolu_01ABC123', + ...overrides, + }; +} + +test('Claude runtimes enforce active state and preserve official event payloads', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + const guard = managedHandler(settings, 'PreToolUse'); + const successAudit = managedHandler(settings, 'PostToolUse'); + const failureAudit = managedHandler(settings, 'PostToolUseFailure'); + + const pre = officialPayload('PreToolUse'); + const guardResult = await runClaudeHook(guard, JSON.stringify(pre), fixture); + assert.equal(guardResult.code, 0, guardResult.stderr); + assert.equal(guardResult.stdout, ''); + + const success = officialPayload('PostToolUse', { + tool_response: { + stdout: 'tests passed', + stderr: '', + interrupted: false, + isImage: false, + }, + }); + const successResult = await runClaudeHook(successAudit, JSON.stringify(success), fixture); + assert.equal(successResult.code, 0, successResult.stderr); + + const failure = officialPayload('PostToolUseFailure', { + error: 'Command exited with non-zero status code 1', + is_interrupt: false, + duration_ms: 4187, + }); + const failureResult = await runClaudeHook(failureAudit, JSON.stringify(failure), fixture); + assert.equal(failureResult.code, 0, failureResult.stderr); + + assert.equal(api.requests.length, 3); + assert.deepEqual( + api.requests.map((request) => [request.method, request.url]), + [ + ['GET', '/v1/agents/agent-1'], + ['POST', '/v1/operations'], + ['POST', '/v1/operations'], + ], + ); + const successOperation = JSON.parse(api.requests[1].raw); + const failureOperation = JSON.parse(api.requests[2].raw); + assert.deepEqual(successOperation.payload, success); + assert.deepEqual(failureOperation.payload, failure); + assert.deepEqual(successOperation.subject, { session_id: 'session-1' }); + assert.deepEqual(successOperation.action, { tool: 'Bash' }); + assert.equal(api.requests[1].headers.authorization, 'Bearer token-1'); + assert.equal(failureOperation.prev_chain_hash, successOperation.chain_hash); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Claude guard returns official blocking exit code 2 for frozen and revoked agents', async (t) => { + for (const status of ['frozen', 'revoked']) { + await t.test(status, async () => { + const api = await startApiServer({ status }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + const guard = managedHandler(settings, 'PreToolUse'); + const result = await runClaudeHook( + guard, + JSON.stringify(officialPayload('PreToolUse')), + fixture, + ); + assert.equal(result.code, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, new RegExp(`Agent "claudecode" is ${status}`, 'i')); + assert.match(result.stderr, /Tool execution blocked/i); + } finally { + await fixture.close(); + await api.close(); + } + }); + } +}); + +test('Claude runtime failures stay observable while guard and audit delivery remain fail-open', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + const guard = managedHandler(settings, 'PreToolUse'); + const audit = managedHandler(settings, 'PostToolUse'); + + const guardResult = await runClaudeHook( + guard, + JSON.stringify(officialPayload('PreToolUse')), + fixture, + ); + assert.equal(guardResult.code, 0); + assert.match(guardResult.stderr, /Failed to resolve agent status/i); + + const auditResult = await runClaudeHook( + audit, + JSON.stringify(officialPayload('PostToolUse', { tool_response: { stdout: '' } })), + fixture, + ); + assert.equal(auditResult.code, 0); + const errorLog = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'); + assert.match(errorLog, /\[claudecode\]/i); + assert.match(errorLog, /fetch failed|ECONNREFUSED/i); + + const malformed = await runClaudeHook(audit, '{ malformed', fixture); + assert.equal(malformed.code, 0); + const updatedLog = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'); + assert.match(updatedLog, /Hook input is invalid JSON/i); + } finally { + await fixture.close(); + } +}); diff --git a/test/claudecode-transaction.test.mjs b/test/claudecode-transaction.test.mjs new file mode 100644 index 0000000..42001ac --- /dev/null +++ b/test/claudecode-transaction.test.mjs @@ -0,0 +1,323 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + installationModuleUrl, + installConfig, + ioModuleUrl, + runNode, + writeJson, +} from '../test-support/claudecode-test-helpers.mjs'; + +const managedFileNames = ['guard.js', 'config.json', 'private.key', 'hook.js']; + +async function snapshotInstallation(fixture) { + const entries = await Promise.all(managedFileNames.map(async (name) => [ + path.join(fixture.agentDir, name), + await readFile(path.join(fixture.agentDir, name), 'utf-8'), + ])); + entries.push([fixture.settingsPath, await readFile(fixture.settingsPath, 'utf-8')]); + return new Map(entries); +} + +async function assertSnapshot(snapshot) { + for (const [filePath, expected] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), expected, filePath); + } +} + +async function assertNoTransactionFiles(fixture) { + const names = [ + ...await readdir(fixture.agentDir), + ...await readdir(path.dirname(fixture.settingsPath)), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); +} + +async function preservedRollback(fixture, basename) { + const entries = await readdir(fixture.agentDir); + const matches = entries.filter( + (name) => name.startsWith(`.${basename}.`) && name.endsWith('.rollback'), + ); + assert.equal(matches.length, 1, entries.join(', ')); + return path.join(fixture.agentDir, matches[0]); +} + +function testEnvironment(fixture, overrides = {}) { + return { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + CLAUDE_CONFIG_DIR: fixture.claudeConfigDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, overrides)), + }; +} + +test('Claude rolls back all five files when a transaction commit fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const source = ` + import { rename } from 'node:fs/promises'; + import { readClaudeDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitClaudeInstallation, + prepareClaudeInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const document = await readClaudeDocument(); + const prepared = await prepareClaudeInstallation(config, { document, changed: false }); + let calls = 0; + await commitClaudeInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 3) throw new Error('injected rename failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + testEnvironment(fixture, { orgId: 'org-updated', token: 'token-updated' }), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected rename failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Claude detects concurrent settings changes before committing runtime updates', async () => { + const fixture = await createFixture({ settings: { model: 'sonnet' } }); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"model":"opus","hooks":{"Notification":[]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readClaudeDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitClaudeInstallation, + prepareClaudeInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readClaudeDocument(); + const prepared = await prepareClaudeInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await commitClaudeInstallation(prepared); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...testEnvironment(fixture, { orgId: 'updated' }), + ELYDORA_CONCURRENT: concurrent, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Claude Code installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.settingsPath) { + assert.equal(await readFile(filePath, 'utf-8'), expected); + } + } + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Claude rejects a stale settings document before staging files', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"hooks":{"Notification":[]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readClaudeDocument } from ${JSON.stringify(ioModuleUrl)}; + import { prepareClaudeInstallation } from ${JSON.stringify(installationModuleUrl)}; + const document = await readClaudeDocument(); + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await prepareClaudeInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...testEnvironment(fixture), + ELYDORA_CONCURRENT: concurrent, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed before installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.settingsPath) { + assert.equal(await readFile(filePath, 'utf-8'), expected); + } + } + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Claude preserves rollback data after a committed runtime changes', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const original = await readFile(fixture.guardScriptPath, 'utf-8'); + const source = ` + import { rename, rm, writeFile } from 'node:fs/promises'; + import { readClaudeDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitClaudeInstallation, + prepareClaudeInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readClaudeDocument(); + const prepared = await prepareClaudeInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + let calls = 0; + await commitClaudeInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 1) { + await rename(from, to); + await rm(to); + await writeFile(to, 'external change\\n'); + return; + } + if (calls === 2) throw new Error('injected later commit failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + testEnvironment(fixture, { orgId: 'updated' }), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /original content preserved at/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'external change\n'); + assert.equal(await readFile(await preservedRollback(fixture, 'guard.js'), 'utf-8'), original); + } finally { + await fixture.close(); + } +}); + +test('Claude rejects orphaned and mismatched runtime identities before writes', async (t) => { + await t.test('orphaned runtime', 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.settingsPath); + } finally { + await fixture.close(); + } + }); + + await t.test('mismatched identity', async () => { + const fixture = await createFixture(); + try { + await writeJson(path.join(fixture.agentDir, 'config.json'), { + agent_name: 'claudecode', + agent_id: 'another-agent', + }); + const configPath = path.join(fixture.agentDir, 'config.json'); + const before = await readFile(configPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity does not match/i); + assert.equal(await readFile(configPath, 'utf-8'), before); + await assertMissing(fixture.settingsPath); + } finally { + await fixture.close(); + } + }); +}); + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +test('Claude rejects linked configuration, settings, and runtime paths', async (t) => { + for (const kind of ['configuration', 'settings', 'runtime']) { + await t.test(kind, async () => { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + await mkdir(fixture.homeDir, { recursive: true }); + let link; + let linkType = 'junction'; + if (kind === 'configuration') { + link = fixture.claudeConfigDir; + } else if (kind === 'runtime') { + link = path.join(fixture.homeDir, '.elydora'); + } else { + await mkdir(fixture.claudeConfigDir, { recursive: true }); + const targetFile = path.join(target, 'settings.json'); + await writeFile(targetFile, '{}\n'); + link = fixture.settingsPath; + linkType = 'file'; + await symlink(targetFile, link, linkType).catch((error) => { throw error; }); + } + if (kind !== 'settings') { + try { + await symlink(target, link, linkType); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } + } + 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('Claude installation leaves no transaction artifacts', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +});