diff --git a/AGENTS.md b/AGENTS.md index bf6a6fd..040356e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. - 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. -- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. +- 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. - 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 f1c45f9..506de22 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Factory Droid installation preserves the active user source across `~/.factory/h Kimi installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules to every detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the legacy Python CLI's `~/.kimi/config.toml`. A fresh installation targets stable Kimi Code; an existing legacy home activates the migration contract. Empty `KIMI_CODE_HOME` values use the default. The selected TOML files, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Kimi's native snake_case event JSON, propagate freeze and revocation through exit code `2`, and use encoded PowerShell on Windows. Run `/hooks` to inspect the global rules. -Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. +Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory. Qwen Code installation writes user hooks to `$QWEN_HOME/settings.json` (default `~/.qwen/settings.json`). User-level `.qwen/.env` takes precedence over `~/.env` when it defines `QWEN_HOME`; explicit process environment values take highest precedence. Workspace settings remain unchanged. Run `/hooks` to review the definitions. `disableAllHooks` and `--safe-mode` suspend hook execution. diff --git a/src/plugins/cursor-installation.ts b/src/plugins/cursor-installation.ts index 8d30423..358dc8f 100644 --- a/src/plugins/cursor-installation.ts +++ b/src/plugins/cursor-installation.ts @@ -64,6 +64,7 @@ export async function prepareCursorInstallation( guardOptions: { failClosed: true, successOutput: '{"permission":"allow"}\n', + denyProtocol: 'cursor', }, auditOptions: { failClosed: true, diff --git a/src/plugins/grok-command.ts b/src/plugins/grok-command.ts new file mode 100644 index 0000000..064121d --- /dev/null +++ b/src/plugins/grok-command.ts @@ -0,0 +1,159 @@ +import os from 'node:os'; +import path from 'node:path'; + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export interface GrokRuntimeReference { + readonly agentId: string; + readonly scriptPath: string; +} + +export function sameGrokPath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +export function sameGrokAgentId(left: string, right: string): boolean { + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function windowsPowerShellPath(): string { + const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined; + const systemRoot = configuredRoot + && path.win32.isAbsolute(configuredRoot) + && !/["%\r\n]/.test(configuredRoot) + ? configuredRoot + : 'C:\\Windows'; + return path.win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); +} + +function windowsCommand(scriptPath: string): string { + const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; + const encoded = Buffer.from(source, 'utf16le').toString('base64'); + return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; +} + +export function buildGrokCommand(scriptPath: string): string { + if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { + throw new Error('Grok hook commands require absolute executable and script paths'); + } + return process.platform === 'win32' + ? windowsCommand(scriptPath) + : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; +} + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + const apostrophe = `'"'"'`; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(apostrophe, index)) { + value += "'"; + index += apostrophe.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parsePosixCommand(command: string): readonly [string, string] | undefined { + const executable = readPosixArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPosixArgument(command, executable.next + 1); + if (!script || script.next !== command.length) return undefined; + return [executable.value, script.value]; +} + +function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] !== "'") { + value += command[index]; + continue; + } + if (command[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return { value, next: index + 1 }; + } + return undefined; +} + +function parsePowerShellSource(source: string): readonly [string, string] | undefined { + if (!source.startsWith('& ')) return undefined; + const executable = readPowerShellArgument(source, 2); + if (!executable || source[executable.next] !== ' ') return undefined; + const script = readPowerShellArgument(source, executable.next + 1); + if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; + return [executable.value, script.value]; +} + +function parseWindowsCommand(command: string): readonly [string, string] | undefined { + const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); + if (!match + || !path.win32.isAbsolute(match[1]) + || path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined; + const encoded = match[2]; + const buffer = Buffer.from(encoded, 'base64'); + if (buffer.length % 2 !== 0 || buffer.toString('base64') !== encoded) return undefined; + return parsePowerShellSource(buffer.toString('utf16le')); +} + +function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined { + const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command); + return match ? [match[1], match[2]] : undefined; +} + +function isNodeExecutable(filePath: string): boolean { + const basename = path.basename(filePath).toLowerCase(); + return basename === 'node' || basename === 'node.exe'; +} + +export function grokRuntimeReference( + command: string, + scriptName: string, +): GrokRuntimeReference | undefined { + const parsed = parsePosixCommand(command) + ?? parseWindowsCommand(command) + ?? parseLegacyWindowsCommand(command); + if (!parsed + || !path.isAbsolute(parsed[0]) + || !path.isAbsolute(parsed[1]) + || !isNodeExecutable(parsed[0]) + || path.basename(parsed[1]) !== scriptName) return undefined; + const agentDirectory = path.dirname(parsed[1]); + if (!sameGrokPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } + const agentId = path.basename(agentDirectory); + if (!agentId || agentId === '.' || agentId === '..') return undefined; + return { agentId, scriptPath: parsed[1] }; +} diff --git a/src/plugins/grok-contract.ts b/src/plugins/grok-contract.ts new file mode 100644 index 0000000..c78a0b6 --- /dev/null +++ b/src/plugins/grok-contract.ts @@ -0,0 +1,276 @@ +import { + buildGrokCommand, + grokRuntimeReference, + sameGrokAgentId, + type GrokRuntimeReference, +} from './grok-command.js'; +import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; + +export const AGENT_KEY = 'grok'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const HOOK_TIMEOUT_SECONDS = 10; + +const MATCHER_REJECTING_EVENTS = new Set([ + 'SessionStart', + 'SessionEnd', + 'Stop', + 'UserPromptSubmit', +]); + +export type ManagedGrokEvent = 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure'; + +export interface GrokHandler extends JsonObject { + readonly type: 'command' | 'http'; + readonly command?: string; + readonly url?: string; + readonly timeout?: number; +} + +export interface GrokGroup extends JsonObject { + readonly matcher?: string; + readonly hooks: GrokHandler[]; +} + +export type GrokHooks = Record; + +export interface GrokDocument { + readonly exists: boolean; + readonly filePath: string; + readonly root: JsonObject; + readonly hooks: GrokHooks; + readonly raw?: string; +} + +export interface RenderedGrokDocument { + readonly document: GrokDocument; + readonly changed: boolean; + readonly next?: string; +} + +export interface GrokRuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +function validateTimeout(value: unknown, label: string): void { + if (value === undefined) return; + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${label} timeout must be a non-negative integer`); + } +} + +function validateHandler( + value: unknown, + event: string, + groupIndex: number, + handlerIndex: number, +): GrokHandler { + const label = `Grok user hooks handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (value.type !== 'command' && value.type !== 'http') { + throw new Error(`${label} has unsupported type "${String(value.type)}"`); + } + if (value.type === 'command' && (typeof value.command !== 'string' || !value.command)) { + throw new Error(`${label} requires a non-empty command`); + } + if (value.type === 'http' && (typeof value.url !== 'string' || !value.url)) { + throw new Error(`${label} requires a non-empty url`); + } + validateTimeout(value.timeout, label); + if (value.env !== undefined) { + if (!isObject(value.env) + || Object.values(value.env).some((item) => typeof item !== 'string')) { + throw new Error(`${label} env must map names to strings`); + } + } + return value as GrokHandler; +} + +function validateGroup(value: unknown, event: string, groupIndex: number): GrokGroup { + const label = `Grok user hooks group hooks.${event}[${groupIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (value.matcher !== undefined && typeof value.matcher !== 'string') { + throw new Error(`${label} matcher must be a string`); + } + if (value.matcher !== undefined && MATCHER_REJECTING_EVENTS.has(event)) { + throw new Error(`${label} cannot declare a matcher for ${event}`); + } + 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, groupIndex, handlerIndex), + ), + } as GrokGroup; +} + +function readHooks(value: unknown): GrokHooks { + if (value === undefined) return {}; + if (!isObject(value)) throw new Error('Grok user hooks field "hooks" must be an object'); + const hooks: GrokHooks = {}; + for (const [event, candidate] of Object.entries(value)) { + if (!Array.isArray(candidate)) { + throw new Error(`Grok user hooks field "hooks.${event}" must be an array`); + } + hooks[event] = candidate.map((group, index) => validateGroup(group, event, index)); + } + return hooks; +} + +export function parseGrokDocument(filePath: string, raw: string): GrokDocument { + const root = parseStrictJsonObject(raw, `Grok user hooks at ${filePath}`); + return { exists: true, filePath, root, hooks: readHooks(root.hooks), raw }; +} + +export function createGrokDocument(filePath: string): GrokDocument { + return { exists: false, filePath, root: {}, hooks: {} }; +} + +export function buildGrokGroup(scriptPath: string): GrokGroup { + return { + hooks: [{ + type: 'command', + command: buildGrokCommand(scriptPath), + timeout: HOOK_TIMEOUT_SECONDS, + }], + }; +} + +function exactManagedGroup(group: GrokGroup): boolean { + return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks'); +} + +function exactManagedHandler(handler: GrokHandler): boolean { + return Object.keys(handler).sort().join('|') === 'command|timeout|type' + && handler.type === 'command' + && handler.timeout === HOOK_TIMEOUT_SECONDS + && typeof handler.command === 'string'; +} + +function managedReference( + handler: GrokHandler, + scriptName: string, +): GrokRuntimeReference | undefined { + if (!exactManagedHandler(handler)) return undefined; + return grokRuntimeReference(handler.command as string, scriptName); +} + +function removeFromGroups( + groups: readonly GrokGroup[], + scriptName: string, + agentId?: string, +): GrokGroup[] { + const result: GrokGroup[] = []; + for (const group of groups) { + if (!exactManagedGroup(group)) { + result.push(group); + continue; + } + const handlers = group.hooks.filter((handler) => { + const reference = managedReference(handler, scriptName); + return !reference + || (agentId !== undefined && !sameGrokAgentId(reference.agentId, agentId)); + }); + if (handlers.length > 0) result.push({ hooks: handlers }); + } + return result; +} + +function managedEvent(event: string): readonly [ManagedGrokEvent, string] | undefined { + if (event === 'PreToolUse') return ['PreToolUse', GUARD_SCRIPT]; + if (event === 'PostToolUse') return ['PostToolUse', AUDIT_SCRIPT]; + if (event === 'PostToolUseFailure') return ['PostToolUseFailure', AUDIT_SCRIPT]; + return undefined; +} + +export function removeManagedGrokHooks(hooks: GrokHooks, agentId?: string): GrokHooks { + const next: GrokHooks = Object.fromEntries( + Object.entries(hooks).map(([event, groups]) => [event, [...groups]]), + ); + for (const [event, scriptName] of [ + ['PreToolUse', GUARD_SCRIPT], + ['PostToolUse', AUDIT_SCRIPT], + ['PostToolUseFailure', AUDIT_SCRIPT], + ] as const) { + const groups = removeFromGroups(next[event] ?? [], scriptName, agentId); + if (groups.length > 0) next[event] = groups; + else delete next[event]; + } + return next; +} + +function entirelyManaged(document: GrokDocument): 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 handlerCount = 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[1]))) return false; + handlerCount += group.hooks.length; + } + } + return handlerCount > 0; +} + +export function renderGrokDocument( + document: GrokDocument, + hooks: GrokHooks, +): RenderedGrokDocument { + 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 GrokGroup[], + scriptName: string, +): Map { + const result = new Map(); + for (const group of groups) { + if (!exactManagedGroup(group)) continue; + for (const handler of group.hooks) { + const reference = managedReference(handler, scriptName); + if (!reference) continue; + const key = process.platform === 'win32' + ? reference.agentId.toLowerCase() + : reference.agentId; + const entries = result.get(key) ?? []; + entries.push(reference); + result.set(key, entries); + } + } + return result; +} + +export function grokRuntimeContracts(hooks: GrokHooks): GrokRuntimeContract[] { + const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT); + const successes = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT); + const failures = referencesForEvent(hooks.PostToolUseFailure ?? [], AUDIT_SCRIPT); + const contracts: GrokRuntimeContract[] = []; + 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; + contracts.push({ + agentId: guard[0].agentId, + guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, + }); + } + return contracts; +} diff --git a/src/plugins/grok-installation.ts b/src/plugins/grok-installation.ts new file mode 100644 index 0000000..e856f7b --- /dev/null +++ b/src/plugins/grok-installation.ts @@ -0,0 +1,116 @@ +import path from 'node:path'; +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + type RenderedGrokDocument, +} from './grok-contract.js'; +import { + commitManagedInstallation, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, + type PreparedManagedTransaction, +} from './managed-transaction.js'; + +const DISPLAY_NAME = 'Grok Build'; +const HOOKS_DIRECTORY_LABEL = 'Grok hooks directory'; +const HOOKS_LABEL = 'Grok user hooks'; + +export type GrokRuntimePaths = ManagedRuntimePaths; +export type PreparedGrokInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +export async function preflightGrokInstallation( + config: InstallConfig, + hooksPath: string, +): Promise { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }], + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function prepareGrokInstallation( + config: InstallConfig, + rendered: RenderedGrokDocument, +): Promise { + const hooksSource = rendered.next ?? rendered.document.raw; + if (hooksSource === undefined) throw new Error('Grok hook configuration is missing'); + const prepared = await prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: [{ + directoryLabel: HOOKS_DIRECTORY_LABEL, + label: HOOKS_LABEL, + filePath: rendered.document.filePath, + expectedSource: rendered.document.raw, + source: hooksSource, + }], + config, + guardOptions: { denyProtocol: 'grok' }, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); + const hooksDirectory = path.dirname(rendered.document.filePath); + return { + ...prepared, + transaction: { + ...prepared.transaction, + directories: [ + { path: path.dirname(hooksDirectory), label: 'Grok home directory' }, + ...prepared.transaction.directories, + ], + }, + }; +} + +export async function commitGrokInstallation( + prepared: PreparedGrokInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} + +export async function prepareGrokUninstall( + rendered: RenderedGrokDocument, +): Promise { + const change = rendered.changed + ? await prepareManagedFileChange({ + filePath: rendered.document.filePath, + label: HOOKS_LABEL, + next: rendered.next, + mode: 0o600, + expectedSource: rendered.document.raw, + verifyExpectedSource: true, + }) + : undefined; + return { + displayName: DISPLAY_NAME, + operation: 'uninstall', + directories: rendered.changed + ? [ + { + path: path.dirname(path.dirname(rendered.document.filePath)), + label: 'Grok home directory', + }, + { path: path.dirname(rendered.document.filePath), label: HOOKS_DIRECTORY_LABEL }, + ] + : [], + changes: change ? [change] : [], + }; +} + +export async function commitGrokUninstall( + prepared: PreparedManagedTransaction, + renameFile?: RenameFile, +): Promise { + await commitManagedTransaction(prepared, renameFile); +} diff --git a/src/plugins/grok-io.ts b/src/plugins/grok-io.ts new file mode 100644 index 0000000..c62a787 --- /dev/null +++ b/src/plugins/grok-io.ts @@ -0,0 +1,112 @@ +import os from 'node:os'; +import path from 'node:path'; +import { + AGENT_KEY, + createGrokDocument, + parseGrokDocument, + type GrokDocument, + type GrokRuntimeContract, +} from './grok-contract.js'; +import { sameGrokAgentId, sameGrokPath } from './grok-command.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; + +const CONFIG_FILE = 'elydora-audit.json'; + +export function grokConfigPath(): string { + const configured = process.env.GROK_HOME; + const grokHome = configured === undefined || configured === '' + ? path.join(os.homedir(), '.grok') + : path.resolve(configured); + return path.join(grokHome, 'hooks', CONFIG_FILE); +} + +export async function readGrokDocument(): Promise { + const filePath = grokConfigPath(); + await inspectPhysicalDirectory(path.dirname(path.dirname(filePath)), 'Grok home directory'); + await inspectPhysicalDirectory(path.dirname(filePath), 'Grok hooks directory'); + const snapshot = await readPhysicalFile(filePath, 'Grok user hooks'); + return snapshot + ? parseGrokDocument(filePath, snapshot.contents) + : createGrokDocument(filePath); +} + +function requireNonEmptyString(value: unknown, field: string, configPath: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); + } + return value; +} + +function validateRuntimeConfig( + config: JsonObject, + contract: GrokRuntimeContract, + 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 (!sameGrokAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { + throw new Error(`Elydora runtime identity does not match Grok 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: GrokRuntimeContract): Promise { + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = path.dirname(contract.guardPath); + if (!sameGrokPath(path.dirname(agentDirectory), runtimeRoot) + || !sameGrokPath(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 grokRuntimeFilesExist( + contracts: readonly GrokRuntimeContract[], +): Promise { + for (const contract of contracts) { + if (await runtimeContractExists(contract)) return true; + } + return false; +} diff --git a/src/plugins/grok.ts b/src/plugins/grok.ts index ac143e6..a6ea403 100644 --- a/src/plugins/grok.ts +++ b/src/plugins/grok.ts @@ -1,458 +1,83 @@ -import { randomUUID } from 'node:crypto'; -import fsp from 'node:fs/promises'; -import type { FileHandle } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + buildGrokGroup, + grokRuntimeContracts, + removeManagedGrokHooks, + renderGrokDocument, + type GrokHooks, +} from './grok-contract.js'; +import { + commitGrokInstallation, + commitGrokUninstall, + preflightGrokInstallation, + prepareGrokInstallation, + prepareGrokUninstall, +} from './grok-installation.js'; +import { grokRuntimeFilesExist, readGrokDocument } from './grok-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; -const AGENT_KEY = 'grok'; -const GUARD_SCRIPT = 'guard.js'; -const AUDIT_SCRIPT = 'hook.js'; -const HOOK_TIMEOUT_SECONDS = 10; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -type JsonObject = Record; - -interface GrokHandler extends JsonObject { - readonly type: 'command' | 'http'; - readonly command?: string; - readonly url?: string; - readonly timeout?: number; -} - -interface GrokGroup extends JsonObject { - readonly matcher?: string; - readonly hooks: GrokHandler[]; -} - -type GrokHooks = Record; - -interface GrokDocument { - readonly exists: boolean; - readonly configPath: string; - readonly root: JsonObject; - readonly hooks: GrokHooks; -} - -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -interface RuntimeContract { - readonly agentId: string; - readonly guardPath: string; - readonly auditPath: string; -} - -function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function hasOwn(value: JsonObject, key: string): boolean { - return Object.prototype.hasOwnProperty.call(value, key); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function hasErrorCode(error: unknown, code: string): boolean { - return isObject(error) && error.code === code; -} - -function resolveConfigPath(): string { - const grokHome = process.env.GROK_HOME || path.join(os.homedir(), '.grok'); - return path.join(grokHome, 'hooks', entry.configFile); -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quoteWindows(value: string): string { - return `"${value.replaceAll('"', '\\"')}"`; -} - -function buildCommand(scriptPath: string): string { - const quote = process.platform === 'win32' ? quoteWindows : quotePosix; - return `${quote(process.execPath)} ${quote(scriptPath)}`; -} - -function buildHandler(scriptPath: string): GrokHandler { +function installedHooks( + hooks: GrokHooks, + guardPath: string, + auditPath: string, +): GrokHooks { + const cleaned = removeManagedGrokHooks(hooks); return { - type: 'command', - command: buildCommand(scriptPath), - timeout: HOOK_TIMEOUT_SECONDS, + ...cleaned, + PreToolUse: [...(cleaned.PreToolUse ?? []), buildGrokGroup(guardPath)], + PostToolUse: [...(cleaned.PostToolUse ?? []), buildGrokGroup(auditPath)], + PostToolUseFailure: [ + ...(cleaned.PostToolUseFailure ?? []), + buildGrokGroup(auditPath), + ], }; } -function readWindowsArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== '"') return undefined; - let value = ''; - for (let index = start + 1; index < command.length; index += 1) { - if (command[index] === '\\' && command[index + 1] === '"') { - value += '"'; - index += 1; - continue; - } - if (command[index] === '"') return { value, next: index + 1 }; - value += command[index]; - } - return undefined; -} - -const POSIX_APOSTROPHE = `'"'"'`; - -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length;) { - if (command.startsWith(POSIX_APOSTROPHE, index)) { - value += "'"; - index += POSIX_APOSTROPHE.length; - continue; - } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; - } - return undefined; -} - -function parseGeneratedCommand(command: string): readonly [string, string] | undefined { - const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument; - const executable = readArgument(command, 0); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readArgument(command, executable.next + 1); - if (!script || script.next !== command.length || !executable.value || !script.value) return undefined; - return [executable.value, script.value]; -} - -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 managedAgentId(handler: GrokHandler, scriptName: string): string | undefined { - if (handler.type !== 'command' - || handler.timeout !== HOOK_TIMEOUT_SECONDS - || typeof handler.command !== 'string') return undefined; - const parsed = parseGeneratedCommand(handler.command); - if (!parsed) return undefined; - const scriptPath = parsed[1]; - if (path.basename(scriptPath) !== scriptName) return undefined; - const agentDirectory = path.dirname(scriptPath); - const runtimeRoot = path.join(os.homedir(), '.elydora'); - if (!samePath(path.dirname(agentDirectory), runtimeRoot)) return undefined; - const agentId = path.basename(agentDirectory); - return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; -} - -function validateHandler(value: unknown, event: string, groupIndex: number, handlerIndex: number): GrokHandler { - const label = `Grok hooks config handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`; - if (!isObject(value)) throw new Error(`${label} must be an object`); - if (value.type !== 'command' && value.type !== 'http') { - throw new Error(`${label} has unsupported type "${String(value.type)}"`); - } - if (value.type === 'command' && (typeof value.command !== 'string' || value.command.length === 0)) { - throw new Error(`${label} requires a non-empty command`); - } - if (value.type === 'http' && (typeof value.url !== 'string' || value.url.length === 0)) { - throw new Error(`${label} requires a non-empty url`); - } - 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`); - } - return value as GrokHandler; -} - -function validateGroup(value: unknown, event: string, groupIndex: number): GrokGroup { - const label = `Grok hooks config group hooks.${event}[${groupIndex}]`; - if (!isObject(value)) throw new Error(`${label} must be an object`); - if (value.matcher !== undefined && 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`); - const handlers = value.hooks.map( - (handler, handlerIndex) => validateHandler(handler, event, groupIndex, handlerIndex), - ); - return { ...value, hooks: handlers } as GrokGroup; -} - -function readHooks(root: JsonObject): GrokHooks { - if (root.hooks === undefined) return {}; - if (!isObject(root.hooks)) throw new Error('Grok hooks config field "hooks" must be an object'); - const hooks: GrokHooks = {}; - for (const [event, value] of Object.entries(root.hooks)) { - if (!Array.isArray(value)) { - throw new Error(`Grok hooks config field "hooks.${event}" must be an array`); - } - hooks[event] = value.map((group, index) => validateGroup(group, event, index)); - } - return hooks; -} - -async function readConfig(): Promise { - const configPath = resolveConfigPath(); - let raw: string; - try { - raw = await fsp.readFile(configPath, 'utf-8'); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return { exists: false, configPath, root: {}, hooks: {} }; - throw new Error(`Read Grok hooks config at ${configPath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - let root: unknown; - try { - root = JSON.parse(raw); - } catch (error) { - throw new Error(`Failed to parse Grok hooks config at ${configPath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!isObject(root)) throw new Error(`Grok hooks config at ${configPath} must contain a JSON object`); - return { exists: true, configPath, root, hooks: readHooks(root) }; -} - -function removeManaged( - groups: GrokGroup[], - scriptName: string, - agentId?: string, -): { readonly groups: GrokGroup[]; readonly changed: boolean } { - let changed = false; - const result: GrokGroup[] = []; - for (const group of groups) { - if (hasOwn(group, 'matcher')) { - result.push(group); - continue; - } - let groupChanged = false; - const handlers = group.hooks.filter((handler) => { - const managedId = managedAgentId(handler, scriptName); - const remove = managedId !== undefined - && (agentId === undefined || sameAgentId(managedId, agentId)); - if (remove) { - changed = true; - groupChanged = true; - } - return !remove; - }); - if (!groupChanged) result.push(group); - else if (handlers.length > 0) result.push({ ...group, hooks: handlers }); - } - return { groups: result, changed }; -} - -function removeManagedHooks(hooks: GrokHooks, agentId?: string): { - readonly hooks: GrokHooks; - readonly changed: boolean; -} { - const next = { ...hooks }; - let changed = false; - for (const [event, scriptName] of [ - ['PreToolUse', GUARD_SCRIPT], - ['PostToolUse', AUDIT_SCRIPT], - ] as const) { - const result = removeManaged(next[event] ?? [], scriptName, agentId); - if (!result.changed) continue; - changed = true; - if (result.groups.length > 0) next[event] = result.groups; - else delete next[event]; - } - return { hooks: next, changed }; -} - -async function failWrite( - handle: FileHandle | undefined, - tempPath: string, - configPath: string, - cause: unknown, -): Promise { - const errors = [asError(cause)]; - if (handle) { - try { - await handle.close(); - } catch (error) { - errors.push(asError(error)); - } - } - try { - await fsp.unlink(tempPath); - } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error)); - } - const message = `Write Grok hooks config at ${configPath}: ${errorMessage(cause)}`; - if (errors.length > 1) throw new AggregateError(errors, message); - throw new Error(message, { cause: errors[0] }); -} - -async function writeConfig(configPath: string, root: JsonObject): Promise { - const directory = path.dirname(configPath); - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - const tempPath = path.join(directory, `.${path.basename(configPath)}.${randomUUID()}.tmp`); - let handle: FileHandle | undefined; - try { - handle = await fsp.open(tempPath, 'wx', 0o600); - await handle.writeFile(JSON.stringify(root, null, 2) + '\n', 'utf-8'); - await handle.sync(); - await handle.close(); - handle = undefined; - await fsp.rename(tempPath, configPath); - } catch (error) { - await failWrite(handle, tempPath, configPath, error); - } -} - -async function removeConfig(configPath: string): Promise { - try { - await fsp.unlink(configPath); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return; - throw new Error(`Remove Grok hooks config at ${configPath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } -} - -async function regularFileExists(filePath: string, label: string): Promise { - try { - return (await fsp.stat(filePath)).isFile(); - } catch (error) { - if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; - throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) }); - } -} - -async function requireRuntime(filePath: string, label: string): Promise { - if (!filePath) throw new Error(`${label} path is required`); - if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); -} - -async function readJsonObject(filePath: string, label: string): Promise { - let raw: string; - try { - raw = await fsp.readFile(filePath, 'utf-8'); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return undefined; - throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { cause: asError(error) }); - } - let value: unknown; - try { - value = JSON.parse(raw); - } catch (error) { - throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`); - return value; -} - -function managedIds(groups: GrokGroup[], scriptName: string): Set { - const ids = new Set(); - for (const group of groups) { - if (hasOwn(group, 'matcher')) continue; - for (const handler of group.hooks) { - const agentId = managedAgentId(handler, scriptName); - if (agentId) ids.add(agentId); - } - } - return ids; -} - -function runtimeContracts(hooks: GrokHooks): RuntimeContract[] { - const guards = managedIds(hooks.PreToolUse ?? [], GUARD_SCRIPT); - const audits = managedIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT); - const root = path.join(os.homedir(), '.elydora'); - return [...guards] - .filter((agentId) => audits.has(agentId)) - .map((agentId) => ({ - agentId, - guardPath: path.join(root, agentId, GUARD_SCRIPT), - auditPath: path.join(root, agentId, AUDIT_SCRIPT), - })); -} - -async function runtimeScriptsExist(contracts: RuntimeContract[]): Promise { - const root = path.join(os.homedir(), '.elydora'); - let entries: Array<{ isDirectory(): boolean; name: string }>; - try { - entries = await fsp.readdir(root, { withFileTypes: true }); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return false; - throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - for (const contract of contracts) { - if (!entries.some((item) => item.isDirectory() && sameAgentId(item.name, contract.agentId))) continue; - const runtimeConfigPath = path.join(root, contract.agentId, 'config.json'); - const runtimeConfig = await readJsonObject(runtimeConfigPath, 'Elydora runtime config'); - if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue; - const [guardExists, auditExists] = await Promise.all([ - regularFileExists(contract.guardPath, 'Elydora guard runtime'), - regularFileExists(contract.auditPath, 'Elydora audit runtime'), - ]); - if (guardExists && auditExists) return true; - } - return false; -} - export const grokPlugin: AgentPlugin = { + managesRuntime: true, + + async preflightInstall(config: InstallConfig): Promise { + const document = await readGrokDocument(); + await preflightGrokInstallation(config, document.filePath); + }, + async install(config: InstallConfig): Promise { - if (!config.agentId) throw new Error('agentId is required'); - const document = await readConfig(); - await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); - await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); - const cleaned = removeManagedHooks(document.hooks).hooks; - const hooks: GrokHooks = { - ...cleaned, - PreToolUse: [...(cleaned.PreToolUse ?? []), { hooks: [buildHandler(config.guardScriptPath)] }], - PostToolUse: [...(cleaned.PostToolUse ?? []), { hooks: [buildHandler(config.hookScriptPath)] }], - }; - await writeConfig(document.configPath, { ...document.root, hooks }); - console.log(' Grok Build: global PreToolUse and PostToolUse hooks installed.'); + const document = await readGrokDocument(); + const paths = await preflightGrokInstallation(config, document.filePath); + const rendered = renderGrokDocument( + document, + installedHooks(document.hooks, paths.guardPath, paths.auditPath), + ); + await commitGrokInstallation(await prepareGrokInstallation(config, rendered)); + console.log( + ' Grok Build: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.', + ); }, async uninstall(agentId?: string): Promise { - const document = await readConfig(); - if (!document.exists) return; - const result = removeManagedHooks(document.hooks, agentId); - if (!result.changed) return; - const root = { ...document.root }; - if (Object.keys(result.hooks).length > 0) root.hooks = result.hooks; - else delete root.hooks; - if (Object.keys(root).length === 0) await removeConfig(document.configPath); - else await writeConfig(document.configPath, root); + const document = await readGrokDocument(); + const rendered = renderGrokDocument( + document, + removeManagedGrokHooks(document.hooks, agentId), + ); + await commitGrokUninstall(await prepareGrokUninstall(rendered)); }, async status(): Promise { - const document = await readConfig(); - const contracts = runtimeContracts(document.hooks); + const document = await readGrokDocument(); + const contracts = grokRuntimeContracts(document.hooks); const hookConfigured = contracts.length > 0; - const hookScriptExists = hookConfigured ? await runtimeScriptsExist(contracts) : false; + const hookScriptExists = hookConfigured && await grokRuntimeFilesExist(contracts); return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, displayName: entry.name, hookConfigured, hookScriptExists, - configPath: document.configPath, + configPath: document.filePath, }; }, }; diff --git a/src/plugins/guard-template.ts b/src/plugins/guard-template.ts index 366318b..8e6a489 100644 --- a/src/plugins/guard-template.ts +++ b/src/plugins/guard-template.ts @@ -3,6 +3,7 @@ import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js'; export interface GuardScriptOptions { readonly failClosed?: boolean; readonly successOutput?: string; + readonly denyProtocol?: 'cursor' | 'grok'; } export function generateGuardScript( @@ -12,6 +13,7 @@ export function generateGuardScript( ): string { const failClosed = options.failClosed ?? false; const successOutput = options.successOutput ?? ''; + const denyProtocol = options.denyProtocol ?? ''; return `#!/usr/bin/env node 'use strict'; @@ -25,6 +27,7 @@ const AGENT_NAME = ${JSON.stringify(agentName)}; const AGENT_ID = ${JSON.stringify(agentId)}; const SUCCESS_OUTPUT = ${JSON.stringify(successOutput)}; const FAIL_CLOSED = ${JSON.stringify(failClosed)}; +const DENY_PROTOCOL = ${JSON.stringify(denyProtocol)}; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json'); @@ -42,12 +45,14 @@ function validateStatus(value, source) { function blockAgent(status) { const state = status === 'frozen' ? 'frozen' : 'revoked'; const message = 'Agent "' + AGENT_NAME + '" is ' + state + ' in Elydora.'; - if (SUCCESS_OUTPUT) { + if (DENY_PROTOCOL === 'cursor') { process.stdout.write(JSON.stringify({ permission: 'deny', userMessage: message, agentMessage: message + ' Tool execution is blocked.', }) + '\\n'); + } else if (DENY_PROTOCOL === 'grok') { + process.stdout.write(JSON.stringify({ decision: 'deny', reason: message }) + '\\n'); } process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\\n'); process.exitCode = 2; diff --git a/test-support/grok-test-helpers.mjs b/test-support/grok-test-helpers.mjs new file mode 100644 index 0000000..c1949bb --- /dev/null +++ b/test-support/grok-test-helpers.mjs @@ -0,0 +1,212 @@ +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/grok.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/grok-contract.js')).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/grok-io.js')).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/grok-installation.js'), +).href; +export const cliPath = path.resolve('dist/cli.js'); + +export function runNode(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(process.execPath, args, { + cwd, + env: childEnv, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function runGrokHook(command, input, fixture, environment = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, { + shell: true, + cwd: fixture.projectDir, + env: { + ...process.env, + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GROK_HOME: fixture.grokHomeOverride ?? '', + ...environment, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'grok', + 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 { grokPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await grokPlugin[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.grokHomeOverride === undefined) unset.push('GROK_HOME'); + else env.GROK_HOME = fixture.grokHomeOverride; + return runNode( + ['--input-type=module', '--eval', source], + env, + fixture.projectDir, + '', + unset, + ); +} + +async function writeOptional(filePath, contents) { + if (contents === undefined) return; + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); +} + +export async function createFixture({ + baseUrl = 'http://127.0.0.1:9', + config, + explicitGrokHome = true, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %GROK_HOOK_EVENT%"); + const projectDir = path.join(rootDir, 'project with spaces'); + const grokHome = explicitGrokHome + ? path.join(homeDir, 'custom grok') + : path.join(homeDir, '.grok'); + const configPath = path.join(grokHome, 'hooks', 'elydora-audit.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 }); + await writeOptional(configPath, config); + const fixture = { + agentDir, + baseUrl, + configPath, + grokHome, + grokHomeOverride: explicitGrokHome ? grokHome : undefined, + guardScriptPath, + homeDir, + hookScriptPath, + projectDir, + rootDir, + install(overrides = {}) { + return runPlugin(this, 'install', installConfig(this, overrides)); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; + return fixture; +} + +export async function readGrokConfig(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, config: JSON.parse(raw) }; +} + +export function managedHandler(config, event) { + for (const group of [...(config.hooks?.[event] ?? [])].reverse()) { + if (Object.keys(group).length !== 1 || !Array.isArray(group.hooks)) continue; + const handler = group.hooks.findLast((candidate) => ( + Object.keys(candidate).sort().join('|') === 'command|timeout|type' + && candidate.type === 'command' + && candidate.timeout === 10 + )); + if (handler) return handler; + } + return undefined; +} + +export function assertManagedHandler(handler) { + assert(handler); + assert.deepEqual(Object.keys(handler).sort(), ['command', 'timeout', 'type']); + assert.equal(handler.type, 'command'); + assert.equal(handler.timeout, 10); + if (process.platform === 'win32') { + assert.match(handler.command, /^"[^"\r\n]+powershell\.exe" .* -EncodedCommand /i); + } else { + assert.match(handler.command, /^'[^']*node[^']*' /i); + } +} + +export function legacyCommand(scriptPath) { + if (process.platform === 'win32') return `"${process.execPath}" "${scriptPath}"`; + const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; + return `${quote(process.execPath)} ${quote(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/grok-plugin.test.mjs b/test/grok-plugin.test.mjs index 159f91e..925916e 100644 --- a/test/grok-plugin.test.mjs +++ b/test/grok-plugin.test.mjs @@ -1,146 +1,33 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { pathToFileURL } from 'node:url'; +import { + assertManagedHandler, + cliPath, + createFixture, + legacyCommand, + managedHandler, + readGrokConfig, + registryModuleUrl, + runNode, + runPlugin, +} from '../test-support/grok-test-helpers.mjs'; -const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/grok.js')).href; -const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -const cliPath = path.resolve('dist/cli.js'); - -function runNode(args, env, input = '', unset = [], cwd) { - return new Promise((resolve, reject) => { - const childEnv = { ...process.env, ...env }; - for (const key of unset) delete childEnv[key]; - const child = spawn(process.execPath, args, { - cwd, - env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); } -function runCommand(command, input = '') { - return new Promise((resolve, reject) => { - const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -function quotePosix(value) { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quoteWindows(value) { - return `"${value.replaceAll('"', '\\"')}"`; -} - -function buildCommand(scriptPath) { - const quote = process.platform === 'win32' ? quoteWindows : quotePosix; - return `${quote(process.execPath)} ${quote(scriptPath)}`; -} - -async function runPlugin(fixture, method, argument) { - const script = ` - import { grokPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await grokPlugin[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, - }; - if (fixture.explicitGrokHome) env.GROK_HOME = fixture.grokHome; - return runNode( - ['--input-type=module', '--eval', script], - env, - '', - fixture.explicitGrokHome ? [] : ['GROK_HOME'], - fixture.homeDir, +function assertManagedTriple(config) { + assertManagedHandler(managedHandler(config, 'PreToolUse')); + assertManagedHandler(managedHandler(config, 'PostToolUse')); + assertManagedHandler(managedHandler(config, 'PostToolUseFailure')); + assert.equal( + managedHandler(config, 'PostToolUse').command, + managedHandler(config, 'PostToolUseFailure').command, ); } -async function createFixture({ - config, - explicitGrokHome = true, - guardSource, - hookSource, -} = {}) { - const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-')); - const grokHome = explicitGrokHome ? path.join(homeDir, 'custom-grok') : path.join(homeDir, '.grok'); - const configPath = path.join(grokHome, 'hooks', 'elydora-audit.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(agentDir, { recursive: true }); - await writeFile( - guardScriptPath, - guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n", - ); - await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n'); - await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({ - agent_id: 'agent-1', - agent_name: 'grok', - })); - if (config !== undefined) { - await mkdir(path.dirname(configPath), { recursive: true }); - await writeFile(configPath, typeof config === 'string' ? config : JSON.stringify(config, null, 2)); - } - const fixture = { - agentDir, - configPath, - explicitGrokHome, - grokHome, - guardScriptPath, - homeDir, - hookScriptPath, - async close() { - await rm(homeDir, { recursive: true, force: true }); - }, - }; - fixture.installResult = await runPlugin(fixture, 'install', { - agentName: 'grok', - agentId: 'agent-1', - baseUrl: 'https://api.elydora.com', - guardScriptPath, - hookScriptPath, - }); - return fixture; -} - -function managedHandler(config, event, scriptName) { - for (const group of config.hooks?.[event] ?? []) { - if (Object.hasOwn(group, 'matcher')) continue; - const handler = group.hooks.find((candidate) => candidate.command?.includes(scriptName)); - if (handler) return handler; - } - return undefined; -} - -function assertStrictHandler(handler) { - assert.deepEqual(Object.keys(handler).sort(), ['command', 'timeout', 'type']); - assert.equal(handler.type, 'command'); - assert.equal(handler.timeout, 10); -} - test('Grok Build is registered in the SDK and CLI', async () => { const { SUPPORTED_AGENTS } = await import(registryModuleUrl); assert.deepEqual(SUPPORTED_AGENTS.get('grok'), { @@ -148,71 +35,63 @@ test('Grok Build is registered in the SDK and CLI', async () => { configDir: '~/.grok/hooks', configFile: 'elydora-audit.json', }); - const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-cli-')); + const fixture = await createFixture(); try { const result = await runNode(['--no-warnings', cliPath, 'status'], { - HOME: homeDir, - USERPROFILE: homeDir, - GROK_HOME: path.join(homeDir, 'custom-grok'), - }, '', [], homeDir); + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GROK_HOME: fixture.grokHome, + }, fixture.projectDir); assert.equal(result.code, 0, result.stderr); assert.match(result.stdout, /Grok Build \(grok\)/); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } -}); - -test('Grok install preserves native user config and is idempotent', async () => { - const existing = { - schemaVersion: 1, - hooks: { - SessionStart: [{ - matcher: 'startup', - hooks: [{ type: 'http', url: 'https://example.test/hook', timeout: 5, headers: { x: 'keep' } }], - label: 'keep group metadata', - }], - PreToolUse: [{ - matcher: 'Bash', - hooks: [{ type: 'command', command: 'existing-command', timeout: 5 }], - }], - }, - }; - const fixture = await createFixture({ config: existing }); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - assert.match(fixture.installResult.stdout, /global PreToolUse and PostToolUse hooks installed/i); - const second = await runPlugin(fixture, 'install', { - agentName: 'grok', - agentId: 'agent-1', - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - }); - assert.equal(second.code, 0, second.stderr); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - assert.equal(config.schemaVersion, 1); - assert.deepEqual(config.hooks.SessionStart, existing.hooks.SessionStart); - assert.deepEqual(config.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); - assert.equal(config.hooks.PreToolUse.length, 2); - assert.equal(config.hooks.PostToolUse.length, 1); - assert.deepEqual(Object.keys(config.hooks.PreToolUse[1]), ['hooks']); - assertStrictHandler(managedHandler(config, 'PreToolUse', 'guard.js')); - assertStrictHandler(managedHandler(config, 'PostToolUse', 'hook.js')); - await assert.rejects(readFile(path.join(fixture.homeDir, '.grok', 'hooks', 'elydora-audit.json')), { - code: 'ENOENT', - }); - await assert.rejects(readFile(path.join(fixture.homeDir, '.claude', 'settings.json')), { code: 'ENOENT' }); - await assert.rejects(readFile(path.join(fixture.homeDir, '.cursor', 'hooks.json')), { code: 'ENOENT' }); } finally { await fixture.close(); } }); -test('Grok treats an empty home override as the official default', async () => { +test('Grok installs an exact managed triple and preserves valid user hooks', async () => { + const existing = { + schemaVersion: 1, + hooks: { + SessionStart: [{ + hooks: [{ type: 'http', url: 'https://example.test/hook', timeout: 0 }], + label: 'keep group metadata', + }], + PreToolUse: [{ + matcher: 'Bash|run_terminal_command', + hooks: [{ type: 'command', command: 'existing-command', timeout: 5 }], + }], + }, + }; + const source = `${JSON.stringify(existing, null, 2)}\n`; + const fixture = await createFixture({ config: source }); + try { + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + assert.match(first.stdout, /PostToolUseFailure hooks installed/i); + const firstDocument = await readGrokConfig(fixture.configPath); + assert.equal(firstDocument.config.schemaVersion, 1); + assert.deepEqual(firstDocument.config.hooks.SessionStart, existing.hooks.SessionStart); + assert.deepEqual(firstDocument.config.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); + assert.equal(firstDocument.config.hooks.PreToolUse.length, 2); + assertManagedTriple(firstDocument.config); + + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + assert.equal(await readFile(fixture.configPath, 'utf-8'), firstDocument.raw); + await assertMissing(path.join(fixture.homeDir, '.grok', 'hooks', 'elydora-audit.json')); + await assertMissing(path.join(fixture.homeDir, '.claude', 'settings.json')); + await assertMissing(path.join(fixture.homeDir, '.cursor', 'hooks.json')); + } finally { + await fixture.close(); + } +}); + +test('empty GROK_HOME resolves to the documented default', async () => { const fixture = await createFixture({ explicitGrokHome: false }); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - fixture.explicitGrokHome = true; - fixture.grokHome = ''; + assert.equal((await fixture.install()).code, 0); + fixture.grokHomeOverride = ''; const status = await runPlugin(fixture, 'status', null); assert.equal(status.code, 0, status.stderr); assert.equal(JSON.parse(status.stdout).installed, true); @@ -221,240 +100,157 @@ test('Grok treats an empty home override as the official default', async () => { } }); -test('Grok commands block freezes and forward the official payload byte-for-byte', async () => { - const capturePath = path.join(os.tmpdir(), `elydora-grok-event-${process.pid}-${Date.now()}.json`); - const hookSource = ` - const fs = require('node:fs'); - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks))); - `; - const fixture = await createFixture({ hookSource }); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - const guard = managedHandler(config, 'PreToolUse', 'guard.js'); - const audit = managedHandler(config, 'PostToolUse', 'hook.js'); - const prePayload = JSON.stringify({ - hookEventName: 'PreToolUse', - sessionId: 'session-1', - cwd: fixture.homeDir, - workspaceRoot: fixture.homeDir, - toolName: 'Bash', - toolInput: { command: 'echo test' }, - }); - const guardResult = await runCommand(guard.command, prePayload); - assert.equal(guardResult.code, 2); - assert.match(guardResult.stderr, /Agent is frozen by Elydora/); - const postPayload = JSON.stringify({ - hookEventName: 'PostToolUse', - sessionId: 'session-1', - cwd: fixture.homeDir, - workspaceRoot: fixture.homeDir, - toolName: 'Bash', - toolInput: { command: 'echo test' }, - toolResult: { output: 'test' }, - }); - const auditResult = await runCommand(audit.command, postPayload); - assert.equal(auditResult.code, 0, auditResult.stderr); - assert.equal(await readFile(capturePath, 'utf-8'), postPayload); - } finally { - await fixture.close(); - await rm(capturePath, { force: true }); - } -}); - -test('Grok status requires a complete hook pair and both runtime files', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - let status = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(status.stdout).installed, true); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - delete config.hooks.PostToolUse; - await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); - status = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(status.stdout).hookConfigured, false); - await runPlugin(fixture, 'install', { - agentName: 'grok', - agentId: 'agent-1', - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - }); - await rm(fixture.guardScriptPath); - status = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(status.stdout).installed, false); - } finally { - await fixture.close(); - } -}); - -test('Grok uninstall removes exact ownership and preserves mixed user handlers', async () => { - const fixture = await createFixture({ config: { owner: 'user', hooks: { Notification: [] } } }); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - config.hooks.PreToolUse.at(-1).hooks.push({ type: 'command', command: 'user-command', timeout: 10 }); - config.hooks.PreToolUse.push({ hooks: [{ - type: 'command', - command: buildCommand(path.join(fixture.agentDir, 'guard.js.backup')), - timeout: 10, - }] }); - config.hooks.PreToolUse.push({ hooks: [{ - type: 'command', - command: buildCommand(path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js')), - timeout: 10, - }] }); - await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); - const uninstallId = process.platform === 'win32' ? 'AGENT-1' : 'agent-1'; - const result = await runPlugin(fixture, 'uninstall', uninstallId); - assert.equal(result.code, 0, result.stderr); - const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - assert.equal(remaining.owner, 'user'); - assert.equal(remaining.hooks.PreToolUse.length, 3); - assert.equal(remaining.hooks.PreToolUse[0].hooks[0].command, 'user-command'); - assert(remaining.hooks.PreToolUse.some((group) => group.hooks[0].command.includes('guard.js.backup'))); - assert(remaining.hooks.PreToolUse.some((group) => group.hooks[0].command.includes('agent-10'))); - assert.equal(remaining.hooks.PostToolUse, undefined); - assert.deepEqual(remaining.hooks.Notification, []); - } finally { - await fixture.close(); - } -}); - -test('Grok install replaces stale Elydora handlers for every agent', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - for (const [event, scriptName] of [['PreToolUse', 'guard.js'], ['PostToolUse', 'hook.js']]) { - config.hooks[event].push({ hooks: [{ - type: 'command', - command: buildCommand(path.join(fixture.homeDir, '.elydora', 'agent-old', scriptName)), - timeout: 10, - }] }); - } - await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); - const result = await runPlugin(fixture, 'install', { - agentName: 'grok', - agentId: 'agent-1', - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - }); - assert.equal(result.code, 0, result.stderr); - const current = await readFile(fixture.configPath, 'utf-8'); - assert.doesNotMatch(current, /agent-old/); - assert.equal(JSON.parse(current).hooks.PreToolUse.length, 1); - assert.equal(JSON.parse(current).hooks.PostToolUse.length, 1); - } finally { - await fixture.close(); - } -}); - -test('Grok uninstall preserves an untouched empty native group', async () => { - const fixture = await createFixture({ config: { owner: 'user' } }); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - config.hooks.PreToolUse.unshift({ hooks: [], label: 'keep empty group' }); - await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); - const result = await runPlugin(fixture, 'uninstall', 'agent-1'); - assert.equal(result.code, 0, result.stderr); - const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - assert.deepEqual(remaining.hooks.PreToolUse, [{ hooks: [], label: 'keep empty group' }]); - assert.equal(remaining.hooks.PostToolUse, undefined); - } finally { - await fixture.close(); - } -}); - -test('Grok uninstall removes a config owned entirely by Elydora', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const result = await runPlugin(fixture, 'uninstall', 'agent-1'); - assert.equal(result.code, 0, result.stderr); - await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); - } finally { - await fixture.close(); - } -}); - -test('Grok preserves malformed JSON for recovery', async () => { +test('Grok parses the hook file before creating runtime files', async () => { const fixture = await createFixture({ config: '{ malformed' }); try { - assert.equal(fixture.installResult.code, 1); - assert.match(fixture.installResult.stderr, /parse Grok hooks config/i); - assert.equal(await readFile(fixture.configPath, 'utf-8'), '{ malformed'); - } finally { - await fixture.close(); - } -}); - -test('Grok rejects invalid native hook shapes before writing', async () => { - const cases = [ - { hooks: null }, - { hooks: { PreToolUse: null } }, - { hooks: { PreToolUse: [null] } }, - { hooks: { PreToolUse: [{ matcher: 1, hooks: [] }] } }, - { hooks: { PreToolUse: [{ hooks: null }] } }, - { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: '' }] }] } }, - { hooks: { PreToolUse: [{ hooks: [{ type: 'file', command: 'x' }] }] } }, - { hooks: { PreToolUse: [{ hooks: [{ type: 'http', url: '' }] }] } }, - { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 0 }] }] } }, - ]; - for (const existing of cases) { - const fixture = await createFixture({ config: existing }); - try { - assert.equal(fixture.installResult.code, 1); - assert.deepEqual(JSON.parse(await readFile(fixture.configPath, 'utf-8')), existing); - } finally { - await fixture.close(); - } - } -}); - -test('Grok install rejects missing runtimes before creating config', async () => { - const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-missing-runtime-')); - const fixture = { - explicitGrokHome: true, - grokHome: path.join(homeDir, 'custom-grok'), - homeDir, - }; - const configPath = path.join(fixture.grokHome, 'hooks', 'elydora-audit.json'); - try { - const result = await runPlugin(fixture, 'install', { - agentName: 'grok', - agentId: 'agent-1', - guardScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'guard.js'), - hookScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'hook.js'), - }); + const result = await fixture.install(); assert.equal(result.code, 1); - assert.match(result.stderr, /runtime is missing/i); - await assert.rejects(readFile(configPath), { code: 'ENOENT' }); + assert.match(result.stderr, /parse Grok user hooks/i); + assert.equal(await readFile(fixture.configPath, 'utf-8'), '{ malformed'); + await assertMissing(fixture.agentDir); } finally { - await rm(homeDir, { recursive: true, force: true }); + await fixture.close(); } }); -test('Grok status surfaces malformed referenced runtime metadata', async () => { +test('Grok rejects inactive native hook shapes before writes', async (t) => { + const cases = [ + ['duplicate field', '{"hooks":{},"hooks":{}}', /duplicate field "hooks"/i], + ['hooks shape', JSON.stringify({ hooks: null }), /field "hooks" must be an object/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], + ['matcher shape', JSON.stringify({ hooks: { PreToolUse: [{ matcher: 1, hooks: [] }] } }), /matcher must be a string/i], + ['lifecycle matcher', JSON.stringify({ hooks: { SessionStart: [{ matcher: 'x', hooks: [] }] } }), /cannot declare a matcher/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], + ['command value', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: '' }] }] } }), /non-empty command/i], + ['http value', JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'http', url: '' }] }] } }), /non-empty url/i], + ['negative timeout', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: -1 }] }] } }), /non-negative integer/i], + ['fraction timeout', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 1.5 }] }] } }), /non-negative integer/i], + ['environment shape', JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', env: { A: 1 } }] }] } }), /env must map names to strings/i], + ]; + for (const [name, config, pattern] of cases) { + await t.test(name, async () => { + const fixture = await createFixture({ config }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, pattern); + assert.equal(await readFile(fixture.configPath, 'utf-8'), config); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + } +}); + +test('Grok migrates exact legacy commands and preserves ownership lookalikes', async () => { + const fixture = await createFixture(); + const lookalike = `${legacyCommand(fixture.guardScriptPath)} --inspect`; + const legacy = { + hooks: { + PreToolUse: [ + { hooks: [{ type: 'command', command: legacyCommand(fixture.guardScriptPath), timeout: 10 }] }, + { hooks: [{ type: 'command', command: lookalike, timeout: 10 }] }, + ], + PostToolUse: [{ hooks: [{ + type: 'command', + command: legacyCommand(fixture.hookScriptPath), + timeout: 10, + }] }], + }, + }; + try { + await mkdir(path.dirname(fixture.configPath), { recursive: true }); + await writeFile(fixture.configPath, JSON.stringify(legacy, null, 2)); + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + let { config } = await readGrokConfig(fixture.configPath); + assertManagedTriple(config); + assert(config.hooks.PreToolUse.some((group) => group.hooks[0].command === lookalike)); + + const managedGroup = config.hooks.PreToolUse.at(-1); + managedGroup.hooks.push({ type: 'command', command: 'user-command', timeout: 10 }); + await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); + const uninstall = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(uninstall.code, 0, uninstall.stderr); + config = (await readGrokConfig(fixture.configPath)).config; + assert.deepEqual(config.hooks.PreToolUse.flatMap((group) => group.hooks), [ + { type: 'command', command: lookalike, timeout: 10 }, + { type: 'command', command: 'user-command', timeout: 10 }, + ]); + assert.equal(config.hooks.PostToolUse, undefined); + assert.equal(config.hooks.PostToolUseFailure, undefined); + } finally { + await fixture.close(); + } +}); + +test('Grok uninstall preserves user config and removes a fully managed file', async () => { + const userSource = `${JSON.stringify({ owner: 'user', hooks: { Notification: [] } }, null, 2)}\n`; + const userFixture = await createFixture({ config: userSource }); + try { + assert.equal((await userFixture.install()).code, 0); + const result = await runPlugin(userFixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual((await readGrokConfig(userFixture.configPath)).config, { + owner: 'user', + hooks: { Notification: [] }, + }); + } finally { + await userFixture.close(); + } + + const managedFixture = await createFixture(); + try { + assert.equal((await managedFixture.install()).code, 0); + const result = await runPlugin(managedFixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + await assertMissing(managedFixture.configPath); + } finally { + await managedFixture.close(); + } +}); + +test('Grok status requires one complete triple, exact identity, and private key', async () => { const fixture = await createFixture(); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); - const status = await runPlugin(fixture, 'status', null); + assert.equal((await fixture.install()).code, 0); + let status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, true); + + let { config } = await readGrokConfig(fixture.configPath); + delete config.hooks.PostToolUseFailure; + await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + + assert.equal((await fixture.install()).code, 0); + config = (await readGrokConfig(fixture.configPath)).config; + config.hooks.PostToolUseFailure.push(config.hooks.PostToolUseFailure.at(-1)); + await writeFile(fixture.configPath, JSON.stringify(config, null, 2)); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + + assert.equal((await fixture.install()).code, 0); + await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid'); + status = await runPlugin(fixture, 'status', null); assert.equal(status.code, 1); - assert.match(status.stderr, /parse Elydora runtime config/i); + assert.match(status.stderr, /private key is invalid/i); } finally { await fixture.close(); } }); -test('Grok atomic writes leave no temporary files', async () => { +test('Grok installation leaves no transaction files', async () => { const fixture = await createFixture(); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - assert.equal((await readdir(path.dirname(fixture.configPath))).some((name) => name.endsWith('.tmp')), false); + assert.equal((await fixture.install()).code, 0); + for (const directory of [fixture.agentDir, path.dirname(fixture.configPath)]) { + const names = await readdir(directory); + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); + } } finally { await fixture.close(); } diff --git a/test/grok-runtime.test.mjs b/test/grok-runtime.test.mjs new file mode 100644 index 0000000..3562b14 --- /dev/null +++ b/test/grok-runtime.test.mjs @@ -0,0 +1,139 @@ +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, + readGrokConfig, + runGrokHook, + startApiServer, +} from '../test-support/grok-test-helpers.mjs'; + +const prePayload = { + hookEventName: 'pre_tool_use', + sessionId: 'session-1', + cwd: 'C:/project', + workspaceRoot: 'C:/project', + toolName: 'run_terminal_command', + toolInput: { command: 'npm test' }, + toolUseId: 'tool-use-1', + toolInputTruncated: false, + timestamp: '2026-07-19T12:00:00Z', +}; + +test('Grok runtimes enforce active state and preserve success and failure payloads', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const { config } = await readGrokConfig(fixture.configPath); + const guard = managedHandler(config, 'PreToolUse'); + const successAudit = managedHandler(config, 'PostToolUse'); + const failureAudit = managedHandler(config, 'PostToolUseFailure'); + + const guardResult = await runGrokHook( + guard.command, + JSON.stringify(prePayload), + fixture, + { GROK_HOOK_EVENT: 'injected-command-fragment' }, + ); + assert.equal(guardResult.code, 0, guardResult.stderr); + assert.equal(guardResult.stdout, ''); + + const successPayload = { + ...prePayload, + hookEventName: 'post_tool_use', + toolResult: { output: 'tests passed' }, + toolResultTruncated: false, + durationMs: 125, + }; + const success = await runGrokHook( + successAudit.command, + JSON.stringify(successPayload), + fixture, + ); + assert.equal(success.code, 0, success.stderr); + + const failurePayload = { + ...prePayload, + hookEventName: 'post_tool_use_failure', + toolResult: { error: 'command failed', exitCode: 1 }, + toolResultTruncated: false, + durationMs: 40, + }; + const failure = await runGrokHook( + failureAudit.command, + JSON.stringify(failurePayload), + fixture, + ); + assert.equal(failure.code, 0, failure.stderr); + + const operations = api.requests + .filter((request) => request.method === 'POST') + .map((request) => JSON.parse(request.raw)); + assert.equal(operations.length, 2); + assert.deepEqual(operations[0].payload, successPayload); + assert.deepEqual(operations[1].payload, failurePayload); + assert.equal(operations[0].subject.session_id, 'session-1'); + assert.equal(operations[1].action.tool, 'run_terminal_command'); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Grok guard emits official deny JSON and 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 { config } = await readGrokConfig(fixture.configPath); + const result = await runGrokHook( + managedHandler(config, 'PreToolUse').command, + JSON.stringify(prePayload), + fixture, + ); + assert.equal(result.code, 2); + assert.match(result.stderr, new RegExp(status, 'i')); + const decision = JSON.parse(result.stdout); + assert.deepEqual(Object.keys(decision).sort(), ['decision', 'reason']); + assert.equal(decision.decision, 'deny'); + assert.match(decision.reason, new RegExp(status, 'i')); + } finally { + await fixture.close(); + await api.close(); + } + }); + } +}); + +test('Grok audit runtime records malformed input and API failures', async () => { + const api = await startApiServer({ operationStatus: 503 }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const { config } = await readGrokConfig(fixture.configPath); + const command = managedHandler(config, 'PostToolUse').command; + const malformed = await runGrokHook(command, '{ malformed', fixture); + assert.equal(malformed.code, 0); + assert.equal(malformed.stderr, ''); + + const upstream = await runGrokHook(command, JSON.stringify({ + ...prePayload, + hookEventName: 'post_tool_use', + toolResult: { output: 'test' }, + }), fixture); + assert.equal(upstream.code, 0); + assert.equal(upstream.stderr, ''); + const log = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'); + assert.match(log, /invalid JSON/i); + assert.match(log, /HTTP 503/i); + } finally { + await fixture.close(); + await api.close(); + } +}); diff --git a/test/grok-security.test.mjs b/test/grok-security.test.mjs new file mode 100644 index 0000000..65437dc --- /dev/null +++ b/test/grok-security.test.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + installConfig, + runPlugin, +} from '../test-support/grok-test-helpers.mjs'; + +async function createLink(t, target, linkPath, type) { + try { + await symlink(target, linkPath, type); + return true; + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return false; + } + throw error; + } +} + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +test('Grok rejects linked hook files before creating runtimes', async (t) => { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, 'hooks-target.json'); + const source = '{"owner":"protected"}\n'; + await mkdir(path.dirname(fixture.configPath), { recursive: true }); + await writeFile(target, source, 'utf-8'); + if (!await createLink(t, target, fixture.configPath, 'file')) return; + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /not a physical file/i); + assert.equal(await readFile(target, 'utf-8'), source); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } +}); + +test('Grok rejects linked home, hooks, and runtime directories before writes', async (t) => { + for (const kind of ['home', 'hooks', 'runtime']) { + await t.test(kind, async (subtest) => { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + let linkPath; + if (kind === 'home') { + linkPath = fixture.grokHome; + await mkdir(path.join(target, 'hooks'), { recursive: true }); + await mkdir(path.dirname(linkPath), { recursive: true }); + } else if (kind === 'hooks') { + linkPath = path.dirname(fixture.configPath); + await mkdir(fixture.grokHome, { recursive: true }); + } else { + linkPath = path.join(fixture.homeDir, '.elydora'); + await mkdir(path.dirname(linkPath), { recursive: true }); + } + if (!await createLink(subtest, target, linkPath, 'junction')) return; + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical directory/i); + const untouched = kind === 'runtime' ? fixture.configPath : fixture.agentDir; + await assertMissing(untouched); + } finally { + await fixture.close(); + } + }); + } +}); + +test('Grok status rejects a linked private key', async (t) => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const keyPath = path.join(fixture.agentDir, 'private.key'); + const target = path.join(fixture.rootDir, 'private-key-target'); + const source = await readFile(keyPath, 'utf-8'); + await writeFile(target, source, 'utf-8'); + await rm(keyPath); + if (!await createLink(t, target, keyPath, 'file')) return; + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /not a physical file/i); + assert.equal(await readFile(target, 'utf-8'), source); + } finally { + await fixture.close(); + } +}); + +test('Grok rejects orphaned and mismatched runtime identity before hook writes', async () => { + for (const kind of ['orphaned', 'mismatched']) { + const fixture = await createFixture(); + try { + await mkdir(fixture.agentDir, { recursive: true }); + if (kind === 'orphaned') { + await writeFile(fixture.guardScriptPath, 'orphaned guard\n'); + } else { + await writeFile(path.join(fixture.agentDir, 'config.json'), JSON.stringify({ + agent_name: 'grok', + agent_id: 'another-agent', + })); + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match( + result.stderr, + kind === 'orphaned' ? /identity cannot be verified/i : /identity does not match/i, + ); + await assertMissing(fixture.configPath); + } finally { + await fixture.close(); + } + } +}); + +test('Grok validates managed runtime inputs before creating hook files', async (t) => { + const fixture = await createFixture(); + try { + for (const [name, overrides, pattern] of [ + ['agent name', { agentName: 'codex' }, /requires agentName grok/i], + ['private key', { privateKey: 'invalid' }, /canonical 32-byte/i], + ['API origin', { baseUrl: 'https://api.elydora.com/path?token=secret' }, /query parameters/i], + [ + 'runtime path', + { guardScriptPath: path.join(fixture.homeDir, 'outside', 'guard.js') }, + /managed agent directory/i, + ], + ]) { + await t.test(name, async () => { + const result = await runPlugin(fixture, 'install', installConfig(fixture, overrides)); + assert.equal(result.code, 1); + assert.match(result.stderr, pattern); + await assertMissing(fixture.configPath); + await assertMissing(fixture.agentDir); + }); + } + } finally { + await fixture.close(); + } +}); + +test('Grok status surfaces malformed referenced runtime metadata', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); diff --git a/test/grok-transaction.test.mjs b/test/grok-transaction.test.mjs new file mode 100644 index 0000000..6996f41 --- /dev/null +++ b/test/grok-transaction.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + contractModuleUrl, + createFixture, + installationModuleUrl, + installConfig, + ioModuleUrl, + runNode, +} from '../test-support/grok-test-helpers.mjs'; + +const runtimeNames = ['guard.js', 'config.json', 'private.key', 'hook.js']; + +async function snapshotFixture(fixture) { + const paths = [ + ...runtimeNames.map((name) => path.join(fixture.agentDir, name)), + fixture.configPath, + ]; + return new Map(await Promise.all(paths.map(async (filePath) => [ + filePath, + await readFile(filePath, 'utf-8'), + ]))); +} + +async function assertSnapshot(snapshot) { + for (const [filePath, contents] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), contents, filePath); + } +} + +async function assertNoTransactionFiles(fixture) { + for (const directory of [fixture.agentDir, path.dirname(fixture.configPath)]) { + const names = await readdir(directory); + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); + } +} + +function runFixtureSource(fixture, source, environment = {}) { + return runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + GROK_HOME: fixture.grokHome, + ...environment, + }, + fixture.projectDir, + ); +} + +const prepareInstallSource = ` + import { + buildGrokGroup, + removeManagedGrokHooks, + renderGrokDocument, + } from ${JSON.stringify(contractModuleUrl)}; + import { readGrokDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + preflightGrokInstallation, + prepareGrokInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const document = await readGrokDocument(); + const paths = await preflightGrokInstallation(config, document.filePath); + const cleaned = removeManagedGrokHooks(document.hooks); + const hooks = { + ...cleaned, + PreToolUse: [...(cleaned.PreToolUse ?? []), buildGrokGroup(paths.guardPath)], + PostToolUse: [...(cleaned.PostToolUse ?? []), buildGrokGroup(paths.auditPath)], + PostToolUseFailure: [ + ...(cleaned.PostToolUseFailure ?? []), + buildGrokGroup(paths.auditPath), + ], + }; + const prepared = await prepareGrokInstallation( + config, + renderGrokDocument(document, hooks), + ); +`; + +test('Grok restores its hook config and four runtime files after the final commit fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotFixture(fixture); + const source = `${prepareInstallSource} + import { rename } from 'node:fs/promises'; + import { commitGrokInstallation } from ${JSON.stringify(installationModuleUrl)}; + let calls = 0; + await commitGrokInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 5) throw new Error('injected Grok config failure'); + await rename(from, to); + }); + `; + const result = await runFixtureSource(fixture, source, { + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { + orgId: 'org-updated', + token: 'token-updated', + })), + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected Grok config failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Grok rejects concurrent config changes before committing runtime updates', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotFixture(fixture); + const concurrent = '{"owner":"concurrent"}\n'; + const source = `${prepareInstallSource} + import { writeFile } from 'node:fs/promises'; + import { commitGrokInstallation } from ${JSON.stringify(installationModuleUrl)}; + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await commitGrokInstallation(prepared); + `; + const result = await runFixtureSource(fixture, source, { + ELYDORA_CONCURRENT: concurrent, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })), + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Grok Build installation/i); + for (const [filePath, contents] of before) { + if (filePath !== fixture.configPath) { + assert.equal(await readFile(filePath, 'utf-8'), contents, filePath); + } + } + assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Grok uninstall keeps the original hook config when commit fails', async () => { + const fixture = await createFixture({ config: '{"owner":"user"}\n' }); + try { + assert.equal((await fixture.install()).code, 0); + const before = await readFile(fixture.configPath, 'utf-8'); + const source = ` + import { + removeManagedGrokHooks, + renderGrokDocument, + } from ${JSON.stringify(contractModuleUrl)}; + import { readGrokDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitGrokUninstall, + prepareGrokUninstall, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readGrokDocument(); + const prepared = await prepareGrokUninstall(renderGrokDocument( + document, + removeManagedGrokHooks(document.hooks, 'agent-1'), + )); + await commitGrokUninstall(prepared, async () => { + throw new Error('injected Grok uninstall failure'); + }); + `; + const result = await runFixtureSource(fixture, source); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected Grok uninstall failure/i); + assert.equal(await readFile(fixture.configPath, 'utf-8'), before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +});