fix(node): sync transactional Codex hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 02:14:25 -04:00
1 parent 91b4251fea
commit f501df5077
23 files changed
+2880 -1639

No files matched your search

+2 -1
View File
@@ -29,7 +29,7 @@ This repository publishes the open-source server, console, integration catalog,
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open.
- Model Codex JSON files, inline TOML layers, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only `~/.codex/hooks.json`, preserve project and managed sources, retain the three-level matcher contract, and require `/hooks` trust review for changed user hooks. - Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction.
- Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control. - Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control.
- Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
@@ -64,6 +64,7 @@ This repository publishes the open-source server, console, integration catalog,
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files. - Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
- Keep Console instruction generation limited to agent identity and public configuration. Adapter commands use hidden CLI prompts; direct SDK examples read named runtime environment variables. Expose credentials through explicit copy controls only. - Keep Console instruction generation limited to agent identity and public configuration. Adapter commands use hidden CLI prompts; direct SDK examples read named runtime environment variables. Expose credentials through explicit copy controls only.
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename. - Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and chain state atomically, and append error logs through no-follow owner-only descriptors.
- Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads. - Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads.
- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous. - Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous.
+59 -247
View File
@@ -17,19 +17,9 @@
".augment/settings.json", ".augment/settings.json",
"~/.augment/settings.json" "~/.augment/settings.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "conversation_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "conversation_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://docs.augmentcode.com/cli/hooks" "source_url": "https://docs.augmentcode.com/cli/hooks"
@@ -42,19 +32,9 @@
"integration_mode": "command_hooks", "integration_mode": "command_hooks",
"config_format": "json", "config_format": "json",
"config_paths": ["~/.claude/settings.json", ".claude/settings.json", ".claude/settings.local.json"], "config_paths": ["~/.claude/settings.json", ".claude/settings.json", ".claude/settings.local.json"],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://code.claude.com/docs/en/hooks" "source_url": "https://code.claude.com/docs/en/hooks"
@@ -72,19 +52,9 @@
".clinerules/hooks/", ".clinerules/hooks/",
".cline/hooks/" ".cline/hooks/"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_call.name/tool_result.name", "input": "tool_call.input/tool_result.input", "session": "taskId" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "json_stdout_cancel", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_call.name/tool_result.name",
"input": "tool_call.input/tool_result.input",
"session": "taskId"
},
"blocking": {
"mechanism": "json_stdout_cancel",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://github.com/cline/cline/blob/main/sdk/examples/hooks/README.md" "source_url": "https://github.com/cline/cline/blob/main/sdk/examples/hooks/README.md"
@@ -96,20 +66,10 @@
"surface": "cli", "surface": "cli",
"integration_mode": "command_hooks", "integration_mode": "command_hooks",
"config_format": "json", "config_format": "json",
"config_paths": ["~/.codex/hooks.json", ".codex/hooks.json"], "config_paths": ["$CODEX_HOME/hooks.json", "~/.codex/hooks.json", ".codex/hooks.json"],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"contract_variants": [ "contract_variants": [
{ {
"id": "inlinetoml", "id": "inlinetoml",
@@ -117,23 +77,14 @@
"activation": "Hooks configured inline in an active Codex config.toml or managed requirements.toml layer", "activation": "Hooks configured inline in an active Codex config.toml or managed requirements.toml layer",
"config_format": "toml", "config_format": "toml",
"config_paths": [ "config_paths": [
"$CODEX_HOME/config.toml",
"~/.codex/config.toml", "~/.codex/config.toml",
".codex/config.toml", ".codex/config.toml",
"requirements.toml" "requirements.toml"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"source_url": "https://learn.chatgpt.com/docs/hooks" "source_url": "https://learn.chatgpt.com/docs/hooks"
}, },
{ {
@@ -145,19 +96,9 @@
".codex-plugin/plugin.json", ".codex-plugin/plugin.json",
"hooks/hooks.json" "hooks/hooks.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"source_url": "https://learn.chatgpt.com/docs/hooks" "source_url": "https://learn.chatgpt.com/docs/hooks"
} }
], ],
@@ -177,15 +118,8 @@
"~/.copilot/hooks/*.json", "~/.copilot/hooks/*.json",
".github/hooks/*.json" ".github/hooks/*.json"
], ],
"events": { "events": { "before_tool": "preToolUse", "after_tool": "postToolUse" },
"before_tool": "preToolUse", "event_fields": { "name": "toolName", "input": "toolArgs", "session": "sessionId" },
"after_tool": "postToolUse"
},
"event_fields": {
"name": "toolName",
"input": "toolArgs",
"session": "sessionId"
},
"blocking": { "blocking": {
"mechanism": "any_nonzero_exit", "mechanism": "any_nonzero_exit",
"failure_mode": "fail_closed", "failure_mode": "fail_closed",
@@ -209,19 +143,9 @@
".cursor/hooks.json", ".cursor/hooks.json",
"~/.cursor/hooks.json" "~/.cursor/hooks.json"
], ],
"events": { "events": { "before_tool": "preToolUse", "after_tool": "postToolUse" },
"before_tool": "preToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "conversation_id" },
"after_tool": "postToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "configurable" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "conversation_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "configurable"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://cursor.com/docs/hooks" "source_url": "https://cursor.com/docs/hooks"
@@ -239,19 +163,9 @@
".factory/hooks.json", ".factory/hooks.json",
".factory/settings.json" ".factory/settings.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://docs.factory.ai/reference/hooks-reference", "source_url": "https://docs.factory.ai/reference/hooks-reference",
@@ -265,19 +179,9 @@
"~/.factory/hooks/hooks.json", "~/.factory/hooks/hooks.json",
".factory/hooks/hooks.json" ".factory/hooks/hooks.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"source_url": "https://docs.factory.ai/reference/hooks-reference" "source_url": "https://docs.factory.ai/reference/hooks-reference"
} }
] ]
@@ -293,19 +197,9 @@
"~/.gemini/settings.json", "~/.gemini/settings.json",
".gemini/settings.json" ".gemini/settings.json"
], ],
"events": { "events": { "before_tool": "BeforeTool", "after_tool": "AfterTool" },
"before_tool": "BeforeTool", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "AfterTool" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://geminicli.com/docs/hooks/reference/" "source_url": "https://geminicli.com/docs/hooks/reference/"
@@ -322,19 +216,9 @@
"~/.grok/hooks/*.json", "~/.grok/hooks/*.json",
".grok/hooks/*.json" ".grok/hooks/*.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "toolName", "input": "toolInput", "session": "sessionId" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "toolName",
"input": "toolInput",
"session": "sessionId"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://docs.x.ai/build/features/hooks" "source_url": "https://docs.x.ai/build/features/hooks"
@@ -350,19 +234,9 @@
"$KIMI_CODE_HOME/config.toml", "$KIMI_CODE_HOME/config.toml",
"~/.kimi-code/config.toml" "~/.kimi-code/config.toml"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks", "source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks",
@@ -375,19 +249,9 @@
"config_paths": [ "config_paths": [
"~/.kimi/config.toml" "~/.kimi/config.toml"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"source_url": "https://moonshotai.github.io/kimi-cli/en/customization/hooks.html" "source_url": "https://moonshotai.github.io/kimi-cli/en/customization/hooks.html"
} }
] ]
@@ -403,19 +267,9 @@
"~/.kiro/agents/*.json", "~/.kiro/agents/*.json",
".kiro/agents/*.json" ".kiro/agents/*.json"
], ],
"events": { "events": { "before_tool": "preToolUse", "after_tool": "postToolUse" },
"before_tool": "preToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "postToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://kiro.dev/docs/cli/hooks/", "source_url": "https://kiro.dev/docs/cli/hooks/",
@@ -429,19 +283,9 @@
"~/.kiro/hooks/*.json", "~/.kiro/hooks/*.json",
".kiro/hooks/*.json" ".kiro/hooks/*.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"source_url": "https://kiro.dev/docs/cli/v3/hooks/" "source_url": "https://kiro.dev/docs/cli/v3/hooks/"
} }
] ]
@@ -456,14 +300,8 @@
"config_paths": [ "config_paths": [
".kiro/hooks/*.json" ".kiro/hooks/*.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
"after_tool": "PostToolUse"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://kiro.dev/docs/hooks/" "source_url": "https://kiro.dev/docs/hooks/"
@@ -480,14 +318,8 @@
".letta/settings.json", ".letta/settings.json",
".letta/settings.local.json" ".letta/settings.local.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
"after_tool": "PostToolUse"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://docs.letta.com/letta-agent/hooks" "source_url": "https://docs.letta.com/letta-agent/hooks"
@@ -503,19 +335,9 @@
"~/.config/opencode/plugins/*.{js,ts,mjs}", "~/.config/opencode/plugins/*.{js,ts,mjs}",
".opencode/plugins/*.{js,ts,mjs}" ".opencode/plugins/*.{js,ts,mjs}"
], ],
"events": { "events": { "before_tool": "tool.execute.before", "after_tool": "tool.execute.after" },
"before_tool": "tool.execute.before", "event_fields": { "name": "tool", "input": "args", "session": "sessionID" },
"after_tool": "tool.execute.after" "blocking": { "mechanism": "exception", "failure_mode": "fail_closed" },
},
"event_fields": {
"name": "tool",
"input": "args",
"session": "sessionID"
},
"blocking": {
"mechanism": "exception",
"failure_mode": "fail_closed"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://opencode.ai/docs/plugins/" "source_url": "https://opencode.ai/docs/plugins/"
@@ -532,19 +354,9 @@
"~/.qwen/settings.json", "~/.qwen/settings.json",
".qwen/settings.json" ".qwen/settings.json"
], ],
"events": { "events": { "before_tool": "PreToolUse", "after_tool": "PostToolUse" },
"before_tool": "PreToolUse", "event_fields": { "name": "tool_name", "input": "tool_input", "session": "session_id" },
"after_tool": "PostToolUse" "blocking": { "mechanism": "exit_code_2", "failure_mode": "fail_open" },
},
"event_fields": {
"name": "tool_name",
"input": "tool_input",
"session": "session_id"
},
"blocking": {
"mechanism": "exit_code_2",
"failure_mode": "fail_open"
},
"adapters": { "node": true, "python": true, "go": true }, "adapters": { "node": true, "python": true, "go": true },
"delivery_state": "available", "delivery_state": "available",
"source_url": "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/" "source_url": "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/"
+1 -1
View File
@@ -22,7 +22,7 @@
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"validate:integrations": "node --test test/integration-catalog.test.mjs", "validate:integrations": "node --test test/integration-catalog.test.mjs test/integration-catalog-contracts.test.mjs",
"test:server": "cd packages/server && npm test", "test:server": "cd packages/server && npm test",
"test:console:e2e": "cd packages/console && npm run test:e2e", "test:console:e2e": "cd packages/console && npm run test:e2e",
"build:server": "cd packages/server && npm run build", "build:server": "cd packages/server && npm run build",
+1 -1
View File
@@ -53,7 +53,7 @@ npx elydora install \
The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file <path>` and `--token_file <path>`. Secret values are rejected as command-line arguments because process listings and shell history can expose them. Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical-directory checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID. The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file <path>` and `--token_file <path>`. Secret values are rejected as command-line arguments because process listings and shell history can expose them. Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical-directory checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout. Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout.
+373
View File
@@ -0,0 +1,373 @@
import os from 'node:os';
import path from 'node:path';
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
export const AGENT_KEY = 'codex';
export const CONFIG_FILE = 'hooks.json';
export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10;
export const OWNED_DESCRIPTION = 'Elydora audit and freeze enforcement';
export const GUARD_STATUS = 'Checking Elydora agent state';
export const AUDIT_STATUS = 'Recording Elydora tool use';
export type CodexHooks = Record<string, JsonObject[]>;
export interface CodexDocument {
readonly exists: boolean;
readonly filePath: string;
readonly root: JsonObject;
readonly hooks: CodexHooks;
readonly raw?: string;
}
export interface RenderedDocument {
readonly document: CodexDocument;
readonly changed: boolean;
readonly next?: string;
}
export interface RuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === 'win32'
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}
function sameAgentId(left: string, right: string): boolean {
return process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function windowsPowerShellPath(): string {
const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined;
const systemRoot = configuredRoot
&& path.win32.isAbsolute(configuredRoot)
&& !/["%\r\n]/.test(configuredRoot)
? configuredRoot
: 'C:\\Windows';
return path.win32.join(
systemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe',
);
}
function windowsCommand(scriptPath: string): string {
const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
const encoded = Buffer.from(source, 'utf16le').toString('base64');
return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
}
export function buildHandler(scriptPath: string, statusMessage: string): JsonObject {
return {
type: 'command',
command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`,
commandWindows: windowsCommand(scriptPath),
timeout: HOOK_TIMEOUT_SECONDS,
statusMessage,
};
}
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
const apostrophe = `'"'"'`;
let value = '';
for (let index = start + 1; index < command.length;) {
if (command.startsWith(apostrophe, index)) {
value += "'";
index += apostrophe.length;
continue;
}
if (command[index] === "'") return { value, next: index + 1 };
value += command[index];
index += 1;
}
return undefined;
}
function parsePosixCommand(command: unknown): readonly [string, string] | undefined {
if (typeof command !== 'string') return undefined;
const executable = readPosixArgument(command, 0);
if (!executable || command[executable.next] !== ' ') return undefined;
const script = readPosixArgument(command, executable.next + 1);
if (!script || script.next !== command.length) return undefined;
return [executable.value, script.value];
}
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
let value = '';
for (let index = start + 1; index < command.length; index += 1) {
if (command[index] !== "'") {
value += command[index];
continue;
}
if (command[index + 1] === "'") {
value += "'";
index += 1;
continue;
}
return { value, next: index + 1 };
}
return undefined;
}
function parsePowerShellSource(source: string): readonly [string, string] | undefined {
if (!source.startsWith('& ')) return undefined;
const executable = readPowerShellArgument(source, 2);
if (!executable || source[executable.next] !== ' ') return undefined;
const script = readPowerShellArgument(source, executable.next + 1);
if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
return [executable.value, script.value];
}
function parseWindowsCommand(command: unknown): readonly [string, string] | undefined {
if (typeof command !== 'string') return undefined;
const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command);
if (!match
|| !path.win32.isAbsolute(match[1])
|| path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined;
const encoded = match[2];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined;
const buffer = Buffer.from(encoded, 'base64');
if (buffer.toString('base64') !== encoded || buffer.length % 2 !== 0) return undefined;
return parsePowerShellSource(buffer.toString('utf16le'));
}
function parseLegacyWindowsCommand(command: unknown): readonly [string, string] | undefined {
if (typeof command !== 'string') return undefined;
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
return match ? [match[1], match[2]] : undefined;
}
function isNodeExecutable(filePath: string): boolean {
const basename = path.basename(filePath);
return basename === 'node' || basename.toLowerCase() === 'node.exe';
}
function exactHandlerKeys(handler: JsonObject): boolean {
return Object.keys(handler).sort().join('|')
=== 'command|commandWindows|statusMessage|timeout|type';
}
function managedScriptPath(handler: JsonObject, statusMessage: string): string | undefined {
if (!exactHandlerKeys(handler)
|| handler.type !== 'command'
|| handler.timeout !== HOOK_TIMEOUT_SECONDS
|| handler.statusMessage !== statusMessage) return undefined;
const posix = parsePosixCommand(handler.command);
const windows = parseWindowsCommand(handler.commandWindows)
?? parseLegacyWindowsCommand(handler.commandWindows);
if (!posix || !windows
|| !path.isAbsolute(posix[0])
|| !path.isAbsolute(posix[1])
|| !isNodeExecutable(posix[0])
|| !isNodeExecutable(windows[0])
|| !samePath(posix[0], windows[0])
|| !samePath(posix[1], windows[1])) return undefined;
return posix[1];
}
function managedAgentId(
handler: JsonObject,
scriptName: string,
statusMessage: string,
): string | undefined {
const scriptPath = managedScriptPath(handler, statusMessage);
if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined;
const agentDirectory = path.dirname(scriptPath);
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
return undefined;
}
const agentId = path.basename(agentDirectory);
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
}
function readHooks(value: unknown, label: string): CodexHooks {
if (value === undefined) return {};
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
const hooks: CodexHooks = {};
for (const [event, candidate] of Object.entries(value)) {
if (!Array.isArray(candidate)) {
throw new Error(`${label} field "hooks.${event}" must be an array`);
}
hooks[event] = candidate.map((group, groupIndex) => {
if (!isObject(group)) {
throw new Error(`${label} matcher group hooks.${event}[${groupIndex}] must be an object`);
}
if (!Array.isArray(group.hooks)) {
throw new Error(
`${label} matcher group hooks.${event}[${groupIndex}] must contain a hooks array`,
);
}
for (const [handlerIndex, handler] of group.hooks.entries()) {
if (!isObject(handler)) {
throw new Error(
`${label} handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}] must be an object`,
);
}
}
return group;
});
}
return hooks;
}
export function parseDocument(filePath: string, raw: string): CodexDocument {
const label = `Codex user hooks at ${filePath}`;
const root = parseStrictJsonObject(raw, label);
return {
exists: true,
filePath,
root,
hooks: readHooks(root.hooks, label),
raw,
};
}
export function createDocument(filePath: string): CodexDocument {
return {
exists: false,
filePath,
root: { description: OWNED_DESCRIPTION },
hooks: {},
};
}
function removeFromGroups(
groups: JsonObject[],
scriptName: string,
statusMessage: string,
agentId?: string,
): JsonObject[] {
const result: JsonObject[] = [];
for (const group of groups) {
const handlers = (group.hooks as JsonObject[]).filter((handler) => {
const managedId = managedAgentId(handler, scriptName, statusMessage);
return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId));
});
if (handlers.length > 0) result.push({ ...group, hooks: handlers });
else if (!exactMatcherGroup(group)) result.push({ ...group, hooks: [] });
}
return result;
}
export function removeManagedHooks(hooks: CodexHooks, agentId?: string): CodexHooks {
const next: CodexHooks = Object.fromEntries(
Object.entries(hooks).map(([event, groups]) => [event, [...groups]]),
);
for (const [event, scriptName, statusMessage] of [
['PreToolUse', GUARD_SCRIPT, GUARD_STATUS],
['PostToolUse', AUDIT_SCRIPT, AUDIT_STATUS],
] as const) {
const groups = removeFromGroups(next[event] ?? [], scriptName, statusMessage, agentId);
if (groups.length > 0) next[event] = groups;
else delete next[event];
}
return next;
}
function entirelyManaged(document: CodexDocument): boolean {
if (!document.exists
|| document.root.description !== OWNED_DESCRIPTION
|| !Object.keys(document.root).every((key) => key === 'description' || 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 = event === 'PreToolUse'
? [GUARD_SCRIPT, GUARD_STATUS] as const
: event === 'PostToolUse'
? [AUDIT_SCRIPT, AUDIT_STATUS] as const
: undefined;
if (!contract || groups.length === 0) return false;
for (const group of groups) {
const handlers = group.hooks as JsonObject[];
if (!exactMatcherGroup(group)
|| handlers.length === 0
|| handlers.some((handler) => !managedAgentId(handler, contract[0], contract[1]))) {
return false;
}
handlerCount += handlers.length;
}
}
return handlerCount > 0;
}
export function renderDocument(
document: CodexDocument,
hooks: CodexHooks,
): RenderedDocument {
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 exactMatcherGroup(group: JsonObject): boolean {
return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*';
}
function managedIds(
groups: JsonObject[],
scriptName: string,
statusMessage: string,
): Map<string, string> {
const result = new Map<string, string>();
for (const group of groups) {
if (!exactMatcherGroup(group)) continue;
for (const handler of group.hooks as JsonObject[]) {
const agentId = managedAgentId(handler, scriptName, statusMessage);
if (!agentId) continue;
const key = process.platform === 'win32' ? agentId.toLowerCase() : agentId;
result.set(key, agentId);
}
}
return result;
}
export function runtimeContracts(hooks: CodexHooks): RuntimeContract[] {
const guards = managedIds(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_STATUS);
const audits = managedIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_STATUS);
const root = path.join(os.homedir(), '.elydora');
return [...guards]
.filter(([key]) => audits.has(key))
.map(([, agentId]) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}
@@ -0,0 +1,69 @@
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
type RenderedDocument,
} from './codex-contract.js';
import {
commitManagedInstallation,
managedRuntimePaths,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
const DISPLAY_NAME = 'Codex';
const HOOKS_DIRECTORY_LABEL = 'Codex hooks directory';
const HOOKS_LABEL = 'Codex user hooks';
export type CodexRuntimePaths = ManagedRuntimePaths;
export type PreparedCodexInstallation = PreparedManagedInstallation;
export type { RenameFile };
export function codexRuntimePaths(config: InstallConfig): CodexRuntimePaths {
return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function preflightCodexInstallation(
config: InstallConfig,
hooksPath: string,
): Promise<CodexRuntimePaths> {
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
hooksPath,
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function prepareCodexInstallation(
config: InstallConfig,
rendered: RenderedDocument,
): Promise<PreparedCodexInstallation> {
if (!rendered.changed && rendered.document.raw === undefined) {
throw new Error('Codex hook installation did not produce a configuration document');
}
const hooksSource = rendered.next ?? rendered.document.raw;
if (hooksSource === undefined) throw new Error('Codex hook configuration is missing');
return prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
hooksLabel: HOOKS_LABEL,
hooksPath: rendered.document.filePath,
expectedHooksSource: rendered.document.raw,
hooksSource,
config,
auditOptions: { nativePayload: true },
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function commitCodexInstallation(
prepared: PreparedCodexInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
+197
View File
@@ -0,0 +1,197 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
AGENT_KEY,
CONFIG_FILE,
type CodexDocument,
type RenderedDocument,
type RuntimeContract,
createDocument,
parseDocument,
} from './codex-contract.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { parseStrictJsonObject } from './strict-json.js';
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
async function codexHomePath(): Promise<string> {
const configured = process.env.CODEX_HOME;
if (configured === undefined || configured === '') return path.join(os.homedir(), '.codex');
let metadata;
try {
metadata = await fsp.stat(configured);
} catch (error) {
throw new Error(`Resolve CODEX_HOME at ${configured}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!metadata.isDirectory()) throw new Error(`CODEX_HOME is not a directory: ${configured}`);
let canonical;
try {
canonical = await fsp.realpath(configured);
} catch (error) {
throw new Error(`Canonicalize CODEX_HOME at ${configured}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!await inspectPhysicalDirectory(canonical, 'CODEX_HOME')) {
throw new Error(`CODEX_HOME is missing: ${canonical}`);
}
return canonical;
}
export async function codexConfigPath(): Promise<string> {
return path.join(await codexHomePath(), CONFIG_FILE);
}
export async function readDocument(): Promise<CodexDocument> {
const filePath = await codexConfigPath();
const snapshot = await readPhysicalFile(filePath, 'Codex user hooks');
return snapshot ? parseDocument(filePath, snapshot.contents) : createDocument(filePath);
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) throw error;
}
}
async function writeExclusive(filePath: string, contents: string): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', 0o600);
await handle.writeFile(contents, 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
} catch (error) {
const failures = [asError(error)];
if (handle) {
try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); }
}
try { await unlinkOptional(filePath); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) throw new AggregateError(failures, 'Stage Codex user hooks');
throw new Error(`Stage Codex user hooks: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function assertUnchanged(document: CodexDocument): Promise<void> {
const current = await readPhysicalFile(document.filePath, 'Codex user hooks');
if (current?.contents !== document.raw) {
throw new Error(`Codex user hooks changed during update: ${document.filePath}`);
}
}
async function ensureHooksDirectory(directory: string): Promise<void> {
try {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
} catch (error) {
throw new Error(`Create Codex hooks directory at ${directory}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!await inspectPhysicalDirectory(directory, 'Codex hooks directory')) {
throw new Error(`Codex hooks directory is missing: ${directory}`);
}
}
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
if (!rendered.changed) return;
await assertUnchanged(rendered.document);
const directory = path.dirname(rendered.document.filePath);
await ensureHooksDirectory(directory);
if (rendered.next === undefined) {
try {
await fsp.unlink(rendered.document.filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) {
throw new Error(
`Remove Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
{ cause: asError(error) },
);
}
}
return;
}
const temporaryPath = path.join(
directory,
`.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`,
);
let failure: Error | undefined;
try {
await writeExclusive(temporaryPath, rendered.next);
await assertUnchanged(rendered.document);
await fsp.rename(temporaryPath, rendered.document.filePath);
} catch (error) {
failure = new Error(
`Write Codex user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
{ cause: asError(error) },
);
}
let cleanupFailure: Error | undefined;
try {
await unlinkOptional(temporaryPath);
} catch (error) {
cleanupFailure = new Error(
`Clean Codex hook staging file at ${temporaryPath}: ${errorMessage(error)}`,
{ cause: asError(error) },
);
}
if (failure && cleanupFailure) {
throw new AggregateError([failure, cleanupFailure], 'Write Codex user hooks');
}
if (failure) throw failure;
if (cleanupFailure) throw cleanupFailure;
}
async function physicalFileExists(filePath: string, label: string): Promise<boolean> {
return Boolean(await readPhysicalFile(filePath, label));
}
function sameAgentId(left: unknown, right: string): boolean {
return typeof left === 'string' && (process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right);
}
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
for (const contract of contracts) {
const directory = path.dirname(contract.guardPath);
const configPath = path.join(directory, 'config.json');
const snapshot = await readPhysicalFile(configPath, 'Elydora runtime config');
if (!snapshot) continue;
const config = parseStrictJsonObject(
snapshot.contents,
`Elydora runtime config at ${configPath}`,
);
if (config.agent_name !== AGENT_KEY || !sameAgentId(config.agent_id, contract.agentId)) {
continue;
}
const files = await Promise.all([
physicalFileExists(contract.guardPath, 'Elydora guard runtime'),
physicalFileExists(contract.auditPath, 'Elydora audit runtime'),
physicalFileExists(path.join(directory, 'private.key'), 'Elydora private key'),
]);
if (files.every(Boolean)) return true;
}
return false;
}
+43 -292
View File
@@ -1,320 +1,71 @@
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 type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
AUDIT_STATUS,
GUARD_STATUS,
buildHandler,
removeManagedHooks,
renderDocument,
runtimeContracts,
} from './codex-contract.js';
import {
commitCodexInstallation,
preflightCodexInstallation,
prepareCodexInstallation,
} from './codex-installation.js';
import { readDocument, runtimeFilesExist, writeDocument } from './codex-io.js';
import { SUPPORTED_AGENTS } from './registry.js'; import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'codex';
const OWNED_DESCRIPTION = 'Elydora audit and freeze enforcement';
const GUARD_STATUS = 'Checking Elydora agent state';
const AUDIT_STATUS = 'Recording Elydora tool use';
const GUARD_SCRIPT = 'guard.js';
const AUDIT_SCRIPT = 'hook.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
type JsonObject = Record<string, unknown>; function matcherGroup(handler: ReturnType<typeof buildHandler>): Record<string, unknown> {
return { matcher: '*', hooks: [handler] };
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasErrorCode(error: unknown, code: string): boolean {
return isObject(error) && error.code === code;
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function resolveConfigPath(): string {
const configDir = entry.configDir.replace(/^~/, os.homedir());
return path.join(configDir, entry.configFile);
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quoteWindows(value: string): string {
return `"${value.replaceAll('"', '\\"')}"`;
}
function buildHandler(scriptPath: string, statusMessage: string): JsonObject {
return {
type: 'command',
command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`,
commandWindows: `${quoteWindows(process.execPath)} ${quoteWindows(scriptPath)}`,
timeout: 10,
statusMessage,
};
}
function eventGroups(hooks: JsonObject, event: string): JsonObject[] {
const value = hooks[event];
if (value === undefined) return [];
if (!Array.isArray(value) || !value.every(isObject)) {
throw new Error(`Codex hooks config field "hooks.${event}" must be an array of objects`);
}
return value;
}
function isManagedCommand(command: unknown, scriptName: string, agentId?: string): boolean {
if (typeof command !== 'string') return false;
const normalized = command.toLowerCase();
if (!normalized.includes('.elydora') || !normalized.includes(scriptName)) return false;
return !agentId || command.includes(agentId);
}
function isElydoraHandler(handler: JsonObject, agentId?: string): boolean {
const scriptName = handler.statusMessage === GUARD_STATUS
? GUARD_SCRIPT
: handler.statusMessage === AUDIT_STATUS
? AUDIT_SCRIPT
: undefined;
if (!scriptName) return false;
return [handler.command, handler.commandWindows].some(
(command) => isManagedCommand(command, scriptName, agentId),
);
}
function withoutElydora(groups: JsonObject[], agentId?: string): JsonObject[] {
const result: JsonObject[] = [];
for (const group of groups) {
if (!Array.isArray(group.hooks) || !group.hooks.every(isObject)) {
throw new Error('Codex hook matcher group must contain a hooks array');
}
const handlers = group.hooks.filter((handler) => !isElydoraHandler(handler, agentId));
if (handlers.length > 0) result.push({ ...group, hooks: handlers });
}
return result;
}
function findHandler(groups: JsonObject[], statusMessage: string): JsonObject | undefined {
for (const group of groups) {
if (!Array.isArray(group.hooks) || !group.hooks.every(isObject)) {
throw new Error('Codex hook matcher group must contain a hooks array');
}
const handler = group.hooks.find(
(candidate) => candidate.statusMessage === statusMessage
&& isElydoraHandler(candidate),
);
if (isObject(handler)) return handler;
}
return undefined;
}
function commandReferences(handler: JsonObject, scriptPath: string): boolean {
return [handler.command, handler.commandWindows].some(
(command) => typeof command === 'string' && command.includes(scriptPath),
);
}
async function readJsonObject(filePath: string, label: string): Promise<JsonObject | undefined> {
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;
}
async function readSettings(configPath: string): Promise<JsonObject | undefined> {
return readJsonObject(configPath, 'Codex hooks config');
}
async function failWrite(
handle: FileHandle | undefined,
tempPath: string,
label: string,
cause: unknown,
): Promise<never> {
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 ${label}: ${errorMessage(cause)}`;
if (errors.length > 1) throw new AggregateError(errors, message);
throw new Error(message, { cause: errors[0] });
}
async function writeSettings(configPath: string, settings: JsonObject): Promise<void> {
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(settings, null, 2) + '\n', 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
await fsp.rename(tempPath, configPath);
} catch (error) {
await failWrite(handle, tempPath, `Codex hooks config at ${configPath}`, error);
}
}
async function removeSettings(configPath: string): Promise<void> {
try {
await fsp.unlink(configPath);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return;
throw new Error(`Remove Codex hooks config at ${configPath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
function getHooks(settings: JsonObject): JsonObject {
if (settings.hooks === undefined) return {};
if (!isObject(settings.hooks)) throw new Error('Codex hooks config field "hooks" must be an object');
return { ...settings.hooks };
}
function isOwnedSettings(settings: JsonObject, hooks: JsonObject): boolean {
const settingsKeys = new Set(['description', 'hooks']);
const hookKeys = new Set(['PreToolUse', 'PostToolUse']);
return Object.keys(settings).every((key) => settingsKeys.has(key))
&& settings.description === OWNED_DESCRIPTION
&& Object.keys(hooks).every((key) => hookKeys.has(key))
&& Object.values(hooks).every((value) => Array.isArray(value) && value.length === 0);
}
async function regularFileExists(filePath: string, label: string): Promise<boolean> {
try {
const metadata = await fsp.stat(filePath);
return metadata.isFile();
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return false;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
async function requireRuntime(filePath: string, label: string): Promise<void> {
if (!filePath) throw new Error(`${label} path is required`);
if (!await regularFileExists(filePath, label)) {
throw new Error(`${label} is missing: ${filePath}`);
}
}
async function runtimeScriptsExist(guard: JsonObject, audit: JsonObject): Promise<boolean> {
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 directory of entries) {
if (!directory.isDirectory()) continue;
const agentDir = path.join(root, directory.name);
const guardPath = path.join(agentDir, GUARD_SCRIPT);
const hookPath = path.join(agentDir, AUDIT_SCRIPT);
if (!commandReferences(guard, guardPath) || !commandReferences(audit, hookPath)) continue;
const configPath = path.join(agentDir, 'config.json');
const config = await readJsonObject(configPath, 'Elydora runtime config');
if (!config || config.agent_name !== AGENT_KEY) continue;
const [guardExists, hookExists] = await Promise.all([
regularFileExists(guardPath, 'Elydora guard runtime'),
regularFileExists(hookPath, 'Elydora audit runtime'),
]);
return guardExists && hookExists;
}
return false;
} }
export const codexPlugin: AgentPlugin = { export const codexPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> { managesRuntime: true,
if (!config.agentId) throw new Error('agentId is required');
const configPath = resolveConfigPath();
const existingSettings = await readSettings(configPath);
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
const settings = existingSettings ?? { description: OWNED_DESCRIPTION };
const hooks = getHooks(settings);
async preflightInstall(config: InstallConfig): Promise<void> {
const document = await readDocument();
await preflightCodexInstallation(config, document.filePath);
},
async install(config: InstallConfig): Promise<void> {
const document = await readDocument();
const paths = await preflightCodexInstallation(config, document.filePath);
const hooks = removeManagedHooks(document.hooks);
hooks.PreToolUse = [ hooks.PreToolUse = [
...withoutElydora(eventGroups(hooks, 'PreToolUse')), ...(hooks.PreToolUse ?? []),
{ matcher: '*', hooks: [buildHandler(config.guardScriptPath, GUARD_STATUS)] }, matcherGroup(buildHandler(paths.guardPath, GUARD_STATUS)),
]; ];
hooks.PostToolUse = [ hooks.PostToolUse = [
...withoutElydora(eventGroups(hooks, 'PostToolUse')), ...(hooks.PostToolUse ?? []),
{ matcher: '*', hooks: [buildHandler(config.hookScriptPath, AUDIT_STATUS)] }, matcherGroup(buildHandler(paths.auditPath, AUDIT_STATUS)),
]; ];
const prepared = await prepareCodexInstallation(config, renderDocument(document, hooks));
await writeSettings(configPath, { ...settings, hooks }); await commitCodexInstallation(prepared);
console.log(' Codex: run /hooks to review and trust the Elydora hooks.'); console.log(` Codex hooks: ${document.filePath}`);
console.log(' Codex trust: run /hooks and approve both Elydora command hooks.');
}, },
async uninstall(agentId?: string): Promise<void> { async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath(); const document = await readDocument();
const settings = await readSettings(configPath); const hooks = removeManagedHooks(document.hooks, agentId);
if (!settings) return; await writeDocument(renderDocument(document, hooks));
const hooks = getHooks(settings);
hooks.PreToolUse = withoutElydora(eventGroups(hooks, 'PreToolUse'), agentId);
hooks.PostToolUse = withoutElydora(eventGroups(hooks, 'PostToolUse'), agentId);
if (isOwnedSettings(settings, hooks)) {
await removeSettings(configPath);
} else {
await writeSettings(configPath, { ...settings, hooks });
}
}, },
async status(): Promise<PluginStatus> { async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath(); const document = await readDocument();
const settings = await readSettings(configPath); const contracts = runtimeContracts(document.hooks);
let guard: JsonObject | undefined; const hookConfigured = contracts.length > 0;
let audit: JsonObject | undefined; const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
if (settings) {
const hooks = getHooks(settings);
guard = findHandler(eventGroups(hooks, 'PreToolUse'), GUARD_STATUS);
audit = findHandler(eventGroups(hooks, 'PostToolUse'), AUDIT_STATUS);
}
const hookConfigured = Boolean(guard && audit);
const hookScriptExists = guard && audit ? await runtimeScriptsExist(guard, audit) : false;
return { return {
installed: hookConfigured && hookScriptExists, installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY, agentName: AGENT_KEY,
displayName: entry.name, displayName: entry.name,
hookConfigured, hookConfigured,
hookScriptExists, hookScriptExists,
configPath, configPath: document.filePath,
}; };
}, },
}; };
+4 -52
View File
@@ -1,12 +1,9 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
parse,
parseTree, export { isObject, parseStrictJsonObject };
printParseErrorCode, export type { JsonObject };
type Node,
type ParseError,
} from 'jsonc-parser';
export const AGENT_KEY = 'cursor'; export const AGENT_KEY = 'cursor';
export const CONFIG_FILE = 'hooks.json'; export const CONFIG_FILE = 'hooks.json';
@@ -14,7 +11,6 @@ export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js'; export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_SECONDS = 10; export const HOOK_TIMEOUT_SECONDS = 10;
export type JsonObject = Record<string, unknown>;
export type CursorHooks = Record<string, JsonObject[]>; export type CursorHooks = Record<string, JsonObject[]>;
export interface CursorDocument { export interface CursorDocument {
@@ -42,10 +38,6 @@ interface ParsedArgument {
readonly next: number; readonly next: number;
} }
export function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function samePath(left: string, right: string): boolean { export function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left); const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right); const normalizedRight = path.resolve(right);
@@ -173,46 +165,6 @@ function managedAgentId(handler: JsonObject, scriptName: string): string | undef
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
} }
function rejectDuplicateKeys(node: Node | undefined, label: string, location: string[] = []): void {
if (!node) return;
if (node.type === 'object') {
const keys = new Set<string>();
for (const property of node.children ?? []) {
const key = String(property.children?.[0]?.value);
const childLocation = [...location, key];
if (keys.has(key)) {
throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`);
}
keys.add(key);
rejectDuplicateKeys(property.children?.[1], label, childLocation);
}
return;
}
if (node.type === 'array') {
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location);
}
}
export function parseStrictJsonObject(raw: string, label: string): JsonObject {
const errors: ParseError[] = [];
const value: unknown = parse(raw, errors, {
allowTrailingComma: false,
disallowComments: true,
});
if (errors.length > 0) {
const details = errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join(', ');
throw new Error(`Failed to parse ${label}: ${details}`);
}
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
rejectDuplicateKeys(parseTree(raw, [], {
allowTrailingComma: false,
disallowComments: true,
}), label);
return value;
}
function readHooks(value: unknown, label: string): CursorHooks { function readHooks(value: unknown, label: string): CursorHooks {
if (value === undefined) return {}; if (value === undefined) return {};
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`); if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
+43 -414
View File
@@ -1,448 +1,77 @@
import { randomUUID } from 'node:crypto';
import { constants } from 'node:fs';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js';
import type { InstallConfig } from './base.js'; import type { InstallConfig } from './base.js';
import {
commitManagedInstallation,
managedRuntimePaths,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import { import {
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT, AUDIT_SCRIPT,
GUARD_SCRIPT, GUARD_SCRIPT,
type JsonObject,
type RenderedDocument, type RenderedDocument,
parseStrictJsonObject,
samePath,
} from './cursor-contract.js'; } from './cursor-contract.js';
import { generateGuardScript } from './guard-template.js';
import { generateHookScript } from './hook-template.js';
import { cursorConfigPath } from './cursor-io.js'; import { cursorConfigPath } from './cursor-io.js';
const MAX_SECRET_BYTES = 64 * 1024; const DISPLAY_NAME = 'Cursor';
const MAX_CONFIG_BYTES = 512 * 1024; const HOOKS_DIRECTORY_LABEL = 'Cursor hooks directory';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024; const HOOKS_LABEL = 'Cursor user hooks';
interface FileSnapshot { export type CursorRuntimePaths = ManagedRuntimePaths;
readonly contents: string; export type PreparedCursorInstallation = PreparedManagedInstallation;
readonly device: bigint | number; export type { RenameFile };
readonly inode: bigint | number;
readonly mode: number;
}
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly next: string;
readonly mode: number;
readonly original?: FileSnapshot;
}
interface StagedChange {
readonly change: FileChange;
readonly temporaryPath: string;
readonly rollbackPath?: string;
committed: boolean;
}
export interface CursorRuntimePaths {
readonly runtimeRoot: string;
readonly agentDirectory: string;
readonly configPath: string;
readonly keyPath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface PreparedCursorInstallation {
readonly paths: CursorRuntimePaths;
readonly changes: readonly FileChange[];
}
export type RenameFile = (source: string, destination: string) => Promise<void>;
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
async function readPhysicalFile(
filePath: string,
label: string,
maximumBytes = MAX_SOURCE_BYTES,
): Promise<FileSnapshot | undefined> {
let before;
try {
before = await fsp.lstat(filePath, { bigint: true });
} catch (error) {
if (hasCode(error, 'ENOENT')) return undefined;
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!before.isFile() || before.isSymbolicLink()) {
throw new Error(`${label} path is not a physical file: ${filePath}`);
}
if (before.size > BigInt(maximumBytes)) {
throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`);
}
let flags = constants.O_RDONLY;
if (process.platform !== 'win32' && typeof constants.O_NOFOLLOW === 'number') {
flags |= constants.O_NOFOLLOW;
}
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, flags);
const after = await handle.stat({ bigint: true });
if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(`${label} changed while opening: ${filePath}`);
}
const contents = await handle.readFile('utf-8');
if (Buffer.byteLength(contents) > maximumBytes) {
throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`);
}
return {
contents,
device: after.dev,
inode: after.ino,
mode: Number(after.mode & 0o777n),
};
} finally {
await handle?.close();
}
}
async function inspectDirectory(directory: string, label: string): Promise<boolean> {
let metadata;
try {
metadata = await fsp.lstat(directory);
} catch (error) {
if (hasCode(error, 'ENOENT')) return false;
throw new Error(`Inspect ${label} at ${directory}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`${label} is not a physical directory: ${directory}`);
}
return true;
}
function validateInstallConfig(config: InstallConfig): void {
for (const [field, value] of [
['orgId', config.orgId],
['agentId', config.agentId],
['kid', config.kid],
['privateKey', config.privateKey],
] as const) {
if (!value) throw new Error(`${field} is required`);
}
if (config.agentName !== AGENT_KEY) {
throw new Error(`Cursor installation requires agentName ${AGENT_KEY}`);
}
const seed = Buffer.from(config.privateKey, 'base64url');
if (seed.length !== 32 || seed.toString('base64url') !== config.privateKey) {
throw new Error('privateKey must be a canonical 32-byte base64url value');
}
const baseUrl = new URL(config.baseUrl);
if (!['http:', 'https:'].includes(baseUrl.protocol)) {
throw new Error('baseUrl must use HTTP or HTTPS');
}
}
export function cursorRuntimePaths(config: InstallConfig): CursorRuntimePaths { export function cursorRuntimePaths(config: InstallConfig): CursorRuntimePaths {
validateInstallConfig(config); return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT);
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId);
const guardPath = path.join(agentDirectory, GUARD_SCRIPT);
const auditPath = path.join(agentDirectory, AUDIT_SCRIPT);
if (!samePath(config.guardScriptPath, guardPath)) {
throw new Error(`Elydora guard runtime must use the managed agent directory: ${guardPath}`);
}
if (!samePath(config.hookScriptPath, auditPath)) {
throw new Error(`Elydora audit runtime must use the managed agent directory: ${auditPath}`);
}
return {
runtimeRoot,
agentDirectory,
configPath: path.join(agentDirectory, 'config.json'),
keyPath: path.join(agentDirectory, 'private.key'),
guardPath,
auditPath,
};
} }
function sameAgentId(left: unknown, right: string): boolean { export async function preflightCursorInstallation(
return typeof left === 'string' && (process.platform === 'win32' config: InstallConfig,
? left.toLowerCase() === right.toLowerCase() ): Promise<CursorRuntimePaths> {
: left === right); return preflightManagedInstallation({
} agentKey: AGENT_KEY,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
async function validateRuntimeIdentity(paths: CursorRuntimePaths): Promise<void> { hooksPath: cursorConfigPath(),
const runtimeRootExists = await inspectDirectory(paths.runtimeRoot, 'Elydora runtime directory'); config,
if (!runtimeRootExists) return; }, GUARD_SCRIPT, AUDIT_SCRIPT);
const agentDirectoryExists = await inspectDirectory(
paths.agentDirectory,
'Elydora agent runtime directory',
);
if (!agentDirectoryExists) return;
const config = await readPhysicalFile(paths.configPath, 'Elydora runtime config', MAX_CONFIG_BYTES);
const artifacts = await Promise.all([
readPhysicalFile(paths.keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(paths.guardPath, 'Elydora guard runtime'),
readPhysicalFile(paths.auditPath, 'Elydora audit runtime'),
readPhysicalFile(path.join(paths.agentDirectory, 'chain-state.json'), 'Elydora chain state'),
readPhysicalFile(path.join(paths.agentDirectory, 'status-cache.json'), 'Elydora status cache'),
]);
if (!config) {
if (artifacts.some(Boolean)) {
throw new Error(
`Elydora runtime identity cannot be verified without config.json: ${paths.agentDirectory}`,
);
}
return;
}
const value = parseStrictJsonObject(config.contents, `Elydora runtime config at ${paths.configPath}`);
if (value.agent_name !== AGENT_KEY || !sameAgentId(value.agent_id, path.basename(paths.agentDirectory))) {
throw new Error(
`Elydora runtime config identity does not match Cursor agent ${path.basename(paths.agentDirectory)}: ${paths.configPath}`,
);
}
}
export async function preflightCursorInstallation(config: InstallConfig): Promise<CursorRuntimePaths> {
const paths = cursorRuntimePaths(config);
await inspectDirectory(path.dirname(cursorConfigPath()), 'Cursor hooks directory');
await validateRuntimeIdentity(paths);
return paths;
}
function runtimeConfig(config: InstallConfig): string {
const value: JsonObject = {
org_id: config.orgId,
agent_id: config.agentId,
kid: config.kid,
base_url: config.baseUrl,
...(config.token ? { token: config.token } : {}),
agent_name: AGENT_KEY,
};
const encoded = `${JSON.stringify(value, null, 2)}\n`;
if (Buffer.byteLength(encoded) > MAX_CONFIG_BYTES) {
throw new Error(`Elydora runtime config exceeds ${MAX_CONFIG_BYTES} bytes`);
}
return encoded;
}
async function prepareChange(
filePath: string,
label: string,
next: string,
mode: number,
maximumBytes = MAX_SOURCE_BYTES,
): Promise<FileChange> {
return {
filePath,
label,
next,
mode,
original: await readPhysicalFile(filePath, label, maximumBytes),
};
} }
export async function prepareCursorInstallation( export async function prepareCursorInstallation(
config: InstallConfig, config: InstallConfig,
rendered: RenderedDocument, rendered: RenderedDocument,
): Promise<PreparedCursorInstallation> { ): Promise<PreparedCursorInstallation> {
const paths = await preflightCursorInstallation(config);
if (!rendered.changed && rendered.document.raw === undefined) { if (!rendered.changed && rendered.document.raw === undefined) {
throw new Error('Cursor hook installation did not produce a configuration document'); throw new Error('Cursor hook installation did not produce a configuration document');
} }
const hooksConfig = rendered.next ?? rendered.document.raw; const hooksSource = rendered.next ?? rendered.document.raw;
if (hooksConfig === undefined) throw new Error('Cursor hook configuration is missing'); if (hooksSource === undefined) throw new Error('Cursor hook configuration is missing');
const changes = await Promise.all([ return prepareManagedInstallation({
prepareChange( agentKey: AGENT_KEY,
paths.guardPath, displayName: DISPLAY_NAME,
'Elydora guard runtime', hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL,
generateGuardScript(AGENT_KEY, config.agentId, { hooksLabel: HOOKS_LABEL,
hooksPath: rendered.document.filePath,
expectedHooksSource: rendered.document.raw,
hooksSource,
config,
guardOptions: {
failClosed: true, failClosed: true,
successOutput: '{"permission":"allow"}\n', successOutput: '{"permission":"allow"}\n',
}), },
0o700, auditOptions: {
),
prepareChange(paths.configPath, 'Elydora runtime config', runtimeConfig(config), 0o600, MAX_CONFIG_BYTES),
prepareChange(paths.keyPath, 'Elydora private key', config.privateKey, 0o600, MAX_SECRET_BYTES),
prepareChange(
paths.auditPath,
'Elydora audit runtime',
generateHookScript(AGENT_KEY, config.agentId, {
failClosed: true, failClosed: true,
nativePayload: true, nativePayload: true,
successOutput: '{}\n', successOutput: '{}\n',
}), },
0o700, }, GUARD_SCRIPT, AUDIT_SCRIPT);
),
prepareChange(
rendered.document.filePath,
'Cursor user hooks',
hooksConfig,
0o600,
MAX_SOURCE_BYTES,
),
]);
const hooksSource = changes.at(-1)!.original?.contents;
if (hooksSource !== rendered.document.raw) {
throw new Error(
`Cursor user hooks changed before installation: ${rendered.document.filePath}`,
);
}
await validateRuntimeIdentity(paths);
return { paths, changes };
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) throw error;
}
}
async function assertUnchanged(change: FileChange): Promise<void> {
const current = await readPhysicalFile(change.filePath, change.label);
if ((!current && change.original)
|| (current && !change.original)
|| (current && change.original && (
current.contents !== change.original.contents
|| current.device !== change.original.device
|| current.inode !== change.original.inode
))) {
throw new Error(`${change.label} changed during Cursor installation: ${change.filePath}`);
}
}
async function writeExclusive(
filePath: string,
contents: string,
mode: number,
label: string,
): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', mode);
await handle.writeFile(contents, 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
if (process.platform !== 'win32') await fsp.chmod(filePath, mode);
} catch (error) {
const failures = [asError(error)];
if (handle) {
try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); }
}
try { await unlinkOptional(filePath); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`);
throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function stage(change: FileChange): Promise<StagedChange> {
await assertUnchanged(change);
const token = randomUUID();
const directory = path.dirname(change.filePath);
const temporaryPath = path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
const rollbackPath = change.original
? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`)
: undefined;
try {
await writeExclusive(temporaryPath, change.next, change.mode, change.label);
if (rollbackPath && change.original) {
await writeExclusive(
rollbackPath,
change.original.contents,
change.original.mode,
`${change.label} rollback`,
);
}
return { change, temporaryPath, rollbackPath, committed: false };
} catch (error) {
const failures = [asError(error)];
for (const item of [temporaryPath, rollbackPath]) {
if (!item) continue;
try { await unlinkOptional(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
throw error;
}
}
async function commit(staged: StagedChange, renameFile: RenameFile): Promise<void> {
await assertUnchanged(staged.change);
await renameFile(staged.temporaryPath, staged.change.filePath);
staged.committed = true;
}
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
if (!staged.committed) return;
if (!staged.change.original) {
await unlinkOptional(staged.change.filePath);
return;
}
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
await renameFile(staged.rollbackPath, staged.change.filePath);
}
async function cleanup(staged: StagedChange): Promise<void> {
await Promise.all(
[staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)),
);
}
async function ensureCursorDirectory(directory: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
await inspectDirectory(directory, 'Cursor hooks directory');
} }
export async function commitCursorInstallation( export async function commitCursorInstallation(
prepared: PreparedCursorInstallation, prepared: PreparedCursorInstallation,
renameFile: RenameFile = fsp.rename, renameFile?: RenameFile,
): Promise<void> { ): Promise<void> {
await ensurePrivateDirectory(prepared.paths.runtimeRoot); await commitManagedInstallation(prepared, renameFile);
await ensurePrivateDirectory(prepared.paths.agentDirectory);
await ensureCursorDirectory(path.dirname(prepared.changes.at(-1)!.filePath));
const staged: StagedChange[] = [];
try {
for (const change of prepared.changes) staged.push(await stage(change));
for (const item of staged) await commit(item, renameFile);
} catch (error) {
const failures = [asError(error)];
for (const item of [...staged].reverse()) {
try { await rollback(item, renameFile); } catch (rollbackError) {
failures.push(asError(rollbackError));
}
}
for (const item of staged) {
try { await cleanup(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
throw new AggregateError(failures, `Install Cursor hooks: ${errorMessage(error)}`);
}
const failures: Error[] = [];
for (const item of staged) {
try { await cleanup(item); } catch (error) { failures.push(asError(error)); }
}
if (failures.length > 0) {
throw new AggregateError(failures, 'Clean Cursor installation transaction files');
}
} }
+11 -20
View File
@@ -16,6 +16,7 @@ export function generateGuardScript(
'use strict'; 'use strict';
// Elydora agent guard for ${agentName}. Generated by @elydora/sdk. // Elydora agent guard for ${agentName}. Generated by @elydora/sdk.
const crypto = require('node:crypto');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
@@ -55,9 +56,13 @@ function blockAgent(status) {
function readStatusCache() { function readStatusCache() {
let raw; let raw;
try { try {
raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8'); raw = readProtectedFile(
STATUS_CACHE_PATH,
'Status cache',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') return undefined; if (hasRuntimeErrorCode(error, 'ENOENT')) return undefined;
throw new Error('Failed to read status cache: ' + error.message); throw new Error('Failed to read status cache: ' + error.message);
} }
let cache; let cache;
@@ -75,26 +80,10 @@ function readStatusCache() {
} }
function writeStatusCache(status) { function writeStatusCache(status) {
const temporaryPath = STATUS_CACHE_PATH + '.' + process.pid + '.tmp'; writeProtectedJson(STATUS_CACHE_PATH, 'Status cache', {
try {
fs.writeFileSync(temporaryPath, JSON.stringify({
status, status,
cached_at: Date.now(), cached_at: Date.now(),
}), { encoding: 'utf-8', mode: 0o600, flag: 'wx' }); });
try {
fs.renameSync(temporaryPath, STATUS_CACHE_PATH);
} catch (error) {
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
try { fs.unlinkSync(STATUS_CACHE_PATH); } catch (unlinkError) {
if (unlinkError.code !== 'ENOENT') throw unlinkError;
}
fs.renameSync(temporaryPath, STATUS_CACHE_PATH);
}
} finally {
try { fs.unlinkSync(temporaryPath); } catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
} }
async function readInput() { async function readInput() {
@@ -103,12 +92,14 @@ async function readInput() {
const raw = Buffer.concat(chunks).toString('utf-8').trim(); const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (!raw) { if (!raw) {
if (FAIL_CLOSED) throw new Error('Hook input is empty'); if (FAIL_CLOSED) throw new Error('Hook input is empty');
process.stderr.write('[Elydora guard] Hook input is empty; enforcement continued in fail-open mode.\\n');
return; return;
} }
try { try {
JSON.parse(raw); JSON.parse(raw);
} catch (error) { } catch (error) {
if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message); if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message);
process.stderr.write('[Elydora guard] Hook input is invalid JSON; enforcement continued in fail-open mode: ' + error.message + '\\n');
} }
} }
+8 -26
View File
@@ -130,7 +130,7 @@ function logError(error) {
try { try {
const message = new Date().toISOString() + ' [' + AGENT_NAME + '] ' + const message = new Date().toISOString() + ' [' + AGENT_NAME + '] ' +
(error.stack || error.message || String(error)) + '\\n'; (error.stack || error.message || String(error)) + '\\n';
fs.appendFileSync(ERROR_LOG_PATH, message, { encoding: 'utf-8', mode: 0o600 }); appendProtectedText(ERROR_LOG_PATH, 'Error log', message);
} catch (logFailure) { } catch (logFailure) {
process.stderr.write('[Elydora audit] Failed to write error log: ' + logFailure.message + '\\n'); process.stderr.write('[Elydora audit] Failed to write error log: ' + logFailure.message + '\\n');
} }
@@ -139,9 +139,13 @@ function logError(error) {
function readChainState() { function readChainState() {
let raw; let raw;
try { try {
raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8'); raw = readProtectedFile(
CHAIN_STATE_PATH,
'Chain state',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') return ZERO_CHAIN_HASH; if (hasRuntimeErrorCode(error, 'ENOENT')) return ZERO_CHAIN_HASH;
throw new Error('Failed to read chain state: ' + error.message); throw new Error('Failed to read chain state: ' + error.message);
} }
let state; let state;
@@ -158,29 +162,7 @@ function readChainState() {
} }
function writeChainState(chainHash) { function writeChainState(chainHash) {
const temporaryPath = CHAIN_STATE_PATH + '.' + process.pid + '.tmp'; writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
let descriptor;
try {
descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
fs.writeFileSync(descriptor, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
try {
fs.renameSync(temporaryPath, CHAIN_STATE_PATH);
} catch (error) {
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch (unlinkError) {
if (unlinkError.code !== 'ENOENT') throw unlinkError;
}
fs.renameSync(temporaryPath, CHAIN_STATE_PATH);
}
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
try { fs.unlinkSync(temporaryPath); } catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
} }
function readConfigAndKey() { function readConfigAndKey() {
+86
View File
@@ -0,0 +1,86 @@
import { constants } from 'node:fs';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
export interface FileSnapshot {
readonly contents: string;
readonly device: bigint | number;
readonly inode: bigint | number;
readonly mode: number;
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
export async function readPhysicalFile(
filePath: string,
label: string,
maximumBytes = MAX_SOURCE_BYTES,
): Promise<FileSnapshot | undefined> {
let before;
try {
before = await fsp.lstat(filePath, { bigint: true });
} catch (error) {
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return undefined;
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!before.isFile() || before.isSymbolicLink()) {
throw new Error(`${label} path is not a physical file: ${filePath}`);
}
if (before.size > BigInt(maximumBytes)) {
throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`);
}
let flags = constants.O_RDONLY;
if (process.platform !== 'win32' && typeof constants.O_NOFOLLOW === 'number') {
flags |= constants.O_NOFOLLOW;
}
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, flags);
const after = await handle.stat({ bigint: true });
if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(`${label} changed while opening: ${filePath}`);
}
const contents = await handle.readFile('utf-8');
if (Buffer.byteLength(contents) > maximumBytes) {
throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`);
}
return {
contents,
device: after.dev,
inode: after.ino,
mode: Number(after.mode & 0o777n),
};
} finally {
await handle?.close();
}
}
export async function inspectPhysicalDirectory(directory: string, label: string): Promise<boolean> {
let metadata;
try {
metadata = await fsp.lstat(directory);
} catch (error) {
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
throw new Error(`Inspect ${label} at ${directory}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`${label} is not a physical directory: ${directory}`);
}
return true;
}
@@ -0,0 +1,432 @@
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 { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js';
import type { InstallConfig } from './base.js';
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
import { generateHookScript, type HookScriptOptions } from './hook-template.js';
import {
inspectPhysicalDirectory,
readPhysicalFile,
type FileSnapshot,
} from './managed-files.js';
import { parseStrictJsonObject } from './strict-json.js';
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly next: string;
readonly mode: number;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
interface StagedChange {
readonly change: FileChange;
readonly temporaryPath: string;
readonly rollbackPath?: string;
committed: boolean;
}
export interface ManagedRuntimePaths {
readonly runtimeRoot: string;
readonly agentDirectory: string;
readonly configPath: string;
readonly keyPath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedInstallationSpec {
readonly agentKey: string;
readonly displayName: string;
readonly hooksDirectoryLabel: string;
readonly hooksLabel: string;
readonly hooksPath: string;
readonly expectedHooksSource?: string;
readonly hooksSource: string;
readonly config: InstallConfig;
readonly guardOptions?: GuardScriptOptions;
readonly auditOptions?: HookScriptOptions;
}
export interface ManagedPreflightSpec {
readonly agentKey: string;
readonly hooksDirectoryLabel: string;
readonly hooksPath: string;
readonly config: InstallConfig;
}
export interface PreparedManagedInstallation {
readonly displayName: string;
readonly hooksDirectoryLabel: string;
readonly paths: ManagedRuntimePaths;
readonly changes: readonly FileChange[];
}
export type RenameFile = (source: string, destination: string) => Promise<void>;
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
export function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === 'win32'
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}
function validateInstallConfig(config: InstallConfig, agentKey: string): void {
for (const [field, value] of [
['agentName', config.agentName],
['orgId', config.orgId],
['agentId', config.agentId],
['kid', config.kid],
['privateKey', config.privateKey],
['baseUrl', config.baseUrl],
['guardScriptPath', config.guardScriptPath],
['hookScriptPath', config.hookScriptPath],
] as const) {
if (typeof value !== 'string' || !value) throw new Error(`${field} is required`);
}
if (config.token !== undefined && (typeof config.token !== 'string' || !config.token)) {
throw new Error('token must be a non-empty string when provided');
}
if (config.agentName !== agentKey) {
throw new Error(`${agentKey} installation requires agentName ${agentKey}`);
}
const seed = Buffer.from(config.privateKey, 'base64url');
if (seed.length !== 32 || seed.toString('base64url') !== config.privateKey) {
throw new Error('privateKey must be a canonical 32-byte base64url value');
}
const baseUrl = new URL(config.baseUrl);
if (!['http:', 'https:'].includes(baseUrl.protocol)) {
throw new Error('baseUrl must use HTTP or HTTPS');
}
if (baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
throw new Error('baseUrl must exclude credentials, query parameters, and fragments');
}
}
export function managedRuntimePaths(
config: InstallConfig,
agentKey: string,
guardScript: string,
auditScript: string,
): ManagedRuntimePaths {
validateInstallConfig(config, agentKey);
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId);
const guardPath = path.join(agentDirectory, guardScript);
const auditPath = path.join(agentDirectory, auditScript);
if (!samePath(config.guardScriptPath, guardPath)) {
throw new Error(`Elydora guard runtime must use the managed agent directory: ${guardPath}`);
}
if (!samePath(config.hookScriptPath, auditPath)) {
throw new Error(`Elydora audit runtime must use the managed agent directory: ${auditPath}`);
}
return {
runtimeRoot,
agentDirectory,
configPath: path.join(agentDirectory, 'config.json'),
keyPath: path.join(agentDirectory, 'private.key'),
guardPath,
auditPath,
};
}
function sameAgentId(left: unknown, right: string): boolean {
return typeof left === 'string' && (process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right);
}
async function validateRuntimeIdentity(
paths: ManagedRuntimePaths,
agentKey: string,
): Promise<void> {
if (!await inspectPhysicalDirectory(paths.runtimeRoot, 'Elydora runtime directory')) return;
if (!await inspectPhysicalDirectory(paths.agentDirectory, 'Elydora agent runtime directory')) return;
const config = await readPhysicalFile(paths.configPath, 'Elydora runtime config', MAX_CONFIG_BYTES);
const artifacts = await Promise.all([
readPhysicalFile(paths.keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(paths.guardPath, 'Elydora guard runtime'),
readPhysicalFile(paths.auditPath, 'Elydora audit runtime'),
readPhysicalFile(path.join(paths.agentDirectory, 'chain-state.json'), 'Elydora chain state'),
readPhysicalFile(path.join(paths.agentDirectory, 'status-cache.json'), 'Elydora status cache'),
readPhysicalFile(path.join(paths.agentDirectory, 'error.log'), 'Elydora error log'),
]);
if (!config) {
if (artifacts.some(Boolean)) {
throw new Error(
`Elydora runtime identity cannot be verified without config.json: ${paths.agentDirectory}`,
);
}
return;
}
const label = `Elydora runtime config at ${paths.configPath}`;
const value = parseStrictJsonObject(config.contents, label);
if (value.agent_name !== agentKey
|| !sameAgentId(value.agent_id, path.basename(paths.agentDirectory))) {
throw new Error(
`Elydora runtime config identity does not match ${agentKey} agent ${path.basename(paths.agentDirectory)}: ${paths.configPath}`,
);
}
}
export async function preflightManagedInstallation(
spec: ManagedPreflightSpec,
guardScript: string,
auditScript: string,
): Promise<ManagedRuntimePaths> {
const paths = managedRuntimePaths(spec.config, spec.agentKey, guardScript, auditScript);
await inspectPhysicalDirectory(path.dirname(spec.hooksPath), spec.hooksDirectoryLabel);
await validateRuntimeIdentity(paths, spec.agentKey);
return paths;
}
function runtimeConfig(config: InstallConfig, agentKey: string): string {
const value = {
org_id: config.orgId,
agent_id: config.agentId,
kid: config.kid,
base_url: config.baseUrl,
...(config.token ? { token: config.token } : {}),
agent_name: agentKey,
};
const encoded = `${JSON.stringify(value, null, 2)}\n`;
if (Buffer.byteLength(encoded) > MAX_CONFIG_BYTES) {
throw new Error(`Elydora runtime config exceeds ${MAX_CONFIG_BYTES} bytes`);
}
return encoded;
}
async function prepareChange(
filePath: string,
label: string,
next: string,
mode: number,
maximumBytes = MAX_SOURCE_BYTES,
): Promise<FileChange> {
return {
filePath,
label,
next,
mode,
maximumBytes,
original: await readPhysicalFile(filePath, label, maximumBytes),
};
}
export async function prepareManagedInstallation(
spec: ManagedInstallationSpec,
guardScript: string,
auditScript: string,
): Promise<PreparedManagedInstallation> {
const paths = await preflightManagedInstallation(spec, guardScript, auditScript);
const changes = await Promise.all([
prepareChange(
paths.guardPath,
'Elydora guard runtime',
generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions),
0o700,
),
prepareChange(
paths.configPath,
'Elydora runtime config',
runtimeConfig(spec.config, spec.agentKey),
0o600,
MAX_CONFIG_BYTES,
),
prepareChange(
paths.keyPath,
'Elydora private key',
spec.config.privateKey,
0o600,
MAX_SECRET_BYTES,
),
prepareChange(
paths.auditPath,
'Elydora audit runtime',
generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions),
0o700,
),
prepareChange(spec.hooksPath, spec.hooksLabel, spec.hooksSource, 0o600),
]);
const hooksSource = changes.at(-1)!.original?.contents;
if (hooksSource !== spec.expectedHooksSource) {
throw new Error(`${spec.hooksLabel} changed before installation: ${spec.hooksPath}`);
}
await validateRuntimeIdentity(paths, spec.agentKey);
return {
displayName: spec.displayName,
hooksDirectoryLabel: spec.hooksDirectoryLabel,
paths,
changes,
};
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) throw error;
}
}
async function assertUnchanged(change: FileChange, displayName: string): Promise<void> {
const current = await readPhysicalFile(change.filePath, change.label, change.maximumBytes);
if ((!current && change.original)
|| (current && !change.original)
|| (current && change.original && (
current.contents !== change.original.contents
|| current.device !== change.original.device
|| current.inode !== change.original.inode
))) {
throw new Error(`${change.label} changed during ${displayName} installation: ${change.filePath}`);
}
}
async function writeExclusive(
filePath: string,
contents: string,
mode: number,
label: string,
): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', mode);
await handle.writeFile(contents, 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
if (process.platform !== 'win32') await fsp.chmod(filePath, mode);
} catch (error) {
const failures = [asError(error)];
if (handle) {
try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); }
}
try { await unlinkOptional(filePath); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`);
throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function stage(change: FileChange, displayName: string): Promise<StagedChange> {
await assertUnchanged(change, displayName);
const token = randomUUID();
const directory = path.dirname(change.filePath);
const temporaryPath = path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
const rollbackPath = change.original
? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`)
: undefined;
try {
await writeExclusive(temporaryPath, change.next, change.mode, change.label);
if (rollbackPath && change.original) {
await writeExclusive(
rollbackPath,
change.original.contents,
change.original.mode,
`${change.label} rollback`,
);
}
return { change, temporaryPath, rollbackPath, committed: false };
} catch (error) {
const failures = [asError(error)];
for (const item of [temporaryPath, rollbackPath]) {
if (!item) continue;
try { await unlinkOptional(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
throw error;
}
}
async function commit(
staged: StagedChange,
displayName: string,
renameFile: RenameFile,
): Promise<void> {
await assertUnchanged(staged.change, displayName);
await renameFile(staged.temporaryPath, staged.change.filePath);
staged.committed = true;
}
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
if (!staged.committed) return;
if (!staged.change.original) {
await unlinkOptional(staged.change.filePath);
return;
}
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
await renameFile(staged.rollbackPath, staged.change.filePath);
}
async function cleanup(staged: StagedChange): Promise<void> {
await Promise.all(
[staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)),
);
}
async function ensureManagedDirectory(directory: string, label: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
if (!await inspectPhysicalDirectory(directory, label)) {
throw new Error(`${label} is missing: ${directory}`);
}
}
export async function commitManagedInstallation(
prepared: PreparedManagedInstallation,
renameFile: RenameFile = fsp.rename,
): Promise<void> {
await ensurePrivateDirectory(prepared.paths.runtimeRoot);
await ensurePrivateDirectory(prepared.paths.agentDirectory);
await ensureManagedDirectory(
path.dirname(prepared.changes.at(-1)!.filePath),
prepared.hooksDirectoryLabel,
);
const staged: StagedChange[] = [];
try {
for (const change of prepared.changes) {
staged.push(await stage(change, prepared.displayName));
}
for (const item of staged) await commit(item, prepared.displayName, renameFile);
} catch (error) {
const failures = [asError(error)];
for (const item of [...staged].reverse()) {
try { await rollback(item, renameFile); } catch (rollbackError) {
failures.push(asError(rollbackError));
}
}
for (const item of staged) {
try { await cleanup(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
throw new AggregateError(
failures,
`Install ${prepared.displayName} hooks: ${errorMessage(error)}`,
);
}
const failures: Error[] = [];
for (const item of staged) {
try { await cleanup(item); } catch (error) { failures.push(asError(error)); }
}
if (failures.length > 0) {
throw new AggregateError(
failures,
`Clean ${prepared.displayName} installation transaction files`,
);
}
}
+114 -14
View File
@@ -1,18 +1,31 @@
export const PROTECTED_RUNTIME_READER: string = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024; export const PROTECTED_RUNTIME_READER: string = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024;
const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024; const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024;
const MAX_PROTECTED_LOG_BYTES = 2 * 1024 * 1024;
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) { function hasRuntimeErrorCode(error, code) {
const before = fs.lstatSync(filePath); return error && typeof error === 'object' && error.code === code;
if (!before.isFile() || before.isSymbolicLink()) { }
function validateProtectedMetadata(metadata, filePath, label, maxBytes) {
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error(label + ' path is not a physical file: ' + filePath); throw new Error(label + ' path is not a physical file: ' + filePath);
} }
if (before.size > maxBytes) { if (metadata.size > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath); throw new Error(label + ' exceeds the size limit: ' + filePath);
} }
if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) { if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
throw new Error(label + ' permissions are too broad: ' + filePath); throw new Error(label + ' permissions are too broad: ' + filePath);
} }
}
function inspectProtectedFile(filePath, label, maxBytes) {
const metadata = fs.lstatSync(filePath);
validateProtectedMetadata(metadata, filePath, label, maxBytes);
return metadata;
}
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) {
const before = inspectProtectedFile(filePath, label, maxBytes);
let flags = fs.constants.O_RDONLY; let flags = fs.constants.O_RDONLY;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') { if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
flags |= fs.constants.O_NOFOLLOW; flags |= fs.constants.O_NOFOLLOW;
@@ -20,15 +33,7 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE
const descriptor = fs.openSync(filePath, flags); const descriptor = fs.openSync(filePath, flags);
try { try {
const after = fs.fstatSync(descriptor); const after = fs.fstatSync(descriptor);
if (!after.isFile()) { validateProtectedMetadata(after, filePath, label, maxBytes);
throw new Error(label + ' path is not a physical file: ' + filePath);
}
if (after.size > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
if (process.platform !== 'win32' && (after.mode & 0o077) !== 0) {
throw new Error(label + ' permissions are too broad: ' + filePath);
}
if (before.dev !== after.dev || before.ino !== after.ino) { if (before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(label + ' changed while opening: ' + filePath); throw new Error(label + ' changed while opening: ' + filePath);
} }
@@ -41,4 +46,99 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE
fs.closeSync(descriptor); fs.closeSync(descriptor);
} }
} }
function openProtectedAppend(filePath, label, maxBytes) {
for (let attempt = 0; attempt < 3; attempt += 1) {
let before;
try {
before = inspectProtectedFile(filePath, label, maxBytes);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
let createFlags = fs.constants.O_WRONLY | fs.constants.O_APPEND |
fs.constants.O_CREAT | fs.constants.O_EXCL;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
createFlags |= fs.constants.O_NOFOLLOW;
}
try {
const descriptor = fs.openSync(filePath, createFlags, 0o600);
const metadata = fs.fstatSync(descriptor);
validateProtectedMetadata(metadata, filePath, label, maxBytes);
return descriptor;
} catch (createError) {
if (hasRuntimeErrorCode(createError, 'EEXIST')) continue;
throw createError;
}
}
let flags = fs.constants.O_WRONLY | fs.constants.O_APPEND;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
flags |= fs.constants.O_NOFOLLOW;
}
const descriptor = fs.openSync(filePath, flags);
try {
const after = fs.fstatSync(descriptor);
validateProtectedMetadata(after, filePath, label, maxBytes);
if (before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(label + ' changed while opening: ' + filePath);
}
return descriptor;
} catch (error) {
fs.closeSync(descriptor);
throw error;
}
}
throw new Error(label + ' changed repeatedly while opening: ' + filePath);
}
function appendProtectedText(filePath, label, text, maxBytes = MAX_PROTECTED_LOG_BYTES) {
const descriptor = openProtectedAppend(filePath, label, maxBytes);
try {
const metadata = fs.fstatSync(descriptor);
const bytes = Buffer.byteLength(text, 'utf-8');
if (metadata.size + bytes > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
fs.writeFileSync(descriptor, text, 'utf-8');
fs.fsyncSync(descriptor);
if (process.platform !== 'win32') fs.fchmodSync(descriptor, 0o600);
} finally {
fs.closeSync(descriptor);
}
}
function writeProtectedJson(filePath, label, value) {
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded, 'utf-8') > MAX_PROTECTED_CONFIG_BYTES) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
const temporaryPath = filePath + '.' + process.pid + '.' +
crypto.randomBytes(8).toString('hex') + '.tmp';
let descriptor;
try {
descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
fs.writeFileSync(descriptor, encoded, 'utf-8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
if (process.platform !== 'win32') fs.chmodSync(temporaryPath, 0o600);
try {
inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
}
try {
fs.renameSync(temporaryPath, filePath);
} catch (error) {
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES);
fs.unlinkSync(filePath);
fs.renameSync(temporaryPath, filePath);
}
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
try { fs.unlinkSync(temporaryPath); } catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
}
}
}
`; `;
+57
View File
@@ -0,0 +1,57 @@
import {
parse,
parseTree,
printParseErrorCode,
type Node,
type ParseError,
} from 'jsonc-parser';
export type JsonObject = Record<string, unknown>;
export function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function rejectDuplicateKeys(
node: Node | undefined,
label: string,
location: string[] = [],
): void {
if (!node) return;
if (node.type === 'object') {
const keys = new Set<string>();
for (const property of node.children ?? []) {
const key = String(property.children?.[0]?.value);
const childLocation = [...location, key];
if (keys.has(key)) {
throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`);
}
keys.add(key);
rejectDuplicateKeys(property.children?.[1], label, childLocation);
}
return;
}
if (node.type === 'array') {
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location);
}
}
export function parseStrictJsonObject(raw: string, label: string): JsonObject {
const errors: ParseError[] = [];
const value: unknown = parse(raw, errors, {
allowTrailingComma: false,
disallowComments: true,
});
if (errors.length > 0) {
const details = errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join(', ');
throw new Error(`Failed to parse ${label}: ${details}`);
}
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
rejectDuplicateKeys(parseTree(raw, [], {
allowTrailingComma: false,
disallowComments: true,
}), label);
return value;
}
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, 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/codex.js')).href;
export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/codex-io.js')).href;
export const installationModuleUrl = pathToFileURL(
path.resolve('dist/plugins/codex-installation.js'),
).href;
export function runNode(args, env, cwd, input = '') {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
cwd,
env: { ...process.env, CODEX_HOME: '', ...env },
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 runHook(handler, input, fixture, environment = {}) {
const command = process.platform === 'win32' ? handler.commandWindows : handler.command;
return new Promise((resolve, reject) => {
const child = process.platform === 'win32'
? spawn(command, {
shell: true,
cwd: fixture.projectDir,
env: {
...process.env,
CODEX_HOME: '',
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
...environment,
},
stdio: ['pipe', 'pipe', 'pipe'],
})
: spawn('/bin/sh', ['-c', command], {
cwd: fixture.projectDir,
env: {
...process.env,
CODEX_HOME: '',
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
...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 async function writeJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`;
await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 });
}
export function installConfig(fixture, overrides = {}) {
return {
agentName: 'codex',
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 { codexPlugin } from ${JSON.stringify(pluginModuleUrl)};
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
const result = await codexPlugin[process.env.ELYDORA_TEST_METHOD](argument);
if (result !== undefined) console.log(JSON.stringify(result));
`;
return runNode(
['--input-type=module', '--eval', source],
{
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
ELYDORA_TEST_METHOD: method,
...environment,
},
fixture.projectDir,
);
}
export async function createFixture({ existingConfig, baseUrl = 'http://127.0.0.1:9' } = {}) {
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-codex-'));
const homeDir = path.join(rootDir, "home with spaces and 'quote %ELYDORA_HOOK_PATH%");
const projectDir = path.join(rootDir, 'project with spaces');
const configPath = path.join(homeDir, '.codex', 'hooks.json');
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
const guardScriptPath = path.join(agentDir, 'guard.js');
const hookScriptPath = path.join(agentDir, 'hook.js');
await mkdir(projectDir, { recursive: true });
if (existingConfig !== undefined) await writeJson(configPath, existingConfig);
return {
agentDir,
baseUrl,
configPath,
guardScriptPath,
homeDir,
hookScriptPath,
projectDir,
rootDir,
install(overrides = {}) {
return runPlugin(this, 'install', installConfig(this, overrides));
},
async close() {
await rm(rootDir, { recursive: true, force: true });
},
};
}
export function managedHandler(config, event, statusMessage) {
for (const group of config.hooks?.[event] ?? []) {
const handler = group.hooks?.find((item) => item.statusMessage === statusMessage);
if (handler) return { group, handler };
}
return undefined;
}
export function assertNativeHandler(value, statusMessage) {
assert(value);
assert.deepEqual(Object.keys(value.group).sort(), ['hooks', 'matcher']);
assert.equal(value.group.matcher, '*');
assert.deepEqual(
Object.keys(value.handler).sort(),
['command', 'commandWindows', 'statusMessage', 'timeout', 'type'],
);
assert.equal(value.handler.type, 'command');
assert.equal(value.handler.timeout, 10);
assert.equal(value.handler.statusMessage, statusMessage);
assert.match(value.handler.command, /node(?:\.exe)?/i);
assert.match(value.handler.commandWindows, /^"[^"]+powershell\.exe" .* -EncodedCommand /i);
}
export function legacyHandler(scriptPath, statusMessage) {
const quotePosix = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
return {
type: 'command',
command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`,
commandWindows: `"${process.execPath}" "${scriptPath}"`,
timeout: 10,
statusMessage,
};
}
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());
}),
};
}
+369 -275
View File
@@ -1,120 +1,35 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import {
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; lstat,
import os from 'node:os'; mkdir,
readFile,
readdir,
realpath,
rename,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { pathToFileURL } from 'node:url'; import {
VALID_PRIVATE_KEY,
assertNativeHandler,
createFixture,
installConfig,
legacyHandler,
managedHandler,
registryModuleUrl,
runNode,
runPlugin,
writeJson,
} from '../test-support/codex-test-helpers.mjs';
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/codex.js')).href; const GUARD_STATUS = 'Checking Elydora agent state';
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; const AUDIT_STATUS = 'Recording Elydora tool use';
function runNode(args, env, input = '') { test('Codex is registered with the native user hooks file', async () => {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
env: { ...process.env, ...env },
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 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 findHandler(settings, event, statusMessage) {
for (const group of settings.hooks?.[event] ?? []) {
const handler = group.hooks?.find((item) => item.statusMessage === statusMessage);
if (handler) return handler;
}
return undefined;
}
async function runPlugin(homeDir, method, argument) {
const script = `
import { codexPlugin } from ${JSON.stringify(pluginModuleUrl)};
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
const result = await codexPlugin[process.env.ELYDORA_TEST_METHOD](argument);
if (result !== undefined) console.log(JSON.stringify(result));
`;
return runNode(
['--input-type=module', '--eval', script],
{
HOME: homeDir,
USERPROFILE: homeDir,
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
ELYDORA_TEST_METHOD: method,
},
);
}
async function createFixture({ guardSource, hookSource, existingSettings } = {}) {
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-codex-'));
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
const configDir = path.join(homeDir, '.codex');
await mkdir(agentDir, { recursive: true });
await mkdir(configDir, { recursive: true });
const guardScriptPath = path.join(agentDir, 'guard.js');
const hookScriptPath = path.join(agentDir, 'hook.js');
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: 'codex',
}));
const configPath = path.join(configDir, 'hooks.json');
if (existingSettings !== undefined) {
await writeFile(configPath, typeof existingSettings === 'string'
? existingSettings
: JSON.stringify(existingSettings, null, 2));
}
const installResult = await runPlugin(homeDir, 'install', {
agentName: 'codex',
agentId: 'agent-1',
baseUrl: 'https://api.elydora.com',
guardScriptPath,
hookScriptPath,
});
return {
agentDir,
configPath,
guardScriptPath,
homeDir,
hookScriptPath,
installResult,
async close() {
await rm(homeDir, { recursive: true, force: true });
},
};
}
test('Codex is registered as a native hook integration', async () => {
const { SUPPORTED_AGENTS } = await import(registryModuleUrl); const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
assert.deepEqual(SUPPORTED_AGENTS.get('codex'), { assert.deepEqual(SUPPORTED_AGENTS.get('codex'), {
name: 'OpenAI Codex', name: 'OpenAI Codex',
@@ -123,162 +38,162 @@ test('Codex is registered as a native hook integration', async () => {
}); });
}); });
test('Codex install preserves existing hooks and is idempotent', async () => { test('Codex follows and canonicalizes the official CODEX_HOME root', async () => {
const existing = { const fixture = await createFixture();
description: 'Workspace hooks', try {
const target = path.join(fixture.rootDir, 'custom Codex home');
const configured = path.join(fixture.rootDir, 'codex-home-link');
await mkdir(target, { recursive: true });
let codexHome = target;
try {
await symlink(target, configured, 'junction');
codexHome = configured;
} catch (error) {
if (error?.code !== 'EPERM') throw error;
}
const install = await runPlugin(
fixture,
'install',
installConfig(fixture),
{ CODEX_HOME: codexHome },
);
assert.equal(install.code, 0, install.stderr);
const expectedPath = path.join(await realpath(target), 'hooks.json');
assert.match(await readFile(expectedPath, 'utf-8'), /PreToolUse/);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
const status = await runPlugin(fixture, 'status', null, { CODEX_HOME: codexHome });
assert.equal(status.code, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).configPath, expectedPath);
assert.equal(JSON.parse(status.stdout).installed, true);
} finally {
await fixture.close();
}
});
test('Codex rejects missing and file-backed CODEX_HOME values before writes', async () => {
const fixture = await createFixture();
try {
const missing = path.join(fixture.rootDir, 'missing-codex-home');
let result = await runPlugin(
fixture,
'install',
installConfig(fixture),
{ CODEX_HOME: missing },
);
assert.equal(result.code, 1);
assert.match(result.stderr, /Resolve CODEX_HOME/i);
await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
const filePath = path.join(fixture.rootDir, 'codex-home-file');
await writeFile(filePath, 'file');
result = await runPlugin(
fixture,
'install',
installConfig(fixture),
{ CODEX_HOME: filePath },
);
assert.equal(result.code, 1);
assert.match(result.stderr, /CODEX_HOME is not a directory/i);
await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
} finally {
await fixture.close();
}
});
test('Codex install preserves sources, migrates legacy handlers, and is idempotent', async () => {
const fixture = await createFixture();
try {
await writeJson(fixture.configPath, {
description: 'User hooks',
custom: { keep: true },
hooks: { hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'existing-command' }] }], SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'keep' }] }],
PreToolUse: [
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'user-pre' }] },
{ matcher: '*', hooks: [legacyHandler(fixture.guardScriptPath, GUARD_STATUS)] },
],
PostToolUse: [
{ matcher: '*', hooks: [legacyHandler(fixture.hookScriptPath, AUDIT_STATUS)] },
],
}, },
};
const fixture = await createFixture({ existingSettings: existing });
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
assert.match(fixture.installResult.stdout, /run \/hooks to review and trust/i);
const secondInstall = await runPlugin(fixture.homeDir, 'install', {
agentName: 'codex',
agentId: 'agent-1',
baseUrl: 'https://api.elydora.com',
guardScriptPath: fixture.guardScriptPath,
hookScriptPath: fixture.hookScriptPath,
}); });
assert.equal(secondInstall.code, 0, secondInstall.stderr); const first = await fixture.install();
assert.equal(first.code, 0, first.stderr);
assert.match(first.stdout, /run \/hooks and approve both/i);
const second = await fixture.install();
assert.equal(second.code, 0, second.stderr);
const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
assert.equal(settings.description, 'Workspace hooks'); assert.equal(config.description, 'User hooks');
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, 'existing-command'); assert.deepEqual(config.custom, { keep: true });
assert.equal(settings.hooks.PreToolUse.length, 1); assert.equal(config.hooks.SessionStart[0].hooks[0].command, 'keep');
assert.equal(settings.hooks.PostToolUse.length, 1); assert.equal(config.hooks.PreToolUse.length, 2);
assert.equal(settings.hooks.PreToolUse[0].matcher, '*'); assert.equal(config.hooks.PostToolUse.length, 1);
assert.equal(settings.hooks.PostToolUse[0].matcher, '*'); assert.deepEqual(config.hooks.PreToolUse[0].hooks, [{ type: 'command', command: 'user-pre' }]);
assertNativeHandler(managedHandler(config, 'PreToolUse', GUARD_STATUS), GUARD_STATUS);
assertNativeHandler(managedHandler(config, 'PostToolUse', AUDIT_STATUS), AUDIT_STATUS);
} finally { } finally {
await fixture.close(); await fixture.close();
} }
}); });
test('Codex hook commands enforce freezes and forward the official event payload', async () => { test('Codex rejects malformed and ambiguous JSON before creating runtime files', async () => {
const capturePath = path.join(os.tmpdir(), `elydora-codex-event-${process.pid}-${Date.now()}.json`); const invalidConfigs = [
const hookSource = ` '{ malformed',
const fs = require('node:fs'); '[]\n',
const chunks = []; '{"hooks":null}\n',
process.stdin.on('data', (chunk) => chunks.push(chunk)); '{"hooks":{"PreToolUse":null}}\n',
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks))); '{"hooks":{"PreToolUse":[null]}}\n',
`; '{"hooks":{"PreToolUse":[{"hooks":null}]}}\n',
const fixture = await createFixture({ hookSource }); '{"hooks":{"PreToolUse":[{"hooks":[null]}]}}\n',
'{"hooks":{},"hooks":{}}\n',
'{"hooks":{},}\n',
];
for (const existingConfig of invalidConfigs) {
const fixture = await createFixture({ existingConfig });
try { try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); const before = await readFile(fixture.configPath, 'utf-8');
const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); const result = await fixture.install();
const guard = findHandler(settings, 'PreToolUse', 'Checking Elydora agent state'); assert.equal(result.code, 1, `accepted ${existingConfig}`);
const audit = findHandler(settings, 'PostToolUse', 'Recording Elydora tool use'); assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
assert(guard); await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
assert(audit);
const commandKey = process.platform === 'win32' ? 'commandWindows' : 'command';
const guardResult = await runCommand(guard[commandKey], JSON.stringify({
hook_event_name: 'PreToolUse',
session_id: 'session-1',
tool_name: 'Bash',
tool_input: { command: 'echo test' },
}));
assert.equal(guardResult.code, 2);
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
const payload = {
hook_event_name: 'PostToolUse',
session_id: 'session-1',
tool_name: 'Bash',
tool_use_id: 'call-1',
tool_input: { command: 'echo test' },
tool_response: { output: 'test' },
};
const auditResult = await runCommand(audit[commandKey], JSON.stringify(payload));
assert.equal(auditResult.code, 0, auditResult.stderr);
assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf-8')), payload);
} finally { } finally {
await fixture.close(); await fixture.close();
await rm(capturePath, { force: true }); }
} }
}); });
test('Codex status requires both runtime scripts and uninstall preserves other hooks', async () => { test('Codex status requires an exact hook pair, matching identity, and runtime secrets', async () => {
const fixture = await createFixture({ existingSettings: { const fixture = await createFixture();
hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'existing-command' }] }] },
} });
try { try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); assert.equal((await fixture.install()).code, 0);
const status = await runPlugin(fixture.homeDir, 'status', null); let status = await runPlugin(fixture, 'status', null);
assert.equal(status.code, 0, status.stderr); assert.equal(status.code, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).installed, true); assert.equal(JSON.parse(status.stdout).installed, true);
await rm(fixture.guardScriptPath); const keyPath = path.join(fixture.agentDir, 'private.key');
const degradedStatus = await runPlugin(fixture.homeDir, 'status', null); await rm(keyPath);
assert.equal(JSON.parse(degradedStatus.stdout).installed, false); status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).installed, false);
const uninstall = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1'); assert.equal((await fixture.install()).code, 0);
assert.equal(uninstall.code, 0, uninstall.stderr); const hooks = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); managedHandler(hooks, 'PreToolUse', GUARD_STATUS).group.matcher = 'Bash';
assert.equal(settings.hooks.PreToolUse.length, 1); await writeJson(fixture.configPath, hooks);
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, 'existing-command'); status = await runPlugin(fixture, 'status', null);
assert.deepEqual(settings.hooks.PostToolUse, []); assert.equal(JSON.parse(status.stdout).hookConfigured, false);
} finally {
await fixture.close();
}
});
test('Codex install preserves user handlers that reuse Elydora status text', async () => { assert.equal((await fixture.install()).code, 0);
const userGuard = { await writeJson(path.join(fixture.agentDir, 'config.json'), {
type: 'command', agent_id: 'another-agent',
command: 'user-guard', agent_name: 'codex',
statusMessage: 'Checking Elydora agent state',
};
const userAudit = {
type: 'command',
command: 'user-audit',
statusMessage: 'Recording Elydora tool use',
};
const fixture = await createFixture({ existingSettings: {
hooks: {
PreToolUse: [{ matcher: 'Bash', hooks: [userGuard] }],
PostToolUse: [{ matcher: 'Bash', hooks: [userAudit] }],
},
} });
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
assert.deepEqual(settings.hooks.PreToolUse[0].hooks[0], userGuard);
assert.deepEqual(settings.hooks.PostToolUse[0].hooks[0], userAudit);
assert.equal(settings.hooks.PreToolUse.length, 2);
assert.equal(settings.hooks.PostToolUse.length, 2);
} finally {
await fixture.close();
}
});
test('Codex install rejects missing runtimes before writing hooks config', async () => {
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-codex-missing-runtime-'));
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
const configPath = path.join(homeDir, '.codex', 'hooks.json');
try {
const install = await runPlugin(homeDir, 'install', {
agentName: 'codex',
agentId: 'agent-1',
baseUrl: 'https://api.elydora.com',
guardScriptPath: path.join(agentDir, 'guard.js'),
hookScriptPath: path.join(agentDir, 'hook.js'),
}); });
assert.equal(install.code, 1); status = await runPlugin(fixture, 'status', null);
assert.match(install.stderr, /runtime is missing/i); assert.equal(JSON.parse(status.stdout).installed, false);
await assert.rejects(readFile(configPath), { code: 'ENOENT' });
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test('Codex status surfaces malformed referenced runtime metadata', async () => {
const fixture = await createFixture();
try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed');
const status = await runPlugin(fixture.homeDir, 'status', null); status = await runPlugin(fixture, 'status', null);
assert.equal(status.code, 1); assert.equal(status.code, 1);
assert.match(status.stderr, /parse Elydora runtime config/i); assert.match(status.stderr, /parse Elydora runtime config/i);
} finally { } finally {
@@ -286,58 +201,237 @@ test('Codex status surfaces malformed referenced runtime metadata', async () =>
} }
}); });
test('Codex status surfaces malformed hook matcher groups', async () => { test('Codex uninstall removes exact ownership and preserves lookalike handlers', async () => {
const fixture = await createFixture(); const fixture = await createFixture({
existingConfig: {
hooks: {
SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'keep' }] }],
},
},
});
try { try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); assert.equal((await fixture.install()).code, 0);
const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
settings.hooks.PreToolUse[0].hooks = null; const otherGuard = legacyHandler(
await writeFile(fixture.configPath, JSON.stringify(settings, null, 2)); path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js'),
const status = await runPlugin(fixture.homeDir, 'status', null); GUARD_STATUS,
assert.equal(status.code, 1); );
assert.match(status.stderr, /matcher group must contain a hooks array/i); const otherAudit = legacyHandler(
path.join(fixture.homeDir, '.elydora', 'agent-10', 'hook.js'),
AUDIT_STATUS,
);
const lookalike = {
type: 'command',
command: `inspect ${fixture.guardScriptPath}`,
commandWindows: `inspect ${fixture.guardScriptPath}`,
timeout: 10,
statusMessage: GUARD_STATUS,
};
const modifiedGroup = {
matcher: 'Bash',
hooks: [structuredClone(managedHandler(config, 'PreToolUse', GUARD_STATUS).handler)],
};
config.hooks.PreToolUse.push(
{ matcher: '*', hooks: [otherGuard] },
{ matcher: '*', hooks: [lookalike] },
modifiedGroup,
);
config.hooks.PostToolUse.push({ matcher: '*', hooks: [otherAudit] });
await writeJson(fixture.configPath, config);
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.equal(remaining.hooks.SessionStart[0].hooks[0].command, 'keep');
assert.equal(remaining.hooks.PreToolUse.length, 3);
assert.equal(remaining.hooks.PostToolUse.length, 1);
assert.match(remaining.hooks.PreToolUse[0].hooks[0].command, /agent-10/);
assert.deepEqual(remaining.hooks.PreToolUse[1].hooks[0], lookalike);
assert.deepEqual(remaining.hooks.PreToolUse[2], { matcher: 'Bash', hooks: [] });
} finally { } finally {
await fixture.close(); await fixture.close();
} }
}); });
test('Codex uninstall removes a hooks config owned entirely by Elydora', async () => { test('Codex confines generated runtimes to the managed agent directory', async () => {
for (const field of ['guardScriptPath', 'hookScriptPath']) {
const fixture = await createFixture(); const fixture = await createFixture();
try { try {
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); const result = await runPlugin(fixture, 'install', installConfig(fixture, {
const uninstall = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1'); [field]: path.join(fixture.homeDir, `unmanaged-${field}.js`),
assert.equal(uninstall.code, 0, uninstall.stderr); }));
assert.equal(result.code, 1);
assert.match(result.stderr, /managed agent directory/i);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
} finally {
await fixture.close();
}
}
});
test('Codex validates runtime identity, credentials, and API origin before writes', async () => {
const invalidOverrides = [
{ agentName: 'cursor' },
{ privateKey: 'invalid' },
{ token: '' },
{ baseUrl: 'file:///tmp/elydora' },
{ baseUrl: 'https://user:secret@api.elydora.com' },
{ baseUrl: 'https://api.elydora.com?tenant=one' },
];
for (const overrides of invalidOverrides) {
const fixture = await createFixture();
try {
const result = await runPlugin(fixture, 'install', installConfig(fixture, overrides));
assert.equal(result.code, 1, JSON.stringify(overrides));
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
} finally {
await fixture.close();
}
}
});
test('Codex rejects linked hook and runtime files', async (t) => {
for (const kind of ['hooks', 'config', 'key', 'guard', 'audit']) {
const fixture = await createFixture();
try {
if (kind === 'hooks') {
const target = path.join(fixture.homeDir, 'hooks-target.json');
const original = '{"hooks":{}}\n';
await mkdir(path.dirname(fixture.configPath), { recursive: true });
await writeFile(target, original);
try {
await symlink(target, fixture.configPath);
} catch (error) {
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
}
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /physical file/i);
assert.equal(await readFile(target, 'utf-8'), original);
continue;
}
assert.equal((await fixture.install()).code, 0);
const filePath = {
config: path.join(fixture.agentDir, 'config.json'),
key: path.join(fixture.agentDir, 'private.key'),
guard: fixture.guardScriptPath,
audit: fixture.hookScriptPath,
}[kind];
const contents = await readFile(filePath);
const target = path.join(fixture.homeDir, `${kind}-target`);
await writeFile(target, contents);
await rm(filePath);
try {
await symlink(target, filePath);
} catch (error) {
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
}
const status = await runPlugin(fixture, 'status', null);
assert.equal(status.code, 1);
assert.match(status.stderr, /physical file/i);
} finally {
await fixture.close();
}
}
});
test('Codex removes an entirely managed file and leaves absent hooks absent', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
} finally { } finally {
await fixture.close(); await fixture.close();
} }
}); });
test('Codex install preserves invalid contract shapes before writing', async () => { test('Codex uninstall rejects a linked default hooks directory', async (t) => {
for (const existingSettings of [ const fixture = await createFixture();
{ hooks: null },
{ hooks: { PreToolUse: null } },
{ hooks: { PreToolUse: [{ hooks: null }] } },
]) {
const fixture = await createFixture({ existingSettings });
try { try {
assert.equal(fixture.installResult.code, 1); assert.equal((await fixture.install()).code, 0);
assert.deepEqual( const codexDirectory = path.dirname(fixture.configPath);
JSON.parse(await readFile(fixture.configPath, 'utf-8')), const target = path.join(fixture.rootDir, 'codex-hooks-target');
existingSettings, await rename(codexDirectory, target);
); try {
await symlink(target, codexDirectory, 'junction');
} catch (error) {
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
}
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
assert.equal(result.code, 1);
assert.match(result.stderr, /physical directory/i);
assert.match(await readFile(path.join(target, 'hooks.json'), 'utf-8'), /PreToolUse/);
} finally { } finally {
await fixture.close(); await fixture.close();
} }
}
}); });
test('Codex install preserves malformed config for recovery', async () => { test('Codex installation protects credentials and cleans transaction files', async () => {
const fixture = await createFixture({ existingSettings: '{ malformed' }); const fixture = await createFixture();
try { try {
assert.equal(fixture.installResult.code, 1); const result = await fixture.install();
assert.match(fixture.installResult.stderr, /parse Codex hooks config/i); assert.equal(result.code, 0, result.stderr);
assert.equal(await readFile(fixture.configPath, 'utf-8'), '{ malformed'); const names = [
...await readdir(fixture.agentDir),
...await readdir(path.dirname(fixture.configPath)),
];
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false);
if (process.platform !== 'win32') {
for (const filePath of [
fixture.configPath,
path.join(fixture.agentDir, 'config.json'),
path.join(fixture.agentDir, 'private.key'),
]) {
assert.equal((await stat(filePath)).mode & 0o777, 0o600);
}
for (const filePath of [fixture.guardScriptPath, fixture.hookScriptPath]) {
assert.equal((await stat(filePath)).mode & 0o777, 0o700);
}
}
} finally {
await fixture.close();
}
});
test('Codex CLI preflight preserves malformed hooks before runtime creation', async () => {
const fixture = await createFixture({ existingConfig: '{ malformed' });
try {
const privateKeyPath = path.join(fixture.rootDir, 'private.key');
await writeFile(privateKeyPath, VALID_PRIVATE_KEY, { mode: 0o600 });
const result = await runNode([
path.resolve('dist/cli.js'),
'install',
'--agent', 'codex',
'--org_id', 'org-1',
'--agent_id', 'agent-1',
'--private_key_file', privateKeyPath,
'--kid', 'kid-1',
'--base_url', fixture.baseUrl,
], {
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
}, fixture.projectDir);
assert.equal(result.code, 1);
assert.match(result.stderr, /Codex user hooks/i);
await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
} finally { } finally {
await fixture.close(); await fixture.close();
} }
+236
View File
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import { readFile, rm, symlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import {
createFixture,
managedHandler,
runHook,
startApiServer,
} from '../test-support/codex-test-helpers.mjs';
const GUARD_STATUS = 'Checking Elydora agent state';
const AUDIT_STATUS = 'Recording Elydora tool use';
function officialPayload(event, overrides = {}) {
return {
session_id: 'session-1',
transcript_path: null,
cwd: 'C:\\workspace',
hook_event_name: event,
model: 'gpt-5.4',
turn_id: 'turn-1',
permission_mode: 'default',
tool_name: 'Bash',
tool_use_id: 'call-1',
tool_input: { command: 'echo test' },
...overrides,
};
}
test('Codex runtimes enforce active state and preserve official event JSON', async () => {
const api = await startApiServer();
const fixture = await createFixture({ baseUrl: api.baseUrl });
try {
assert.equal((await fixture.install()).code, 0);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler;
const audit = managedHandler(config, 'PostToolUse', AUDIT_STATUS).handler;
const pre = officialPayload('PreToolUse');
const guardResult = await runHook(guard, JSON.stringify(pre), fixture);
assert.equal(guardResult.code, 0, guardResult.stderr);
assert.equal(guardResult.stdout, '');
assert.equal(api.requests[0].method, 'GET');
assert.equal(api.requests[0].url, '/v1/agents/agent-1');
assert.equal(api.requests[0].headers.authorization, 'Bearer token-1');
const success = officialPayload('PostToolUse', {
tool_response: { output: 'test', exit_code: 0 },
});
const successResult = await runHook(audit, JSON.stringify(success), fixture);
assert.equal(successResult.code, 0, successResult.stderr);
assert.equal(successResult.stdout, '');
const failure = officialPayload('PostToolUse', {
tool_use_id: 'call-2',
tool_input: { command: 'exit 7' },
tool_response: { output: '', stderr: 'failed', exit_code: 7 },
});
const failureResult = await runHook(audit, JSON.stringify(failure), fixture);
assert.equal(failureResult.code, 0, failureResult.stderr);
assert.equal(failureResult.stdout, '');
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, success);
assert.deepEqual(operations[1].payload, failure);
assert.equal(operations[0].subject.session_id, 'session-1');
assert.equal(operations[0].action.tool, 'Bash');
assert.equal(api.requests.at(-1).headers.authorization, 'Bearer token-1');
} finally {
await fixture.close();
await api.close();
}
});
test('Codex guard propagates exit code 2 for frozen and revoked agents', async () => {
for (const status of ['frozen', 'revoked']) {
const api = await startApiServer({ status });
const fixture = await createFixture({ baseUrl: api.baseUrl });
try {
assert.equal((await fixture.install()).code, 0);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler;
const result = await runHook(
guard,
JSON.stringify(officialPayload('PreToolUse')),
fixture,
);
assert.equal(result.code, 2);
assert.equal(result.stdout, '');
assert.match(result.stderr, new RegExp(`agent \\"codex\\" is ${status}`, 'i'));
} finally {
await fixture.close();
await api.close();
}
}
});
test('Codex fail-open guard reports malformed input, config, and status data', async () => {
const activeApi = await startApiServer();
const activeFixture = await createFixture({ baseUrl: activeApi.baseUrl });
try {
assert.equal((await activeFixture.install()).code, 0);
const config = JSON.parse(await readFile(activeFixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler;
const malformed = await runHook(guard, '{ malformed', activeFixture);
assert.equal(malformed.code, 0);
assert.match(malformed.stderr, /invalid JSON.*fail-open/i);
await rm(path.join(activeFixture.agentDir, 'config.json'));
const missingConfig = await runHook(
guard,
JSON.stringify(officialPayload('PreToolUse')),
activeFixture,
);
assert.equal(missingConfig.code, 0);
assert.match(missingConfig.stderr, /failed to read agent config/i);
} finally {
await activeFixture.close();
await activeApi.close();
}
const invalidApi = await startApiServer({ status: 'unexpected' });
const invalidFixture = await createFixture({ baseUrl: invalidApi.baseUrl });
try {
assert.equal((await invalidFixture.install()).code, 0);
const config = JSON.parse(await readFile(invalidFixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler;
const result = await runHook(
guard,
JSON.stringify(officialPayload('PreToolUse')),
invalidFixture,
);
assert.equal(result.code, 0);
assert.match(result.stderr, /invalid agent status/i);
} finally {
await invalidFixture.close();
await invalidApi.close();
}
});
test('Codex fail-open audit 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 = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const audit = managedHandler(config, 'PostToolUse', AUDIT_STATUS).handler;
const malformed = await runHook(audit, '{ malformed', fixture);
assert.equal(malformed.code, 0);
let log = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8');
assert.match(log, /Hook input is invalid JSON/i);
const failedApi = await runHook(
audit,
JSON.stringify(officialPayload('PostToolUse', { tool_response: { exit_code: 0 } })),
fixture,
);
assert.equal(failedApi.code, 0);
log = await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8');
assert.match(log, /Audit API returned HTTP 503/i);
await assert.rejects(readFile(path.join(fixture.agentDir, 'chain-state.json')), {
code: 'ENOENT',
});
} finally {
await fixture.close();
await api.close();
}
});
test('Codex runtimes reject linked cache, chain, and error state', async (t) => {
const api = await startApiServer();
const fixture = await createFixture({ baseUrl: api.baseUrl });
try {
assert.equal((await fixture.install()).code, 0);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'PreToolUse', GUARD_STATUS).handler;
const audit = managedHandler(config, 'PostToolUse', AUDIT_STATUS).handler;
const cacheTarget = path.join(fixture.rootDir, 'cache-target');
const cachePath = path.join(fixture.agentDir, 'status-cache.json');
await writeFile(cacheTarget, 'cache sentinel', { mode: 0o600 });
try {
await symlink(cacheTarget, cachePath);
} catch (error) {
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
}
const guardResult = await runHook(
guard,
JSON.stringify(officialPayload('PreToolUse')),
fixture,
);
assert.equal(guardResult.code, 0);
assert.match(guardResult.stderr, /Status cache path is not a physical file/i);
assert.equal(await readFile(cacheTarget, 'utf-8'), 'cache sentinel');
await rm(cachePath);
const chainTarget = path.join(fixture.rootDir, 'chain-target');
const chainPath = path.join(fixture.agentDir, 'chain-state.json');
await writeFile(chainTarget, 'chain sentinel', { mode: 0o600 });
await symlink(chainTarget, chainPath);
const auditResult = await runHook(
audit,
JSON.stringify(officialPayload('PostToolUse', { tool_response: { exit_code: 0 } })),
fixture,
);
assert.equal(auditResult.code, 0);
assert.equal(await readFile(chainTarget, 'utf-8'), 'chain sentinel');
assert.match(
await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'),
/Chain state path is not a physical file/i,
);
await rm(chainPath);
const errorTarget = path.join(fixture.rootDir, 'error-target');
const errorPath = path.join(fixture.agentDir, 'error.log');
await writeFile(errorTarget, 'error sentinel', { mode: 0o600 });
await rm(errorPath);
await symlink(errorTarget, errorPath);
const errorResult = await runHook(audit, '{ malformed', fixture);
assert.equal(errorResult.code, 0);
assert.match(errorResult.stderr, /Error log path is not a physical file/i);
assert.equal(await readFile(errorTarget, 'utf-8'), 'error sentinel');
} finally {
await fixture.close();
await api.close();
}
});
+238
View File
@@ -0,0 +1,238 @@
import assert from 'node:assert/strict';
import {
lstat,
mkdir,
readFile,
readdir,
symlink,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import {
createFixture,
installationModuleUrl,
installConfig,
ioModuleUrl,
runNode,
writeJson,
} from '../test-support/codex-test-helpers.mjs';
const managedFileNames = ['guard.js', 'config.json', 'private.key', 'hook.js'];
async function snapshotInstallation(fixture) {
const entries = await Promise.all(managedFileNames.map(async (name) => [
path.join(fixture.agentDir, name),
await readFile(path.join(fixture.agentDir, name), 'utf-8'),
]));
entries.push([fixture.configPath, await readFile(fixture.configPath, 'utf-8')]);
return new Map(entries);
}
async function assertSnapshot(snapshot) {
for (const [filePath, expected] of snapshot) {
assert.equal(await readFile(filePath, 'utf-8'), expected, filePath);
}
}
async function assertNoTransactionFiles(fixture) {
const names = [
...await readdir(fixture.agentDir),
...await readdir(path.dirname(fixture.configPath)),
];
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false);
}
test('Codex rolls back all five files when a transaction commit fails', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const source = `
import { rename } from 'node:fs/promises';
import { readDocument } from ${JSON.stringify(ioModuleUrl)};
import {
commitCodexInstallation,
prepareCodexInstallation,
} from ${JSON.stringify(installationModuleUrl)};
const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG);
const document = await readDocument();
const prepared = await prepareCodexInstallation(config, { document, changed: false });
let calls = 0;
await commitCodexInstallation(prepared, async (from, to) => {
calls += 1;
if (calls === 3) throw new Error('injected rename failure');
await rename(from, to);
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, {
orgId: 'org-updated',
token: 'token-updated',
})),
},
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /injected rename failure/i);
await assertSnapshot(before);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Codex detects concurrent hook changes before committing runtime updates', async () => {
const fixture = await createFixture({
existingConfig: {
hooks: { SessionStart: [{ matcher: 'startup', hooks: [{ command: 'original' }] }] },
},
});
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const concurrent = '{"hooks":{"SessionStart":[{"matcher":"resume","hooks":[{"command":"concurrent"}]}]}}\n';
const source = `
import { writeFile } from 'node:fs/promises';
import { readDocument } from ${JSON.stringify(ioModuleUrl)};
import {
commitCodexInstallation,
prepareCodexInstallation,
} from ${JSON.stringify(installationModuleUrl)};
const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG);
const document = await readDocument();
const prepared = await prepareCodexInstallation(config, { document, changed: false });
await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8');
await commitCodexInstallation(prepared);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
ELYDORA_CONCURRENT: concurrent,
ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })),
},
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /changed during Codex installation/i);
for (const [filePath, expected] of before) {
if (filePath !== fixture.configPath) assert.equal(await readFile(filePath, 'utf-8'), expected);
}
assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Codex rejects a stale hook document before staging files', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const before = await snapshotInstallation(fixture);
const concurrent = '{"hooks":{"SessionStart":[]}}\n';
const source = `
import { writeFile } from 'node:fs/promises';
import { readDocument } from ${JSON.stringify(ioModuleUrl)};
import { prepareCodexInstallation } from ${JSON.stringify(installationModuleUrl)};
const document = await readDocument();
await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8');
await prepareCodexInstallation(
JSON.parse(process.env.ELYDORA_INSTALL_CONFIG),
{ document, changed: false },
);
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
ELYDORA_CONCURRENT: concurrent,
ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture)),
},
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /changed before installation/i);
for (const [filePath, expected] of before) {
if (filePath !== fixture.configPath) assert.equal(await readFile(filePath, 'utf-8'), expected);
}
assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent);
await assertNoTransactionFiles(fixture);
} finally {
await fixture.close();
}
});
test('Codex rejects orphaned runtime artifacts before writing hooks', async () => {
const fixture = await createFixture();
try {
await mkdir(fixture.agentDir, { recursive: true });
await writeFile(fixture.guardScriptPath, 'orphaned guard\n');
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /identity cannot be verified/i);
assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n');
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
} finally {
await fixture.close();
}
});
test('Codex rejects mismatched runtime identity before changing files', async () => {
const fixture = await createFixture();
try {
await writeJson(path.join(fixture.agentDir, 'config.json'), {
agent_name: 'codex',
agent_id: 'another-agent',
});
const configPath = path.join(fixture.agentDir, 'config.json');
const before = await readFile(configPath, 'utf-8');
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /identity does not match/i);
assert.equal(await readFile(configPath, 'utf-8'), before);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
} finally {
await fixture.close();
}
});
test('Codex rejects linked hooks and runtime directories before writes', async (t) => {
for (const kind of ['codex', 'runtime']) {
const fixture = await createFixture();
try {
const target = path.join(fixture.rootDir, `${kind}-target`);
await mkdir(target, { recursive: true });
const link = kind === 'codex'
? path.join(fixture.homeDir, '.codex')
: path.join(fixture.homeDir, '.elydora');
await mkdir(fixture.homeDir, { recursive: true });
try {
await symlink(target, link, 'junction');
} catch (error) {
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
}
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /physical directory/i);
assert.deepEqual(await readdir(target), []);
const untouched = kind === 'codex'
? path.join(fixture.homeDir, '.elydora')
: fixture.configPath;
await assert.rejects(lstat(untouched), { code: 'ENOENT' });
} finally {
await fixture.close();
}
}
});
+24 -2
View File
@@ -14,6 +14,7 @@ async function createFixture(status) {
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-guard-')); const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-guard-'));
const agentDir = path.join(homeDir, '.elydora', AGENT_ID); const agentDir = path.join(homeDir, '.elydora', AGENT_ID);
await mkdir(agentDir, { recursive: true }); await mkdir(agentDir, { recursive: true });
const server = http.createServer((_request, response) => { const server = http.createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' }); response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ agent: { status } })); response.end(JSON.stringify({ agent: { status } }));
@@ -21,12 +22,15 @@ async function createFixture(status) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address(); const address = server.address();
assert(address && typeof address === 'object'); assert(address && typeof address === 'object');
await writeFile( await writeFile(
path.join(agentDir, 'config.json'), path.join(agentDir, 'config.json'),
JSON.stringify({ agent_id: AGENT_ID, base_url: `http://127.0.0.1:${address.port}` }), JSON.stringify({ agent_id: AGENT_ID, base_url: `http://127.0.0.1:${address.port}` }),
); );
const scriptPath = path.join(homeDir, 'guard.cjs'); const scriptPath = path.join(homeDir, 'guard.cjs');
await writeFile(scriptPath, generateGuardScript('claudecode', AGENT_ID)); await writeFile(scriptPath, generateGuardScript('claudecode', AGENT_ID));
return { return {
homeDir, homeDir,
scriptPath, scriptPath,
@@ -39,11 +43,11 @@ async function createFixture(status) {
}; };
} }
function runGuard(scriptPath, homeDir) { function runGuard(scriptPath, homeDir, input = '{}') {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [scriptPath], { const child = spawn(process.execPath, [scriptPath], {
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@@ -51,6 +55,7 @@ function runGuard(scriptPath, homeDir) {
child.stderr.on('data', (chunk) => { stderr += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject); child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr })); child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
}); });
} }
@@ -58,6 +63,7 @@ test('frozen agents use the blocking exit code required by hook CLIs', async ()
const fixture = await createFixture('frozen'); const fixture = await createFixture('frozen');
try { try {
const result = await runGuard(fixture.scriptPath, fixture.homeDir); const result = await runGuard(fixture.scriptPath, fixture.homeDir);
assert.equal(result.code, 2); assert.equal(result.code, 2);
assert.match(result.stderr, /Tool execution blocked/); assert.match(result.stderr, /Tool execution blocked/);
assert.equal(result.stdout, ''); assert.equal(result.stdout, '');
@@ -70,6 +76,7 @@ test('active agents allow tool execution', async () => {
const fixture = await createFixture('active'); const fixture = await createFixture('active');
try { try {
const result = await runGuard(fixture.scriptPath, fixture.homeDir); const result = await runGuard(fixture.scriptPath, fixture.homeDir);
assert.equal(result.code, 0); assert.equal(result.code, 0);
assert.equal(result.stderr, ''); assert.equal(result.stderr, '');
assert.equal(result.stdout, ''); assert.equal(result.stdout, '');
@@ -85,7 +92,9 @@ test('cached frozen status keeps the blocking exit code', async () => {
path.join(fixture.homeDir, '.elydora', AGENT_ID, 'status-cache.json'), path.join(fixture.homeDir, '.elydora', AGENT_ID, 'status-cache.json'),
JSON.stringify({ status: 'frozen', cached_at: Date.now() }), JSON.stringify({ status: 'frozen', cached_at: Date.now() }),
); );
const result = await runGuard(fixture.scriptPath, fixture.homeDir); const result = await runGuard(fixture.scriptPath, fixture.homeDir);
assert.equal(result.code, 2); assert.equal(result.code, 2);
assert.match(result.stderr, /Tool execution blocked/); assert.match(result.stderr, /Tool execution blocked/);
assert.equal(result.stdout, ''); assert.equal(result.stdout, '');
@@ -93,3 +102,16 @@ test('cached frozen status keeps the blocking exit code', async () => {
await fixture.close(); await fixture.close();
} }
}); });
test('fail-open guards report malformed hook input', async () => {
const fixture = await createFixture('active');
try {
const result = await runGuard(fixture.scriptPath, fixture.homeDir, '{ malformed');
assert.equal(result.code, 0);
assert.match(result.stderr, /invalid JSON.*fail-open/i);
assert.equal(result.stdout, '');
} finally {
await fixture.close();
}
});
+305
View File
@@ -0,0 +1,305 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const root = new URL('../', import.meta.url);
async function readJson(path) {
return JSON.parse(await readFile(new URL(path, root), 'utf8'));
}
test('high-drift providers retain their verified hook contracts', async () => {
const catalog = await readJson('integrations/catalog.json');
const providers = new Map(catalog.providers.map((provider) => [provider.id, provider]));
assert.deepEqual(providers.get('augment').config_paths, [
'/etc/augment/settings.json',
'C:\\ProgramData\\Augment\\settings.json',
'.augment/settings.local.json',
'.augment/settings.json',
'~/.augment/settings.json',
]);
assert.deepEqual(providers.get('augment').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('augment').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'conversation_id',
});
assert.deepEqual(providers.get('augment').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('codex').config_paths, [
'$CODEX_HOME/hooks.json',
'~/.codex/hooks.json',
'.codex/hooks.json',
]);
assert.deepEqual(providers.get('codex').contract_variants, [
{
id: 'inlinetoml',
release_channel: 'stable',
activation: 'Hooks configured inline in an active Codex config.toml or managed requirements.toml layer',
config_format: 'toml',
config_paths: [
'$CODEX_HOME/config.toml',
'~/.codex/config.toml',
'.codex/config.toml',
'requirements.toml',
],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://learn.chatgpt.com/docs/hooks',
},
{
id: 'pluginbundle',
release_channel: 'stable',
activation: 'An enabled Codex plugin supplies hooks through its manifest or default hooks file',
config_format: 'plugin',
config_paths: [
'.codex-plugin/plugin.json',
'hooks/hooks.json',
],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://learn.chatgpt.com/docs/hooks',
},
]);
assert.deepEqual(providers.get('copilot').config_paths, [
'$COPILOT_HOME/hooks/*.json',
'~/.copilot/hooks/*.json',
'.github/hooks/*.json',
]);
assert.deepEqual(providers.get('copilot').events, {
before_tool: 'preToolUse',
after_tool: 'postToolUse',
});
assert.deepEqual(providers.get('copilot').event_fields, {
name: 'toolName',
input: 'toolArgs',
session: 'sessionId',
});
assert.deepEqual(providers.get('copilot').blocking, {
mechanism: 'any_nonzero_exit',
failure_mode: 'fail_closed',
timeout_failure_mode: 'fail_open',
});
assert.equal(providers.get('cursor').surface, 'cli_and_ide');
assert.deepEqual(providers.get('cursor').config_paths, [
'/Library/Application Support/Cursor/hooks.json',
'/etc/cursor/hooks.json',
'C:\\ProgramData\\Cursor\\hooks.json',
'.cursor/hooks.json',
'~/.cursor/hooks.json',
]);
assert.deepEqual(providers.get('cursor').events, {
before_tool: 'preToolUse',
after_tool: 'postToolUse',
});
assert.deepEqual(providers.get('cursor').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'conversation_id',
});
assert.deepEqual(providers.get('cursor').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'configurable',
});
assert.deepEqual(providers.get('droid').config_paths, [
'~/.factory/hooks.json',
'~/.factory/settings.json',
'.factory/hooks.json',
'.factory/settings.json',
]);
assert.deepEqual(providers.get('droid').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('droid').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
});
assert.deepEqual(providers.get('droid').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('droid').contract_variants, [
{
id: 'nestedhooks',
release_channel: 'legacy',
activation: 'An existing nested hooks file remains active until Droid saves and archives it',
config_format: 'json',
config_paths: [
'~/.factory/hooks/hooks.json',
'.factory/hooks/hooks.json',
],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://docs.factory.ai/reference/hooks-reference',
},
]);
assert.equal(providers.get('cline').integration_mode, 'command_hooks');
assert.equal(providers.get('cline').config_format, 'script');
assert.deepEqual(providers.get('cline').config_paths, [
'~/Documents/Cline/Hooks/',
'$CLINE_DIR/hooks/',
'.clinerules/hooks/',
'.cline/hooks/',
]);
assert.deepEqual(providers.get('cline').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('cline').event_fields, {
name: 'tool_call.name/tool_result.name',
input: 'tool_call.input/tool_result.input',
session: 'taskId',
});
assert.deepEqual(providers.get('cline').blocking, {
mechanism: 'json_stdout_cancel',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('kimi').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.equal(providers.get('kimi').config_format, 'toml');
assert.deepEqual(providers.get('kimi').config_paths, [
'$KIMI_CODE_HOME/config.toml',
'~/.kimi-code/config.toml',
]);
assert.deepEqual(providers.get('kimi').contract_variants, [
{
id: 'pythoncli',
release_channel: 'legacy',
activation: 'kimi-cli',
config_format: 'toml',
config_paths: ['~/.kimi/config.toml'],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://moonshotai.github.io/kimi-cli/en/customization/hooks.html',
},
]);
assert.deepEqual(providers.get('grok').event_fields, {
name: 'toolName',
input: 'toolInput',
session: 'sessionId',
});
assert.deepEqual(providers.get('grok').config_paths, [
'$GROK_HOME/hooks/*.json',
'~/.grok/hooks/*.json',
'.grok/hooks/*.json',
]);
assert.deepEqual(providers.get('grok').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('grok').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('kirocli').config_paths, [
'~/.kiro/agents/*.json',
'.kiro/agents/*.json',
]);
assert.deepEqual(providers.get('kirocli').contract_variants, [
{
id: 'v3',
release_channel: 'early_access',
activation: 'kiro-cli --v3',
config_format: 'json',
config_paths: ['~/.kiro/hooks/*.json', '.kiro/hooks/*.json'],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://kiro.dev/docs/cli/v3/hooks/',
},
]);
assert.deepEqual(providers.get('kiroide').config_paths, [
'.kiro/hooks/*.json',
]);
assert.deepEqual(providers.get('kiroide').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('kiroide').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.equal(providers.get('qwen').config_format, 'json');
assert.deepEqual(providers.get('qwen').config_paths, [
'$QWEN_HOME/settings.json',
'~/.qwen/settings.json',
'.qwen/settings.json',
]);
assert.deepEqual(providers.get('qwen').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('qwen').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
});
assert.deepEqual(providers.get('qwen').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
});
-294
View File
@@ -248,297 +248,3 @@ test('adapter delivery claims resolve to source files in every SDK mirror', asyn
} }
} }
}); });
test('high-drift providers retain their verified hook contracts', async () => {
const catalog = await readJson('integrations/catalog.json');
const providers = new Map(catalog.providers.map((provider) => [provider.id, provider]));
assert.deepEqual(providers.get('augment').config_paths, [
'/etc/augment/settings.json',
'C:\\ProgramData\\Augment\\settings.json',
'.augment/settings.local.json',
'.augment/settings.json',
'~/.augment/settings.json',
]);
assert.deepEqual(providers.get('augment').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('augment').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'conversation_id',
});
assert.deepEqual(providers.get('augment').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('codex').config_paths, [
'~/.codex/hooks.json',
'.codex/hooks.json',
]);
assert.deepEqual(providers.get('codex').contract_variants, [
{
id: 'inlinetoml',
release_channel: 'stable',
activation: 'Hooks configured inline in an active Codex config.toml or managed requirements.toml layer',
config_format: 'toml',
config_paths: [
'~/.codex/config.toml',
'.codex/config.toml',
'requirements.toml',
],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://learn.chatgpt.com/docs/hooks',
},
{
id: 'pluginbundle',
release_channel: 'stable',
activation: 'An enabled Codex plugin supplies hooks through its manifest or default hooks file',
config_format: 'plugin',
config_paths: [
'.codex-plugin/plugin.json',
'hooks/hooks.json',
],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://learn.chatgpt.com/docs/hooks',
},
]);
assert.deepEqual(providers.get('copilot').config_paths, [
'$COPILOT_HOME/hooks/*.json',
'~/.copilot/hooks/*.json',
'.github/hooks/*.json',
]);
assert.deepEqual(providers.get('copilot').events, {
before_tool: 'preToolUse',
after_tool: 'postToolUse',
});
assert.deepEqual(providers.get('copilot').event_fields, {
name: 'toolName',
input: 'toolArgs',
session: 'sessionId',
});
assert.deepEqual(providers.get('copilot').blocking, {
mechanism: 'any_nonzero_exit',
failure_mode: 'fail_closed',
timeout_failure_mode: 'fail_open',
});
assert.equal(providers.get('cursor').surface, 'cli_and_ide');
assert.deepEqual(providers.get('cursor').config_paths, [
'/Library/Application Support/Cursor/hooks.json',
'/etc/cursor/hooks.json',
'C:\\ProgramData\\Cursor\\hooks.json',
'.cursor/hooks.json',
'~/.cursor/hooks.json',
]);
assert.deepEqual(providers.get('cursor').events, {
before_tool: 'preToolUse',
after_tool: 'postToolUse',
});
assert.deepEqual(providers.get('cursor').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'conversation_id',
});
assert.deepEqual(providers.get('cursor').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'configurable',
});
assert.deepEqual(providers.get('droid').config_paths, [
'~/.factory/hooks.json',
'~/.factory/settings.json',
'.factory/hooks.json',
'.factory/settings.json',
]);
assert.deepEqual(providers.get('droid').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('droid').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
});
assert.deepEqual(providers.get('droid').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('droid').contract_variants, [
{
id: 'nestedhooks',
release_channel: 'legacy',
activation: 'An existing nested hooks file remains active until Droid saves and archives it',
config_format: 'json',
config_paths: [
'~/.factory/hooks/hooks.json',
'.factory/hooks/hooks.json',
],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://docs.factory.ai/reference/hooks-reference',
},
]);
assert.equal(providers.get('cline').integration_mode, 'command_hooks');
assert.equal(providers.get('cline').config_format, 'script');
assert.deepEqual(providers.get('cline').config_paths, [
'~/Documents/Cline/Hooks/',
'$CLINE_DIR/hooks/',
'.clinerules/hooks/',
'.cline/hooks/',
]);
assert.deepEqual(providers.get('cline').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('cline').event_fields, {
name: 'tool_call.name/tool_result.name',
input: 'tool_call.input/tool_result.input',
session: 'taskId',
});
assert.deepEqual(providers.get('cline').blocking, {
mechanism: 'json_stdout_cancel',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('kimi').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.equal(providers.get('kimi').config_format, 'toml');
assert.deepEqual(providers.get('kimi').config_paths, [
'$KIMI_CODE_HOME/config.toml',
'~/.kimi-code/config.toml',
]);
assert.deepEqual(providers.get('kimi').contract_variants, [
{
id: 'pythoncli',
release_channel: 'legacy',
activation: 'kimi-cli',
config_format: 'toml',
config_paths: ['~/.kimi/config.toml'],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://moonshotai.github.io/kimi-cli/en/customization/hooks.html',
},
]);
assert.deepEqual(providers.get('grok').event_fields, {
name: 'toolName',
input: 'toolInput',
session: 'sessionId',
});
assert.deepEqual(providers.get('grok').config_paths, [
'$GROK_HOME/hooks/*.json',
'~/.grok/hooks/*.json',
'.grok/hooks/*.json',
]);
assert.deepEqual(providers.get('grok').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('grok').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.deepEqual(providers.get('kirocli').config_paths, [
'~/.kiro/agents/*.json',
'.kiro/agents/*.json',
]);
assert.deepEqual(providers.get('kirocli').contract_variants, [
{
id: 'v3',
release_channel: 'early_access',
activation: 'kiro-cli --v3',
config_format: 'json',
config_paths: ['~/.kiro/hooks/*.json', '.kiro/hooks/*.json'],
events: {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
},
event_fields: {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
},
blocking: {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
},
source_url: 'https://kiro.dev/docs/cli/v3/hooks/',
},
]);
assert.deepEqual(providers.get('kiroide').config_paths, [
'.kiro/hooks/*.json',
]);
assert.deepEqual(providers.get('kiroide').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('kiroide').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
assert.equal(providers.get('qwen').config_format, 'json');
assert.deepEqual(providers.get('qwen').config_paths, [
'$QWEN_HOME/settings.json',
'~/.qwen/settings.json',
'.qwen/settings.json',
]);
assert.deepEqual(providers.get('qwen').events, {
before_tool: 'PreToolUse',
after_tool: 'PostToolUse',
});
assert.deepEqual(providers.get('qwen').event_fields, {
name: 'tool_name',
input: 'tool_input',
session: 'session_id',
});
assert.deepEqual(providers.get('qwen').blocking, {
mechanism: 'exit_code_2',
failure_mode: 'fail_open',
});
});