fix(node): mirror official Claude hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 06:34:52 -04:00
1 parent 7fe99f7e5f
commit baaa71e32f
13 files changed
+1824 -149

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ This repository publishes the open-source server, console, integration catalog,
- 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, ignore executable lookup as activation evidence, and collapse equal stable and legacy paths to the stable contract. 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. Status requires strict runtime metadata, a canonical private key, exact identity, and non-empty physical scripts. - 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, ignore executable lookup as activation evidence, and collapse equal stable and legacy paths to the stable contract. 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. Status requires strict runtime metadata, a canonical private key, exact identity, and non-empty physical scripts.
- Preserve Kimi TOML comments, array style, and unrelated formatting through syntax-preserving edits, then parse every selected document before writing any file. - Preserve Kimi TOML comments, array style, and unrelated formatting through syntax-preserving edits, then parse every selected document before writing any file.
- 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 each SDK's Grok hook file, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires the exact event triple, strict runtime metadata, a canonical private key, exact identity, and non-empty physical scripts. - 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 each SDK's Grok hook file, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires the exact event triple, strict runtime metadata, a canonical private key, exact identity, and non-empty physical scripts.
- 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 each SDK's Claude Code 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 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.
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open.
+19 -3
View File
@@ -31,9 +31,25 @@
"surface": "cli", "surface": "cli",
"integration_mode": "command_hooks", "integration_mode": "command_hooks",
"config_format": "json", "config_format": "json",
"config_paths": ["~/.claude/settings.json", ".claude/settings.json", ".claude/settings.local.json"], "config_paths": [
"events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" }, "$CLAUDE_CONFIG_DIR/settings.json",
"event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" }, "~/.claude/settings.json",
".claude/settings.json",
".claude/settings.local.json"
],
"events": {
"before_tool": "PreToolUse",
"after_tool": "PostToolUse",
"after_tool_failure": "PostToolUseFailure"
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id",
"call_id": "tool_use_id",
"output": "tool_response",
"error": "error"
},
"blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" }, "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
+2
View File
@@ -53,6 +53,8 @@ npx elydora install \
The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file <path>` and `--token_file <path>`. Secret values are rejected as command-line arguments because process listings and shell history can expose them. Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical-directory checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID. The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file <path>` and `--token_file <path>`. Secret values are rejected as command-line arguments because process listings and shell history can expose them. Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical-directory checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
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. 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. 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.
@@ -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<Record<string, readonly string[]>> = {
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<string, ClaudeGroup[]>;
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<string, RuntimeReference[]> {
const references = new Map<string, RuntimeReference[]>();
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;
}
@@ -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<ClaudeRuntimePaths> {
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<PreparedClaudeInstallation> {
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<void> {
await commitManagedInstallation(prepared, renameFile);
}
+153
View File
@@ -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<ClaudeDocument> {
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<void> {
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<boolean> {
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<boolean> {
for (const contract of contracts) {
if (await runtimeContractExists(contract)) return true;
}
return false;
}
+61 -146
View File
@@ -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 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'; import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'claudecode';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string { function requireEnabledHooks(disabled: boolean, filePath: string): void {
return entry.configDir.replace(/^~/, os.homedir()); if (disabled) {
throw new Error(`Claude Code hooks are disabled by disableAllHooks: ${filePath}`);
} }
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((entry) => {
if (Array.isArray(entry.hooks)) {
const cmds = entry.hooks as Array<Record<string, unknown>>;
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 = { export const claudecodePlugin: AgentPlugin = {
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
const document = await readClaudeDocument();
requireEnabledHooks(document.hooksDisabled, document.filePath);
await preflightClaudeInstallation(config, document.filePath);
},
async install(config: InstallConfig): Promise<void> { async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir(); const document = await readClaudeDocument();
await fsp.mkdir(configDir, { recursive: true }); requireEnabledHooks(document.hooksDisabled, document.filePath);
const paths = await preflightClaudeInstallation(config, document.filePath);
const configPath = resolveConfigPath(); const hooks = removeManagedClaudeHooks(document.hooks);
let settings: Record<string, unknown> = {}; hooks.PreToolUse = [
...(hooks.PreToolUse ?? []),
try { buildClaudeGroup(paths.guardPath, GUARD_STATUS),
const raw = await fsp.readFile(configPath, 'utf-8'); ];
settings = JSON.parse(raw); hooks.PostToolUse = [
} catch { ...(hooks.PostToolUse ?? []),
// File doesn't exist or isn't valid JSON — start fresh buildClaudeGroup(paths.auditPath, AUDIT_STATUS),
} ];
hooks.PostToolUseFailure = [
if (!settings.hooks || typeof settings.hooks !== 'object') { ...(hooks.PostToolUseFailure ?? []),
settings.hooks = {}; buildClaudeGroup(paths.auditPath, AUDIT_STATUS),
} ];
const hooks = settings.hooks as Record<string, unknown>; const rendered = renderClaudeDocument(document, hooks);
const prepared = await prepareClaudeInstallation(config, rendered);
// --- PreToolUse (guard — freeze enforcement) --- await commitClaudeInstallation(prepared);
if (!Array.isArray(hooks.PreToolUse)) { console.log(` Claude Code hooks: ${document.filePath}`);
hooks.PreToolUse = []; console.log(' Claude Code verification: run /hooks and claude doctor.');
}
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
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<Record<string, unknown>>);
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');
}, },
async uninstall(agentId?: string): Promise<void> { async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath(); const document = await readClaudeDocument();
const hooks = removeManagedClaudeHooks(document.hooks, agentId);
let settings: Record<string, unknown>; await writeClaudeDocument(renderClaudeDocument(document, hooks));
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return; // Nothing to uninstall
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
}, },
async status(): Promise<PluginStatus> { async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath(); const document = await readClaudeDocument();
const contracts = claudeRuntimeContracts(document.hooks);
let hookConfigured = false; const hookConfigured = !document.hooksDisabled && contracts.length > 0;
let hookScriptPath = ''; const hookScriptExists = hookConfigured
try { ? await claudeRuntimeFilesExist(contracts)
const raw = await fsp.readFile(configPath, 'utf-8'); : false;
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const checkArr = (arr: unknown) => {
if (!Array.isArray(arr)) return false;
return (arr as Array<Record<string, unknown>>).some((entry) => {
if (Array.isArray(entry.hooks)) {
return (entry.hooks as Array<Record<string, unknown>>).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<Record<string, unknown>>) {
if (Array.isArray(e.hooks)) {
for (const h of e.hooks as Array<Record<string, unknown>>) {
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
}
}
return { return {
installed: hookConfigured && hookScriptExists, installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY, agentName: AGENT_KEY,
displayName: entry.name, displayName: entry.name,
hookConfigured, hookConfigured,
hookScriptExists, hookScriptExists,
configPath, configPath: document.filePath,
}; };
}, },
}; };
@@ -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());
}),
};
}
@@ -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();
}
});
+368
View File
@@ -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();
}
});
+145
View File
@@ -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();
}
});
@@ -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();
}
});
@@ -32,6 +32,29 @@ test('high-drift providers retain their verified hook contracts', async () => {
mechanism: 'exit_code_2', mechanism: 'exit_code_2',
failure_mode: 'fail_open', failure_mode: 'fail_open',
}); });
assert.deepEqual(providers.get('claudecode').config_paths, [
'$CLAUDE_CONFIG_DIR/settings.json',
'~/.claude/settings.json',
'.claude/settings.json',
'.claude/settings.local.json',
]);
assert.deepEqual(providers.get('claudecode').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
after_tool_failure: 'PostToolUseFailure',
});
assert.deepEqual(providers.get('claudecode').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
call_id: 'tool_use_id',
output: 'tool_response',
error: 'error',
});
assert.deepEqual(providers.get('claudecode').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('codex').config_paths, [ assert.deepEqual(providers.get('codex').config_paths, [
'$CODEX_HOME/hooks.json', '$CODEX_HOME/hooks.json',
'~/.codex/hooks.json', '~/.codex/hooks.json',