Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528081bb88 | ||
|
|
6b0e8b8f77 | ||
|
|
a63c360539 | ||
|
|
89bdd6e3b8 | ||
|
|
909691328e | ||
|
|
eb95173015 | ||
|
|
9e1c0d9eb0 | ||
|
|
90bea63eea | ||
|
|
25bfa39fcf | ||
|
|
3b5cbd6e17 | ||
|
|
db4a6ffb62 | ||
|
|
81ecb372ba | ||
|
|
b7f14ba34b | ||
|
|
07f32a48d7 | ||
|
|
2101cb77e3 | ||
|
|
85c2d2d491 | ||
|
|
f371d8fc89 | ||
|
|
2defbeeaaa | ||
|
|
cb978d8fb2 | ||
|
|
9591a6e655 | ||
|
|
53b08ec4a7 | ||
|
|
69a75153f5 | ||
|
|
3f43f4aad5 | ||
|
|
5889b09a09 | ||
|
|
3cfd0a5187 | ||
|
|
590de9dbdb | ||
|
|
2f988e72ba | ||
|
|
cf77d49a3c | ||
|
|
65b60b1eba | ||
|
|
323757e43d | ||
|
|
b59404cd33 | ||
|
|
586ffa0278 | ||
|
|
e011003680 | ||
|
|
e2d8351f36 | ||
|
|
d8d4e8d336 | ||
|
|
9290c1c6f1 | ||
|
|
d8bff04380 | ||
|
|
a9adba1fc1 | ||
|
|
6a7fdb7595 | ||
|
|
6d36f557b1 | ||
|
|
d5a2dcbe53 | ||
|
|
d2794c8fbb | ||
|
|
188b70655d | ||
|
|
924bed6c21 | ||
|
|
856ae5313e | ||
|
|
c78df90361 | ||
|
|
0e35b845a4 | ||
|
|
d59b0101b1 | ||
|
|
3999ed0f44 | ||
|
|
66c5ab00a9 | ||
|
|
a69545d50f | ||
|
|
9b7b7f5959 | ||
|
|
b2c4b4cdb4 | ||
|
|
05f3aa1f0b | ||
|
|
eb4fc69185 | ||
|
|
2214c00b94 | ||
|
|
51ed21a115 | ||
|
|
212fb61e83 | ||
|
|
f0e82b46fb |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
src/
|
||||
tsconfig.json
|
||||
.gitignore
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,79 @@
|
||||
# Elydora Node SDK Engineering Contract
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the published `@elydora/sdk` package, its `elydora` CLI, local signing behavior, API client, generated hook runtime, and Node-specific agent adapters.
|
||||
|
||||
## Integration Sources
|
||||
|
||||
- Verify every agent hook contract against current official provider documentation before changing an adapter.
|
||||
- Treat `../Elydora-Open-Source/integrations/catalog.json` as the cross-product provider inventory.
|
||||
- Keep the exported `INTEGRATION_TYPES` tuple aligned with that inventory, and require `integration_type` in every agent registration request.
|
||||
- Keep adapter delivery claims aligned with executable tests in this repository.
|
||||
- Synchronize completed provider behavior into the Python SDK, Go SDK, Open Source distribution, Console, Docs, and landing page through separate reviewed commits.
|
||||
|
||||
## Hook Adapter Invariants
|
||||
|
||||
- Preserve unrelated user configuration and remove only Elydora-owned entries.
|
||||
- Parse every affected user configuration before the first write.
|
||||
- Surface malformed or unreadable configuration with contextual errors and leave the original file intact.
|
||||
- Write configuration through a same-directory temporary file followed by an atomic rename.
|
||||
- Quote the current Node executable and generated script paths for the host shell.
|
||||
- Forward official hook JSON from STDIN without reshaping provider fields.
|
||||
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
|
||||
- Report installation as healthy only when a complete hook contract references every required event and both generated runtime scripts, runtime config, and private key exist as physical files.
|
||||
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
|
||||
- Resolve Codex user hooks through `CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Preserve user TOML, project, plugin, and managed hook sources because Codex composes them additively. Register exact `PreToolUse` and `PostToolUse` matcher groups with ten-second command handlers, preserve the complete native payload, propagate freeze and revocation through exit code `2`, and keep guard lookup and audit delivery fail-open with observable errors. Commit Codex's user hooks, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Use `/hooks` to approve both definition hashes after installation.
|
||||
- Resolve stable Kimi hooks through `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and treat an empty override as the default. Activate `~/.kimi/config.toml` only when the legacy home exists. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules with ten-second commands; stable Kimi supports its current sixteen-event schema and legacy kimi-cli supports its thirteen-event schema. Preserve the native snake_case payload, propagate exit code `2`, and keep audit delivery fail-open with owner-only error logs. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit every detected config, generated runtime, runtime config, and private key through one rollback-capable transaction.
|
||||
- Resolve Grok Build user hooks through `$GROK_HOME/hooks/*.json`, with `~/.grok/hooks/*.json` selected when the variable is absent. Reject an empty `GROK_HOME` and direct the operator to unset it or provide an absolute home directory; Grok Build 0.2.106 otherwise resolves the empty value to cwd-relative `hooks`. Keep Claude Code, Cursor, project `.grok/hooks`, plugin, and `hooks-paths` sources read-only. Reject matcher-bearing `SessionStart`, `SessionEnd`, `Stop`, and `UserPromptSubmit` groups because the stable loader drops them; preserve matchers on the remaining supported events. Accept a null handler `env` as an empty map. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second command handlers, preserve the complete native camelCase payload, and emit Grok's documented deny JSON plus exit code `2` for frozen or revoked agents. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit Grok's hook file, generated runtimes, runtime config, and private key through one rollback-capable transaction. Verify discovery with `grok inspect --json` and the in-session `/hooks` view.
|
||||
- Resolve Claude Code user hooks through `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` when the variable is absent. Match Claude Code's path resolution exactly: relative and empty values resolve from the current working directory, and literal tildes remain path segments. Keep project, local, managed, plugin, skill, and agent hook sources read-only. Validate the complete shipped handler schema, including command exec form, async rewake metadata, HTTP, MCP tool, prompt, and agent handlers. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second exec-form command handlers. Preserve the complete native snake_case payload and propagate freeze and revocation through exit code `2`. Commit Claude Code's user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject installation while the user source sets `disableAllHooks`, then require `/hooks` and `claude doctor` verification for higher-scope policy effects.
|
||||
- Resolve Gemini CLI user hooks through `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` when the variable is absent or empty. Preserve relative and literal-tilde home values as Gemini CLI does. Keep workspace, system defaults, system overrides, and extension hooks read-only. Preserve JSON comments while rejecting trailing commas and duplicate keys. Register exact matchless `BeforeTool` and `AfterTool` groups named `elydora-guard` and `elydora-audit` with ten-second command handlers. Preserve the complete native snake_case payload, emit valid JSON on exit code `0`, and propagate freeze and revocation through exit code `2`. Use encoded PowerShell commands on Windows. Commit Gemini CLI's user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Respect `hooksConfig.enabled` and `hooksConfig.disabled`, then require `/hooks list` verification for higher-scope policy effects.
|
||||
- Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`. Commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact runtime identity, canonical private keys, physical files, and exact wrapper sources during status checks, then verify the effective configuration with `auggie tools list`.
|
||||
- Resolve Cline 3 file hooks through `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents, `.clinerules/hooks`, and workspace `.cline/hooks` sources read-only. Install exact `PreToolUse.mjs` and `PostToolUse.mjs` files for the CLI `tool_call` and `tool_result` events, preserve the complete hybrid native payload byte-for-byte, and translate guard exit code `2` into a one-line JSON stdout cancellation control. Commit both hook files, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact wrapper and runtime sources, canonical private keys, physical files, and matching runtime identity during status checks, then verify discovery with the current official `@cline/core` loader.
|
||||
- Resolve GitHub Copilot CLI user hooks through `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default, and treat an empty override as the default. Keep policy, repository, inline settings, cross-tool Claude, and plugin hook sources read-only. Evaluate `disableAllHooks` through legacy user config, user settings, Claude repository and local settings, then GitHub repository and local settings; the managed hook file flag remains independently authoritative. Register exact `preToolUse`, `postToolUse`, and `postToolUseFailure` command handlers with ten-second timeouts, preserve the complete native camelCase payload, and propagate frozen or revoked state through exit code `2`. Commit the user hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical source paths, strict hook schemas, exact runtime sources, canonical private keys, and matching runtime identity during status checks, then verify discovery with the current official Copilot native hook loader and restart active CLI sessions.
|
||||
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler and propagate guard exit code `2` through the host shell.
|
||||
- Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, and require `/hooks` review for the session snapshot and remote organization policy.
|
||||
- Resolve Qwen Code 0.20.0 user settings through explicit `QWEN_HOME`, then the initial Qwen home `.env`, then the OS home `.env`; when bootstrap discovers a redirected home, read that home's `.env` as well. Treat empty overrides as unset while preserving their process-level ownership semantics. Read system defaults, user settings, the active trusted workspace, system overrides, and trusted-folder rules with Qwen's documented precedence; write only the user source. Validate the current twenty-one event names and command, HTTP, and prompt handler schemas while preserving future event payloads. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups named `elydora-guard` and `elydora-audit` with ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate freeze and revocation through exit code `2`, and use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows. Commit user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction, with snapshot preconditions for every consulted routing, policy, trust, and settings source. Require physical files, exact runtime sources, canonical private keys, matching runtime identity, an effective enabled state, `/hooks` review, and restart of active Qwen Code sessions.
|
||||
- Resolve Kiro IDE 1.0 hooks through the current workspace `.kiro/hooks/elydora-audit.json`. Register exact `PreToolUse` and `PostToolUse` command entries in the v1 JSON schema with `.*` matchers, ten-second timeouts, and enabled state. Preserve Kiro's complete native payload and propagate frozen or revoked state through exit code `2`. Use the user-level `~/.kiro/hooks/elydora-audit.kiro.hook` source solely as exact Elydora legacy migration input. Commit the workspace hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require a physical workspace hook path, strict schemas, exact runtime sources, canonical private keys, matching runtime identity, and a complete enabled contract during status checks, then verify both entries in the Agent Hooks panel.
|
||||
- Resolve Kiro CLI v2 through the global `~/.kiro/agents/elydora-audit.json` custom agent and CLI 2.13.0+ v3 through `~/.kiro/hooks/elydora-audit.json`. Register exact lower-camel `preToolUse` and `postToolUse` v2 handlers with `*` matchers and ten-second millisecond timeouts; register exact PascalCase `PreToolUse` and `PostToolUse` v3 handlers in Kiro's v1 schema with `.*` matchers and ten-second timeouts. Preserve the complete native payload, propagate frozen or revoked state through exit code `2`, and use encoded PowerShell commands on Windows. Commit both hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject path and reserved-name collisions, migrate only exact Elydora command ownership, and require physical sources, strict schemas, canonical private keys, exact runtime sources, matching identity, and a common complete v2/v3 contract during status checks. Validate v2 with `kiro-cli agent validate --path` and activate v3 with `kiro-cli --v3`.
|
||||
- Resolve OpenCode 1.18.4 global plugins through `${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/elydora-audit.js`; require an absolute `XDG_CONFIG_HOME` and use the official `.js` or `.ts` discovery contract. Export exactly one named `ElydoraAuditPlugin` function. Implement `tool.execute.before` with official `{ tool, sessionID, callID }` input plus `{ args }` output and `tool.execute.after` with official `{ tool, sessionID, callID, args }` input plus `{ title, output, metadata }` output. Preserve both native arguments. Run the guard synchronously with a bounded timeout and propagate frozen or revoked state by throwing; await the audit runtime with a bounded timeout and report failures to stderr after tool completion. Embed the current absolute Node executable in the generated plugin. Commit the plugin, exact legacy `.mjs` migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical paths, strict metadata, exact generated sources, canonical private keys, executable identity, matching runtime identity, and a clean migration state during status checks. Restart active OpenCode sessions after installation.
|
||||
- Resolve Letta Code 0.28.13 global hooks through `${HOME:-~}/.letta/settings.json`, matching Letta's `HOME || homedir()` behavior. Keep the active workspace `.letta/settings.json` and `.letta/settings.local.json` sources read-only, deduplicate the project source when the workspace is the home directory, and apply the official `hooks.disabled` precedence: an explicit global `false` enables hooks, global `true` disables them, then project and project-local `true` values disable them. Validate all eleven current events plus command and prompt handler shapes while preserving future event payloads. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with `*` matchers and ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate frozen or revoked state through exit code `2`, and surface audit delivery failures through stderr, owner-only `error.log`, and exit code `1`; Letta post events retain their documented nonblocking semantics. Use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows and exact two-argument shell commands on Unix. Commit global settings, generated runtimes, runtime config, and private key through one rollback-capable transaction guarded by every consulted source snapshot. Migrate the legacy `node "<script>"` form through exact ownership checks, require physical sources, canonical private keys, exact runtime sources, executable identity, matching runtime identity, and an effective enabled state during status checks, then run `/hooks` and restart active sessions.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Keep production source files at or below 500 lines.
|
||||
- Keep functions focused on one ownership boundary.
|
||||
- Propagate unexpected errors to the CLI boundary.
|
||||
- Use documented defaults only for genuinely optional configuration.
|
||||
- Avoid compatibility shims without a named public or user configuration contract.
|
||||
- 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.
|
||||
- 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 validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error.
|
||||
- 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.
|
||||
|
||||
## Shared Modules
|
||||
|
||||
- `src/plugins/common.ts`: path and agent-id comparison, error helpers, size limits, `managedScriptReference`.
|
||||
- `src/plugins/shell-command.ts`: POSIX and PowerShell quoting, encoded Windows commands, command parsers, `isNodeExecutable`.
|
||||
- `src/plugins/managed-installation.ts` and `managed-transaction.ts`: preflight, prepare, and rollback-capable commit.
|
||||
- `src/plugins/managed-runtime-status.ts`: runtime config and key validation; `managedRuntimeFilesExist` and `managedRuntimePresent`.
|
||||
- `src/plugins/strict-json.ts` and `jsonc-edit.ts`: strict parsing and formatting-preserving JSONC edits.
|
||||
- `test-support/harness.mjs`: process runner, fake API server, fixture root, plugin runner; provider helper files wrap it.
|
||||
- Put logic used by two providers in a shared module; keep provider-specific rules in `<provider>-*.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the focused adapter test during development, then execute all release gates before commit:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
node --test test/<provider>-plugin.test.mjs
|
||||
npm test
|
||||
npm audit --omit=dev --audit-level=high
|
||||
npm pack --dry-run --json
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Provider adapter tests must cover installation, idempotency, official event forwarding, blocking behavior, status, missing runtime files, uninstall ownership, and malformed configuration preservation.
|
||||
|
||||
Commit and push one root issue before starting the next one.
|
||||
@@ -48,10 +48,11 @@ npx elydora install \
|
||||
--agent claudecode \
|
||||
--org_id org-123 \
|
||||
--agent_id agent-456 \
|
||||
--private_key <key> \
|
||||
--kid agent-456-key-v1
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
@@ -65,15 +66,52 @@ npx elydora install \
|
||||
|
||||
| Agent | Key |
|
||||
|-------|-----|
|
||||
| Augment Code CLI | `augment` |
|
||||
| Claude Code | `claudecode` |
|
||||
| Cursor | `cursor` |
|
||||
| Codex CLI | `codex` |
|
||||
| Cline | `cline` |
|
||||
| Factory Droid | `droid` |
|
||||
| Kimi Code | `kimi` |
|
||||
| Grok Build | `grok` |
|
||||
| Qwen Code | `qwen` |
|
||||
| Cursor CLI | `cursor` |
|
||||
| Gemini CLI | `gemini` |
|
||||
| Kiro CLI | `kirocli` |
|
||||
| Kiro IDE | `kiroide` |
|
||||
| OpenCode | `opencode` |
|
||||
| Copilot CLI | `copilot` |
|
||||
| GitHub Copilot CLI | `copilot` |
|
||||
| Letta Code | `letta` |
|
||||
|
||||
Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups to `$CLAUDE_CONFIG_DIR/settings.json`, with `~/.claude/settings.json` selected when the variable is absent. Relative and empty overrides resolve from the current working directory, matching Claude Code's native path behavior. Each handler uses Claude Code's cross-platform exec form with the current Node.js executable, a single script argument, and a ten-second timeout. Managed runtimes preserve the complete native snake_case event payload and enforce frozen or revoked state through exit code `2`. Project, local, managed, plugin, skill, and agent hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. `disableAllHooks` blocks installation; managed hook policy and safe mode can suppress user hooks. Run `/hooks` and `claude doctor` after installation.
|
||||
|
||||
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
|
||||
|
||||
GitHub Copilot CLI installation writes exact `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `$COPILOT_HOME/hooks/elydora-audit.json` (`~/.copilot/hooks/elydora-audit.json` by default). Empty home overrides select the default. Policy, repository, inline settings, cross-tool Claude, and plugin hook sources remain unchanged and continue loading additively. Elydora evaluates the effective `disableAllHooks` value across Copilot's documented settings precedence before writing. The managed hook file, exact legacy migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed commands preserve the complete native camelCase payload and propagate freeze or revocation through exit code `2`; Copilot timeouts retain their documented fail-open behavior. Restart active Copilot CLI sessions after installation.
|
||||
|
||||
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.
|
||||
|
||||
Gemini CLI installation writes named, matchless `BeforeTool` and `AfterTool` command hooks to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Empty home overrides select the OS home; relative and literal-tilde values retain Gemini CLI's native path behavior. JSON comments and unrelated settings remain intact. Workspace, system-default, system-override, and extension hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve the complete native snake_case payload, emit valid JSON on successful execution, propagate freeze and revocation through exit code `2`, and use encoded PowerShell commands on Windows. `hooksConfig.enabled` and `hooksConfig.disabled` remain authoritative. Run `/hooks list` after installation to inspect effective hooks from every source.
|
||||
|
||||
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Documents, `.clinerules/hooks`, and workspace `.cline/hooks` remain unchanged and continue loading additively. Both hook files, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. The wrappers preserve Cline 3's complete native payload, and the guard emits its documented one-line JSON cancellation control.
|
||||
|
||||
Factory Droid 0.175.0 installation resolves one active user hook source in this order: `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the `hooks` field from user `settings.local.json` and `settings.json`. Current hook files use the documented top-level `hooks` container; legacy direct event maps remain readable until Factory migrates them. Elydora writes exact `PreToolUse` and `PostToolUse` groups to that source, removes only exact Elydora ownership from inactive user sources, and keeps project, folder, plugin, and organization hook definitions read-only. System, project, folder, and user `hooksDisabled` values follow Factory's extension-only precedence; system-managed `allowManagedHooksOnly` blocks a user-hook installation. Hook sources, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed commands preserve the complete native snake_case payload, propagate frozen or revoked state through exit code `2`, and use Factory's native PowerShell call operator with explicit `$LASTEXITCODE` propagation on Windows. Run `/hooks` after installation to refresh Droid's session snapshot and verify remote organization policy.
|
||||
|
||||
Kimi installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules to every detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the legacy Python CLI's `~/.kimi/config.toml`. A fresh installation targets stable Kimi Code; an existing legacy home activates the migration contract. Empty `KIMI_CODE_HOME` values use the default. The selected TOML files, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Kimi's native snake_case event JSON, propagate freeze and revocation through exit code `2`, and use encoded PowerShell on Windows. Run `/hooks` to inspect the global rules.
|
||||
|
||||
Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json`, with `~/.grok/hooks/elydora-audit.json` selected when `GROK_HOME` is absent. An empty `GROK_HOME` stops installation and status checks with guidance to unset the variable or provide an absolute home directory, preventing Grok Build 0.2.106 from resolving it to cwd-relative `hooks`. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Elydora rejects matcher-bearing `SessionStart`, `SessionEnd`, `Stop`, and `UserPromptSubmit` groups that the stable loader drops; other [xAI hook groups](https://docs.x.ai/build/features/hooks) retain matcher metadata and nullable handler environments. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` and open `/hooks` to inspect the effective hook inventory.
|
||||
|
||||
Qwen Code 0.20.0 installation writes exact, named `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` command hooks to `$QWEN_HOME/settings.json` (default `~/.qwen/settings.json`). Explicit environment values lead Qwen's home bootstrap, followed by `<QWEN_HOME>/.env` or `~/.qwen/.env`, then `~/.env`; a discovered home contributes its own `.env`. System defaults, trusted workspace settings, system overrides, and trust rules remain read-only and participate in effective `disableAllHooks` evaluation. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction guarded by snapshots of every consulted source. Managed handlers use ten-second millisecond timeouts, preserve Qwen's complete native snake_case payload for success and failure events, and propagate frozen or revoked state through exit code `2`. Run `/hooks` after installation and restart active Qwen Code sessions.
|
||||
|
||||
Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration.
|
||||
|
||||
Kiro IDE 1.0 installation is workspace-scoped. Run the installer from the workspace root; it writes `.kiro/hooks/elydora-audit.json` with exact `PreToolUse` and `PostToolUse` command hooks in Kiro's v1 JSON schema. The hook file, any exact Elydora legacy migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve Kiro's complete native payload and propagate frozen or revoked state through exit code `2`. Open the Agent Hooks panel after installation and confirm both Elydora entries.
|
||||
|
||||
Kiro CLI installation commits the global v2 custom agent at `~/.kiro/agents/elydora-audit.json`, the CLI 2.13.0+ v3 global hooks at `~/.kiro/hooks/elydora-audit.json`, generated runtimes, runtime config, and private key through one rollback-capable transaction. The v2 agent uses exact lower-camel `preToolUse` and `postToolUse` handlers with ten-second millisecond timeouts. The v3 source uses Kiro's v1 schema with exact PascalCase `PreToolUse` and `PostToolUse` command hooks. Both contracts preserve the complete native event payload and propagate frozen or revoked state through exit code `2`. Validate v2 with `kiro-cli agent validate --path ~/.kiro/agents/elydora-audit.json` and start it with `kiro-cli --agent elydora-audit`; start the v3 TUI with `kiro-cli --v3`.
|
||||
|
||||
OpenCode 1.18.4 installation writes the named `ElydoraAuditPlugin` export to `${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/elydora-audit.js`, matching the official global plugin loader's `.js` and `.ts` discovery contract. `XDG_CONFIG_HOME` must be absolute. The `tool.execute.before` hook synchronously evaluates the full native input and argument payload and throws on frozen or revoked state. The awaited `tool.execute.after` hook preserves the complete native input and output payload and reports audit runtime failures to stderr. The plugin, exact legacy `.mjs` migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Status requires physical paths, exact generated sources, the installed Node executable, canonical private keys, and matching runtime identity. Restart active OpenCode sessions after installation.
|
||||
|
||||
Letta Code 0.28.13 installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups to `${HOME:-~}/.letta/settings.json`, following the [official hook source and event contract](https://docs.letta.com/reference/deprecated/hooks). Workspace `.letta/settings.json` and `.letta/settings.local.json` remain read-only and participate in effective `hooks.disabled` evaluation. An explicit global `false` enables hooks; global `true`, project `true`, and project-local `true` disable them according to Letta's precedence. Managed command handlers use ten-second millisecond timeouts, the current absolute Node.js executable, PowerShell exit-code propagation on Windows, and complete native snake_case payloads. Frozen and revoked state propagates through exit code `2`; audit delivery failures remain visible through stderr, owner-only `error.log`, and exit code `1`, while Letta post events remain nonblocking. Global settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction guarded by snapshots of all three settings sources. Status requires an effective enabled state, exact hook and executable identity, physical files, canonical private keys, and matching runtime identity. Run `/hooks` and restart active Letta Code sessions after installation.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Configuration
|
||||
@@ -86,7 +124,7 @@ const client = new ElydoraClient({
|
||||
baseUrl?: string, // API base URL (default: https://api.elydora.com)
|
||||
ttlMs?: number, // Operation TTL in ms (default: 30000)
|
||||
maxRetries?: number, // Max retries on transient failures (default: 3)
|
||||
kid?: string, // Key ID (default: {agentId}-key-v1)
|
||||
kid?: string, // Key ID (default: {agentId}-key-1)
|
||||
});
|
||||
```
|
||||
|
||||
@@ -105,8 +143,13 @@ client.setToken(auth.token);
|
||||
// Get current authenticated user profile
|
||||
const { user } = await client.getMe();
|
||||
|
||||
// Issue a new API token (with optional TTL in seconds)
|
||||
// Issue an API token (optional TTL in seconds) and use it for later calls
|
||||
const { token, expires_at } = await client.issueApiToken(3600);
|
||||
client.setToken(token);
|
||||
|
||||
// Rotate the API token the client currently holds
|
||||
const rotated = await client.rotateApiToken();
|
||||
client.setToken(rotated.token);
|
||||
```
|
||||
|
||||
### Operations
|
||||
@@ -136,6 +179,7 @@ const result = await client.verifyOperation(operationId);
|
||||
// Register a new agent
|
||||
const agent = await client.registerAgent({
|
||||
agent_id: 'my-agent',
|
||||
integration_type: 'codex',
|
||||
display_name: 'My Agent',
|
||||
responsible_entity: 'team@example.com',
|
||||
keys: [{ kid: 'key-v1', public_key: '<base64url>', algorithm: 'ed25519' }],
|
||||
@@ -144,6 +188,9 @@ const agent = await client.registerAgent({
|
||||
// Get agent details
|
||||
const details = await client.getAgent(agentId);
|
||||
|
||||
// Change the integration type
|
||||
await client.updateAgent(agentId, 'cursor');
|
||||
|
||||
// Freeze an agent
|
||||
await client.freezeAgent(agentId, 'security review');
|
||||
|
||||
@@ -206,7 +253,21 @@ const { keys } = await client.getJWKS();
|
||||
```typescript
|
||||
// Check API health (no authentication required)
|
||||
const health = await client.health();
|
||||
// health.status, health.version, health.protocol_version, health.timestamp
|
||||
// health.status, health.version, health.protocol_version, health.capabilities, health.timestamp
|
||||
|
||||
// Check dependency health (D1, auth storage, R2, KV)
|
||||
const deep = await client.deepHealth();
|
||||
```
|
||||
|
||||
### Webhooks, Members, Admin Events
|
||||
|
||||
```typescript
|
||||
const { webhooks } = await client.listWebhooks();
|
||||
const { webhook } = await client.registerWebhook(url, ['agent.status_changed'], secret);
|
||||
await client.deleteWebhook(webhook.webhook_id);
|
||||
|
||||
const { members } = await client.listMembers();
|
||||
const { events } = await client.listAdminEvents(50);
|
||||
```
|
||||
|
||||
### Client State
|
||||
|
||||
Generated
+34
-2
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "@elydora/sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@elydora/sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "2.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@decimalturn/toml-patch": "^2.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jsonc-parser": "^3.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"elydora": "dist/cli.js"
|
||||
},
|
||||
@@ -19,6 +24,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@decimalturn/toml-patch": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@decimalturn/toml-patch/-/toml-patch-2.0.0.tgz",
|
||||
"integrity": "sha512-nVU9bcndpePHaDalbqA3Rbx/46onD9fcKgUI5dUsvbIpFMYXp/78ScUwdFUCXjcjN9DD+pNitTfDiwJJTPb4ig==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
|
||||
@@ -29,6 +43,24 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
+10
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elydora/sdk",
|
||||
"version": "1.2.1",
|
||||
"version": "2.1.1",
|
||||
"description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -8,11 +8,11 @@
|
||||
"elydora": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts"
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/postbuild.mjs",
|
||||
"test": "npm run build && node --test",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"engines": {
|
||||
@@ -20,7 +20,12 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.0.0"
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@decimalturn/toml-patch": "^2.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jsonc-parser": "^3.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import readline from 'node:readline';
|
||||
import { Writable } from 'node:stream';
|
||||
|
||||
const MAX_SECRET_FILE_BYTES = 64 * 1024;
|
||||
|
||||
interface SecretSources {
|
||||
readonly privateKeyFile?: string;
|
||||
readonly tokenFile?: string;
|
||||
}
|
||||
|
||||
interface SecretTerminal {
|
||||
readonly interactive: boolean;
|
||||
readHidden(prompt: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface InstallSecrets {
|
||||
readonly privateKey: string;
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
function parseSingleLineSecret(raw: string, label: string, allowEmpty: boolean): string {
|
||||
const value = raw.endsWith('\r\n')
|
||||
? raw.slice(0, -2)
|
||||
: raw.endsWith('\n')
|
||||
? raw.slice(0, -1)
|
||||
: raw;
|
||||
|
||||
if (!allowEmpty && value.length === 0) {
|
||||
throw new Error(`${label} is empty`);
|
||||
}
|
||||
if (value.includes('\r') || value.includes('\n') || value.includes('\0')) {
|
||||
throw new Error(`${label} must contain exactly one line`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readSecretFile(filePath: string, label: string): Promise<string> {
|
||||
const stats = await fsp.lstat(filePath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`${label} file is not a regular file: ${filePath}`);
|
||||
}
|
||||
if (stats.size > MAX_SECRET_FILE_BYTES) {
|
||||
throw new Error(`${label} file exceeds ${MAX_SECRET_FILE_BYTES} bytes: ${filePath}`);
|
||||
}
|
||||
if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} file must be accessible only by its owner: ${filePath}`);
|
||||
}
|
||||
|
||||
const raw = await fsp.readFile(filePath, 'utf-8');
|
||||
return parseSingleLineSecret(raw, label, false);
|
||||
}
|
||||
|
||||
async function readHidden(prompt: string): Promise<string> {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
throw new Error('hidden input requires an interactive terminal');
|
||||
}
|
||||
|
||||
let muted = false;
|
||||
const output = new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
if (!muted) process.stderr.write(chunk, encoding);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const terminal = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output,
|
||||
terminal: true,
|
||||
historySize: 0,
|
||||
});
|
||||
|
||||
process.stderr.write(prompt);
|
||||
muted = true;
|
||||
try {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
terminal.once('SIGINT', () => reject(new Error('secret input cancelled')));
|
||||
terminal.question('', resolve);
|
||||
});
|
||||
} finally {
|
||||
muted = false;
|
||||
terminal.close();
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTerminal: SecretTerminal = {
|
||||
interactive: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
||||
readHidden,
|
||||
};
|
||||
|
||||
export async function resolveInstallSecrets(
|
||||
sources: SecretSources,
|
||||
terminal: SecretTerminal = defaultTerminal,
|
||||
): Promise<InstallSecrets> {
|
||||
let privateKey: string;
|
||||
if (sources.privateKeyFile) {
|
||||
privateKey = await readSecretFile(sources.privateKeyFile, 'private key');
|
||||
} else if (terminal.interactive) {
|
||||
privateKey = parseSingleLineSecret(
|
||||
await terminal.readHidden('Private key: '),
|
||||
'private key',
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
'private key input requires an interactive terminal or --private_key_file <path>',
|
||||
);
|
||||
}
|
||||
|
||||
let token: string | undefined;
|
||||
if (sources.tokenFile) {
|
||||
token = await readSecretFile(sources.tokenFile, 'API token');
|
||||
} else if (terminal.interactive) {
|
||||
token = parseSingleLineSecret(
|
||||
await terminal.readHidden('API token (optional): '),
|
||||
'API token',
|
||||
true,
|
||||
) || undefined;
|
||||
}
|
||||
|
||||
return { privateKey, token };
|
||||
}
|
||||
+142
-106
@@ -2,10 +2,20 @@ import { parseArgs } from 'node:util';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { resolveInstallSecrets } from './cli-secrets.js';
|
||||
import { derivePublicKey } from './crypto.js';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
requirePhysicalDirectory,
|
||||
requirePhysicalFile,
|
||||
resolvePrivateChildDirectory,
|
||||
} from './runtime-paths.js';
|
||||
import { writePrivateFile } from './secure-files.js';
|
||||
import { SUPPORTED_AGENTS } from './plugins/registry.js';
|
||||
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
|
||||
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
|
||||
import { generateGuardScript } from './plugins/guard-template.js';
|
||||
import { generateHookScript } from './plugins/hook-template.js';
|
||||
import { augmentPlugin } from './plugins/augment.js';
|
||||
import { claudecodePlugin } from './plugins/claudecode.js';
|
||||
import { cursorPlugin } from './plugins/cursor.js';
|
||||
import { geminiPlugin } from './plugins/gemini.js';
|
||||
@@ -14,10 +24,17 @@ import { kiroidePlugin } from './plugins/kiroide.js';
|
||||
import { opencodePlugin } from './plugins/opencode.js';
|
||||
import { copilotPlugin } from './plugins/copilot.js';
|
||||
import { lettaPlugin } from './plugins/letta.js';
|
||||
import { codexPlugin } from './plugins/codex.js';
|
||||
import { clinePlugin } from './plugins/cline.js';
|
||||
import { droidPlugin } from './plugins/droid.js';
|
||||
import { kimiPlugin } from './plugins/kimi.js';
|
||||
import { grokPlugin } from './plugins/grok.js';
|
||||
import { qwenPlugin } from './plugins/qwen.js';
|
||||
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
|
||||
const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
||||
['augment', augmentPlugin],
|
||||
['claudecode', claudecodePlugin],
|
||||
['cursor', cursorPlugin],
|
||||
['gemini', geminiPlugin],
|
||||
@@ -26,22 +43,83 @@ const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
||||
['opencode', opencodePlugin],
|
||||
['copilot', copilotPlugin],
|
||||
['letta', lettaPlugin],
|
||||
['codex', codexPlugin],
|
||||
['cline', clinePlugin],
|
||||
['droid', droidPlugin],
|
||||
['kimi', kimiPlugin],
|
||||
['grok', grokPlugin],
|
||||
['qwen', qwenPlugin],
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function die(message: string): never {
|
||||
console.error(`Error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface InstalledAgent {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readInstalledAgent(agentId: string): Promise<InstalledAgent | undefined> {
|
||||
const agentDirectory = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||
if (!(await requirePhysicalDirectory(agentDirectory))) return undefined;
|
||||
|
||||
const configPath = path.join(agentDirectory, 'config.json');
|
||||
if (!(await requirePhysicalFile(configPath))) return undefined;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fsp.readFile(configPath, 'utf-8');
|
||||
} catch (error) {
|
||||
throw new Error(`Could not read agent config: ${configPath}`, { cause: error });
|
||||
}
|
||||
|
||||
let config: unknown;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse agent config: ${configPath}`, { cause: error });
|
||||
}
|
||||
if (
|
||||
!isRecord(config)
|
||||
|| typeof config.agent_id !== 'string'
|
||||
|| typeof config.agent_name !== 'string'
|
||||
) {
|
||||
throw new Error(`Agent config has an invalid runtime identity: ${configPath}`);
|
||||
}
|
||||
if (config.agent_id !== agentId) {
|
||||
throw new Error(`Agent config crosses its runtime directory: ${configPath}`);
|
||||
}
|
||||
resolvePrivateChildDirectory(ELYDORA_DIR, config.agent_id);
|
||||
return { agentId, agentName: config.agent_name, configPath };
|
||||
}
|
||||
|
||||
async function discoverInstalledAgents(): Promise<InstalledAgent[]> {
|
||||
if (!(await requirePhysicalDirectory(ELYDORA_DIR))) return [];
|
||||
|
||||
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
||||
const installedAgents: InstalledAgent[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Agent runtime path is not a physical directory: ${path.join(ELYDORA_DIR, entry.name)}`);
|
||||
}
|
||||
if (!entry.isDirectory()) continue;
|
||||
const installedAgent = await readInstalledAgent(entry.name);
|
||||
if (installedAgent) installedAgents.push(installedAgent);
|
||||
}
|
||||
return installedAgents;
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
|
||||
|
||||
Usage:
|
||||
elydora install --agent <name> --org_id <id> --agent_id <id> --private_key <key> --kid <kid> [--token <token>] [--base_url <url>]
|
||||
elydora install --agent <name> --org_id <id> --agent_id <id> --kid <kid> [--private_key_file <path>] [--token_file <path>] [--base_url <url>]
|
||||
elydora uninstall --agent <name> [--agent_id <id>]
|
||||
elydora status
|
||||
elydora agents
|
||||
@@ -56,10 +134,6 @@ Supported agents: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function cmdInstall(args: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
@@ -67,9 +141,9 @@ async function cmdInstall(args: string[]): Promise<void> {
|
||||
agent: { type: 'string' },
|
||||
org_id: { type: 'string' },
|
||||
agent_id: { type: 'string' },
|
||||
private_key: { type: 'string' },
|
||||
private_key_file: { type: 'string' },
|
||||
kid: { type: 'string' },
|
||||
token: { type: 'string' },
|
||||
token_file: { type: 'string' },
|
||||
base_url: { type: 'string' },
|
||||
},
|
||||
strict: true,
|
||||
@@ -86,17 +160,17 @@ async function cmdInstall(args: string[]): Promise<void> {
|
||||
|
||||
const agentId = values.agent_id;
|
||||
if (!agentId) die('--agent_id is required');
|
||||
|
||||
const privateKey = values.private_key;
|
||||
if (!privateKey) die('--private_key is required');
|
||||
const agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||
|
||||
const kid = values.kid;
|
||||
if (!kid) die('--kid is required');
|
||||
|
||||
const token = values.token;
|
||||
const { privateKey, token } = await resolveInstallSecrets({
|
||||
privateKeyFile: values.private_key_file,
|
||||
tokenFile: values.token_file,
|
||||
});
|
||||
const baseUrl = values.base_url ?? 'https://api.elydora.com';
|
||||
|
||||
// Validate private key by deriving public key
|
||||
let publicKey: string;
|
||||
try {
|
||||
publicKey = derivePublicKey(privateKey);
|
||||
@@ -106,47 +180,10 @@ async function cmdInstall(args: string[]): Promise<void> {
|
||||
|
||||
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`);
|
||||
|
||||
// Create ~/.elydora/{agentId}/ directory
|
||||
const agentDir = path.join(ELYDORA_DIR, agentId);
|
||||
await fsp.mkdir(agentDir, { recursive: true });
|
||||
|
||||
// Write agent config
|
||||
const agentConfigPath = path.join(agentDir, 'config.json');
|
||||
const agentConfig = {
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
kid,
|
||||
base_url: baseUrl,
|
||||
...(token ? { token } : {}),
|
||||
agent_name: agentName,
|
||||
};
|
||||
await fsp.writeFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n', 'utf-8');
|
||||
if (process.platform !== 'win32') {
|
||||
await fsp.chmod(agentConfigPath, 0o600);
|
||||
}
|
||||
console.log(` Agent config: ${agentConfigPath}`);
|
||||
|
||||
// Write private key (chmod 600)
|
||||
const keyPath = path.join(agentDir, 'private.key');
|
||||
await fsp.writeFile(keyPath, privateKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') {
|
||||
await fsp.chmod(keyPath, 0o600);
|
||||
}
|
||||
console.log(` Private key: ${keyPath}`);
|
||||
|
||||
// Generate and write hook script (PostToolUse — audit logging)
|
||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||
const hookScript = generateHookScript(agentName, agentId);
|
||||
await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
console.log(` Hook script: ${hookScriptPath}`);
|
||||
|
||||
// Generate and write guard script (PreToolUse — freeze enforcement)
|
||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||
const guardScript = generateGuardScript(agentName, agentId);
|
||||
await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
console.log(` Guard script: ${guardScriptPath}`);
|
||||
|
||||
// Install agent-specific config hook
|
||||
const plugin = PLUGINS.get(agentName)!;
|
||||
const installConfig: InstallConfig = {
|
||||
agentName,
|
||||
@@ -159,8 +196,32 @@ async function cmdInstall(args: string[]): Promise<void> {
|
||||
hookScriptPath,
|
||||
guardScriptPath,
|
||||
};
|
||||
await plugin.preflightInstall?.(installConfig);
|
||||
|
||||
if (!plugin.managesRuntime) {
|
||||
await ensurePrivateDirectory(ELYDORA_DIR);
|
||||
await ensurePrivateDirectory(agentDir);
|
||||
const agentConfig = {
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
kid,
|
||||
base_url: baseUrl,
|
||||
...(token ? { token } : {}),
|
||||
agent_name: agentName,
|
||||
};
|
||||
await writePrivateFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n');
|
||||
await writePrivateFile(keyPath, privateKey);
|
||||
const hookScript = generateHookScript(agentName, agentId);
|
||||
await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
const guardScript = generateGuardScript(agentName, agentId);
|
||||
await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
}
|
||||
|
||||
await plugin.install(installConfig);
|
||||
console.log(` Agent config: ${agentConfigPath}`);
|
||||
console.log(` Private key: ${keyPath}`);
|
||||
console.log(` Hook script: ${hookScriptPath}`);
|
||||
console.log(` Guard script: ${guardScriptPath}`);
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(agentName)!;
|
||||
console.log(`\nElydora audit hook installed for ${entry.name}.`);
|
||||
@@ -183,45 +244,44 @@ async function cmdUninstall(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
let agentId = values.agent_id;
|
||||
let agentDir: string;
|
||||
let agentDirectoryExists: boolean;
|
||||
|
||||
// If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name
|
||||
if (!agentId) {
|
||||
try {
|
||||
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
|
||||
try {
|
||||
const raw = await fsp.readFile(cfgPath, 'utf-8');
|
||||
const cfg = JSON.parse(raw);
|
||||
if (cfg.agent_name === agentName) {
|
||||
agentId = entry.name;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable configs
|
||||
}
|
||||
if (agentId) {
|
||||
agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||
const runtimeRootExists = await requirePhysicalDirectory(ELYDORA_DIR);
|
||||
agentDirectoryExists = runtimeRootExists
|
||||
? await requirePhysicalDirectory(agentDir)
|
||||
: false;
|
||||
if (agentDirectoryExists) {
|
||||
const installedAgent = await readInstalledAgent(agentId);
|
||||
if (installedAgent && installedAgent.agentName !== agentName) {
|
||||
throw new Error(
|
||||
`Agent runtime ${agentId} belongs to ${installedAgent.agentName}, not ${agentName}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ELYDORA_DIR may not exist
|
||||
}
|
||||
if (!agentId) {
|
||||
} else {
|
||||
const matches = (await discoverInstalledAgents())
|
||||
.filter((installedAgent) => installedAgent.agentName === agentName);
|
||||
if (matches.length === 0) {
|
||||
die(`No installed agent found for "${agentName}". Use --agent_id to specify explicitly.`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
die(`Multiple installed agents found for "${agentName}". Use --agent_id to specify explicitly.`);
|
||||
}
|
||||
agentId = matches[0].agentId;
|
||||
agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||
agentDirectoryExists = true;
|
||||
}
|
||||
|
||||
const plugin = PLUGINS.get(agentName)!;
|
||||
const registryEntry = SUPPORTED_AGENTS.get(agentName)!;
|
||||
|
||||
// Uninstall agent-specific config
|
||||
await plugin.uninstall(agentId);
|
||||
|
||||
// Remove entire agent directory
|
||||
const agentDir = path.join(ELYDORA_DIR, agentId);
|
||||
try {
|
||||
await fsp.rm(agentDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Already removed
|
||||
if (agentDirectoryExists) {
|
||||
await fsp.rm(agentDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`);
|
||||
@@ -232,32 +292,12 @@ async function cmdStatus(): Promise<void> {
|
||||
|
||||
let anyInstalled = false;
|
||||
|
||||
// Scan ~/.elydora/*/config.json to discover installed agents
|
||||
const installedAgents: Array<{ agentId: string; agentName: string; configPath: string }> = [];
|
||||
try {
|
||||
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
|
||||
try {
|
||||
const raw = await fsp.readFile(cfgPath, 'utf-8');
|
||||
const cfg = JSON.parse(raw);
|
||||
if (cfg.agent_name && cfg.agent_id) {
|
||||
installedAgents.push({ agentId: entry.name, agentName: cfg.agent_name, configPath: cfgPath });
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable configs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ELYDORA_DIR may not exist
|
||||
}
|
||||
const installedAgents = await discoverInstalledAgents();
|
||||
|
||||
for (const [name, plugin] of PLUGINS) {
|
||||
const st = await plugin.status();
|
||||
const statusIcon = st.installed ? '[installed]' : '[not installed]';
|
||||
|
||||
// Find matching installed agent(s) for this plugin
|
||||
const matching = installedAgents.filter((a) => a.agentName === name);
|
||||
|
||||
console.log(` ${st.displayName} (${name}) ${statusIcon}`);
|
||||
@@ -286,10 +326,6 @@ function cmdAgents(): void {
|
||||
console.log('\nUse "elydora install --agent <name>" to install an audit hook.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
|
||||
+20
-92
@@ -37,6 +37,7 @@ import type {
|
||||
ErrorResponse,
|
||||
} from './types.js';
|
||||
import { ElydoraError } from './errors.js';
|
||||
import { assertIntegrationType } from './integration-types.js';
|
||||
import {
|
||||
jcsCanonicalise,
|
||||
computePayloadHash,
|
||||
@@ -73,34 +74,19 @@ export class ElydoraClient {
|
||||
this.prevChainHash = ZERO_CHAIN_HASH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the API token used for authenticated API calls.
|
||||
*/
|
||||
setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current chain hash (useful for debugging/inspection).
|
||||
*/
|
||||
getChainHash(): string {
|
||||
return this.prevChainHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key derived from the configured private key.
|
||||
*/
|
||||
getPublicKey(): string {
|
||||
return derivePublicKey(this.privateKey);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth (static methods — no instance needed)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use Better Auth endpoints directly. See docs.
|
||||
*/
|
||||
/** @deprecated Password auth for the SDK CLI path; Console users sign in through Better Auth. */
|
||||
static async register(
|
||||
baseUrl: string,
|
||||
email: string,
|
||||
@@ -122,9 +108,7 @@ export class ElydoraClient {
|
||||
return handleResponse<AuthRegisterResponse>(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Better Auth endpoints directly. See docs.
|
||||
*/
|
||||
/** @deprecated Password auth for the SDK CLI path; Console users sign in through Better Auth. */
|
||||
static async login(
|
||||
baseUrl: string,
|
||||
email: string,
|
||||
@@ -140,10 +124,6 @@ export class ElydoraClient {
|
||||
return handleResponse<AuthLoginResponse>(res);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth (instance methods — require token)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getMe(): Promise<GetMeResponse> {
|
||||
return this.request<GetMeResponse>('GET', '/v1/auth/me');
|
||||
}
|
||||
@@ -156,11 +136,8 @@ export class ElydoraClient {
|
||||
return this.request<RotateApiTokenResponse>('POST', '/v1/auth/rotate', {});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Agent management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async registerAgent(request: RegisterAgentRequest): Promise<RegisterAgentResponse> {
|
||||
assertIntegrationType(request?.integration_type);
|
||||
return this.request<RegisterAgentResponse>('POST', '/v1/agents/register', request);
|
||||
}
|
||||
|
||||
@@ -169,6 +146,7 @@ export class ElydoraClient {
|
||||
}
|
||||
|
||||
async updateAgent(agentId: string, integType: IntegrationType): Promise<UpdateAgentResponse> {
|
||||
assertIntegrationType(integType);
|
||||
return this.request<UpdateAgentResponse>('PATCH', `/v1/agents/${encodeURIComponent(agentId)}`, { integration_type: integType });
|
||||
}
|
||||
|
||||
@@ -192,31 +170,15 @@ export class ElydoraClient {
|
||||
await this.request<unknown>('POST', `/v1/agents/${encodeURIComponent(agentId)}/revoke`, { kid, reason });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Operations (core)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construct and sign an Elydora Operation Record (EOR) locally.
|
||||
*
|
||||
* 1. Generate UUIDv7 for operation_id
|
||||
* 2. Generate random nonce (16 bytes, base64url)
|
||||
* 3. Compute payload_hash (SHA-256 of JCS-canonicalized payload)
|
||||
* 4. Compute chain_hash from prev_chain_hash
|
||||
* 5. Construct canonical EOR (minus signature)
|
||||
* 6. Sign JCS-canonicalized EOR with Ed25519
|
||||
* 7. Update internal prev_chain_hash
|
||||
*/
|
||||
// Builds, hashes, chains, and signs an EOR locally, then advances prev_chain_hash.
|
||||
createOperation(params: CreateOperationParams): EOR {
|
||||
const operationId = uuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const nonce = generateNonce();
|
||||
const payload = params.payload ?? null;
|
||||
|
||||
// Compute payload hash
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
|
||||
// Compute chain hash
|
||||
const chainHash = computeChainHash(
|
||||
this.prevChainHash,
|
||||
payloadHash,
|
||||
@@ -224,7 +186,6 @@ export class ElydoraClient {
|
||||
issuedAt,
|
||||
);
|
||||
|
||||
// Construct the EOR without signature for signing
|
||||
const eorWithoutSig: Omit<EOR, 'signature'> = {
|
||||
op_version: '1.0',
|
||||
operation_id: operationId,
|
||||
@@ -242,11 +203,9 @@ export class ElydoraClient {
|
||||
agent_pubkey_kid: this.kid,
|
||||
};
|
||||
|
||||
// Sign the JCS-canonicalized EOR (without signature field)
|
||||
const canonical = jcsCanonicalise(eorWithoutSig);
|
||||
const signature = signEd25519(this.privateKey, Buffer.from(canonical, 'utf-8'));
|
||||
|
||||
// Update internal chain state
|
||||
this.prevChainHash = chainHash;
|
||||
|
||||
return {
|
||||
@@ -267,18 +226,10 @@ export class ElydoraClient {
|
||||
return this.request<VerifyOperationResponse>('POST', `/v1/operations/${encodeURIComponent(operationId)}/verify`, {});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Audit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async queryAudit(params: AuditQueryRequest): Promise<AuditQueryResponse> {
|
||||
return this.request<AuditQueryResponse>('POST', '/v1/audit/query', params);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Epochs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async listEpochs(): Promise<ListEpochsResponse> {
|
||||
return this.request<ListEpochsResponse>('GET', '/v1/epochs');
|
||||
}
|
||||
@@ -287,10 +238,6 @@ export class ElydoraClient {
|
||||
return this.request<GetEpochResponse>('GET', `/v1/epochs/${encodeURIComponent(epochId)}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Exports
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async createExport(params: CreateExportRequest): Promise<CreateExportResponse> {
|
||||
return this.request<CreateExportResponse>('POST', '/v1/exports', params);
|
||||
}
|
||||
@@ -320,10 +267,6 @@ export class ElydoraClient {
|
||||
return res.arrayBuffer();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JWKS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getJWKS(): Promise<JWKSResponse> {
|
||||
const url = `${this.baseUrl}/.well-known/elydora/jwks.json`;
|
||||
const res = await fetch(url, {
|
||||
@@ -333,10 +276,6 @@ export class ElydoraClient {
|
||||
return handleResponse<JWKSResponse>(res);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Health
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async health(): Promise<HealthResponse> {
|
||||
const url = `${this.baseUrl}/v1/health`;
|
||||
const res = await fetch(url, {
|
||||
@@ -346,16 +285,16 @@ export class ElydoraClient {
|
||||
return handleResponse<HealthResponse>(res);
|
||||
}
|
||||
|
||||
/** 503 carries the degraded report, not an error. */
|
||||
async deepHealth(): Promise<DeepHealthResponse> {
|
||||
const url = `${this.baseUrl}/v1/health/deep`;
|
||||
const res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } });
|
||||
if (res.status === 503) {
|
||||
return (await res.json()) as DeepHealthResponse;
|
||||
}
|
||||
return handleResponse<DeepHealthResponse>(res);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Webhooks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async listWebhooks(): Promise<ListWebhooksResponse> {
|
||||
return this.request<ListWebhooksResponse>('GET', '/v1/webhooks');
|
||||
}
|
||||
@@ -368,27 +307,15 @@ export class ElydoraClient {
|
||||
await this.request<unknown>('DELETE', `/v1/webhooks/${encodeURIComponent(webhookId)}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Members
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async listMembers(): Promise<ListMembersResponse> {
|
||||
return this.request<ListMembersResponse>('GET', '/v1/members');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Admin events
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async listAdminEvents(limit?: number): Promise<ListAdminEventsResponse> {
|
||||
const query = limit ? `?limit=${limit}` : '';
|
||||
return this.request<ListAdminEventsResponse>('GET', `/v1/admin/events${query}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal HTTP helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
@@ -417,8 +344,7 @@ export class ElydoraClient {
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Only retry on 429 or 5xx
|
||||
if (attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
|
||||
if (attempt < this.maxRetries && isIdempotent(method) && (res.status === 429 || res.status >= 500)) {
|
||||
const retryAfter = res.headers.get('Retry-After');
|
||||
const delayMs = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
@@ -431,15 +357,15 @@ export class ElydoraClient {
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
// Only retry on network errors, not API errors
|
||||
if (err instanceof ElydoraError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (attempt < this.maxRetries) {
|
||||
if (attempt < this.maxRetries && isIdempotent(method)) {
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 10_000));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,13 +373,15 @@ export class ElydoraClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
|
||||
|
||||
/** A lost response to a non-idempotent request must not be replayed. */
|
||||
function isIdempotent(method: string): boolean {
|
||||
return IDEMPOTENT_METHODS.has(method.toUpperCase());
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (res.ok) {
|
||||
// 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
@@ -464,7 +392,7 @@ async function handleResponse<T>(res: Response): Promise<T> {
|
||||
try {
|
||||
errorBody = (await res.json()) as ErrorResponse;
|
||||
} catch {
|
||||
// Response body was not valid JSON
|
||||
// non-JSON error body: fall through to the status-only error
|
||||
}
|
||||
|
||||
if (errorBody?.error) {
|
||||
|
||||
+6
-70
@@ -1,18 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { base64urlEncode } from './utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JCS Canonicalization (RFC 8785)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Canonicalize a JSON value according to JCS (RFC 8785).
|
||||
*
|
||||
* - Object keys sorted lexicographically by UTF-16 code units
|
||||
* - No whitespace
|
||||
* - Numbers serialized using ES2015 Number serialization
|
||||
* - Strings serialized with minimal escaping per JSON spec
|
||||
*/
|
||||
// RFC 8785 JSON Canonicalization Scheme
|
||||
export function jcsCanonicalise(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
@@ -46,30 +35,13 @@ export function jcsCanonicalise(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SHA-256
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute SHA-256 of a UTF-8 string or buffer and return base64url.
|
||||
*/
|
||||
export function sha256Base64url(data: string | Buffer): string {
|
||||
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
|
||||
const hash = crypto.createHash('sha256').update(input).digest();
|
||||
return base64urlEncode(hash);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the chain hash for an operation.
|
||||
*
|
||||
* chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + issued_at)
|
||||
*
|
||||
* All inputs concatenated as UTF-8 strings separated by '|'.
|
||||
*/
|
||||
// chain_hash = SHA-256("prev|payload_hash|operation_id|issued_at")
|
||||
export function computeChainHash(
|
||||
prevChainHash: string,
|
||||
payloadHash: string,
|
||||
@@ -80,13 +52,6 @@ export function computeChainHash(
|
||||
return sha256Base64url(input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of JCS-canonicalized payload, returned as base64url.
|
||||
*/
|
||||
export function computePayloadHash(
|
||||
payload: Record<string, unknown> | string | null,
|
||||
): string {
|
||||
@@ -94,23 +59,11 @@ export function computePayloadHash(
|
||||
return sha256Base64url(canonical);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 signing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PKCS8 prefix for wrapping a 32-byte Ed25519 seed
|
||||
// PKCS8 envelope: SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.112 }, OCTET STRING { OCTET STRING seed } }
|
||||
const PKCS8_ED25519_PREFIX = Buffer.from([
|
||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||
0x02, 0x01, 0x00, // INTEGER 0
|
||||
0x30, 0x05, // SEQUENCE (5 bytes)
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
|
||||
0x04, 0x22, // OCTET STRING (34 bytes)
|
||||
0x04, 0x20, // OCTET STRING (32 bytes)
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Import a base64url-encoded 32-byte Ed25519 seed as a Node.js KeyObject.
|
||||
*/
|
||||
function importPrivateKey(privateKeyBase64url: string): crypto.KeyObject {
|
||||
const seed = Buffer.from(privateKeyBase64url, 'base64url');
|
||||
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
|
||||
@@ -121,37 +74,20 @@ function importPrivateKey(privateKeyBase64url: string): crypto.KeyObject {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign data with Ed25519 using a base64url-encoded 32-byte seed.
|
||||
*
|
||||
* @returns base64url-encoded 64-byte signature
|
||||
*/
|
||||
export function signEd25519(privateKeyBase64url: string, data: Buffer): string {
|
||||
const keyObject = importPrivateKey(privateKeyBase64url);
|
||||
const signature = crypto.sign(null, data, keyObject);
|
||||
return base64urlEncode(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Ed25519 public key from a base64url-encoded 32-byte seed.
|
||||
*
|
||||
* @returns base64url-encoded 32-byte public key
|
||||
*/
|
||||
export function derivePublicKey(privateKeyBase64url: string): string {
|
||||
const keyObject = importPrivateKey(privateKeyBase64url);
|
||||
const publicKeyObject = crypto.createPublicKey(keyObject);
|
||||
const rawPublicKey = publicKeyObject.export({ type: 'spki', format: 'der' });
|
||||
// The raw 32-byte public key is the last 32 bytes of the SPKI DER encoding
|
||||
// The raw key is the last 32 bytes of the SPKI DER encoding.
|
||||
const publicKeyBytes = rawPublicKey.subarray(rawPublicKey.length - 32);
|
||||
return base64urlEncode(publicKeyBytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zero chain hash (initial prev_chain_hash for the first operation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The initial chain hash value: base64url encoding of 32 zero bytes.
|
||||
* Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
*/
|
||||
// 32 zero bytes; equals the Backend GENESIS_CHAIN_HASH.
|
||||
export const ZERO_CHAIN_HASH: string = base64urlEncode(Buffer.alloc(32, 0));
|
||||
+2
-4
@@ -1,5 +1,6 @@
|
||||
export { ElydoraClient } from './client.js';
|
||||
export { ElydoraError } from './errors.js';
|
||||
export { INTEGRATION_TYPES } from './types.js';
|
||||
|
||||
export {
|
||||
jcsCanonicalise,
|
||||
@@ -19,7 +20,6 @@ export {
|
||||
} from './utils.js';
|
||||
|
||||
export type {
|
||||
// Enums
|
||||
AgentStatus,
|
||||
KeyStatus,
|
||||
ExportStatus,
|
||||
@@ -28,7 +28,6 @@ export type {
|
||||
AdminAction,
|
||||
ErrorCode,
|
||||
|
||||
// Entities
|
||||
Agent,
|
||||
AgentKey,
|
||||
Operation,
|
||||
@@ -40,11 +39,9 @@ export type {
|
||||
User,
|
||||
Export,
|
||||
|
||||
// Protocol
|
||||
EOR,
|
||||
EAR,
|
||||
|
||||
// API request/response
|
||||
ElydoraClientConfig,
|
||||
CreateOperationParams,
|
||||
RegisterAgentRequest,
|
||||
@@ -74,6 +71,7 @@ export type {
|
||||
JWKSResponse,
|
||||
HealthResponse,
|
||||
DeepHealthResponse,
|
||||
Webhook,
|
||||
ListWebhooksResponse,
|
||||
RegisterWebhookResponse,
|
||||
ListMembersResponse,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { INTEGRATION_TYPES, type IntegrationType } from './types.js';
|
||||
|
||||
export function assertIntegrationType(value: unknown): asserts value is IntegrationType {
|
||||
const isSupported = typeof value === 'string'
|
||||
&& INTEGRATION_TYPES.some((integrationType) => integrationType === value);
|
||||
if (!isSupported) {
|
||||
throw new TypeError(
|
||||
`Invalid integration_type "${String(value)}". Expected one of: ${INTEGRATION_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import { quotePosix, readPosixArgument, type ParsedArgument } from './shell-command.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'augment';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_MILLISECONDS = 10_000;
|
||||
|
||||
const WRAPPER_EXTENSION = process.platform === 'win32' ? '.cmd' : '.sh';
|
||||
export const GUARD_WRAPPER = `augment-guard${WRAPPER_EXTENSION}`;
|
||||
export const AUDIT_WRAPPER = `augment-hook${WRAPPER_EXTENSION}`;
|
||||
|
||||
const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
|
||||
const SESSION_EVENTS = new Set([
|
||||
'Stop',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'Notification',
|
||||
'PromptSubmit',
|
||||
]);
|
||||
|
||||
export interface AugmentHandler extends JsonObject {
|
||||
readonly type: 'command';
|
||||
readonly command: string;
|
||||
readonly args?: string[];
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface AugmentGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: AugmentHandler[];
|
||||
}
|
||||
|
||||
export type AugmentHooks = Record<string, AugmentGroup[]>;
|
||||
|
||||
export interface AugmentDocument {
|
||||
readonly exists: boolean;
|
||||
readonly configPath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: AugmentHooks;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedAugmentDocument {
|
||||
readonly document: AugmentDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
readonly guardWrapperPath: string;
|
||||
readonly auditWrapperPath: string;
|
||||
}
|
||||
|
||||
function hasOwn(value: JsonObject, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function quoteWindows(value: string): string {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
function quoteBatch(value: string): string {
|
||||
return `"${value.replaceAll('%', '%%')}"`;
|
||||
}
|
||||
|
||||
function readWindowsArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== '"') return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] === '\\' && command[index + 1] === '"') {
|
||||
value += '"';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === '"') return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseWrapperCommand(command: string): string | undefined {
|
||||
const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument;
|
||||
const parsed = readArgument(command, 0);
|
||||
return parsed?.next === command.length && parsed.value ? parsed.value : undefined;
|
||||
}
|
||||
|
||||
export function wrapperPaths(agentDirectory: string): {
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
} {
|
||||
return {
|
||||
guardPath: path.join(agentDirectory, GUARD_WRAPPER),
|
||||
auditPath: path.join(agentDirectory, AUDIT_WRAPPER),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHandler(wrapperPath: string): AugmentHandler {
|
||||
const quote = process.platform === 'win32' ? quoteWindows : quotePosix;
|
||||
return {
|
||||
type: 'command',
|
||||
command: quote(wrapperPath),
|
||||
timeout: HOOK_TIMEOUT_MILLISECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWrapper(runtimePath: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
return `@echo off\r\n${quoteBatch(process.execPath)} ${quoteBatch(runtimePath)}\r\nexit /b %errorlevel%\r\n`;
|
||||
}
|
||||
return `#!/bin/sh\nexec ${quotePosix(process.execPath)} ${quotePosix(runtimePath)}\n`;
|
||||
}
|
||||
|
||||
function managedAgentId(handler: AugmentHandler, wrapperName: string): string | undefined {
|
||||
if (handler.type !== 'command'
|
||||
|| handler.timeout !== HOOK_TIMEOUT_MILLISECONDS
|
||||
|| typeof handler.command !== 'string'
|
||||
|| hasOwn(handler, 'args')) return undefined;
|
||||
const wrapperPath = parseWrapperCommand(handler.command);
|
||||
if (!wrapperPath) return undefined;
|
||||
const actualName = path.basename(wrapperPath);
|
||||
const namesMatch = process.platform === 'win32'
|
||||
? actualName.toLowerCase() === wrapperName.toLowerCase()
|
||||
: actualName === wrapperName;
|
||||
if (!namesMatch) return undefined;
|
||||
const agentDirectory = path.dirname(wrapperPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
|
||||
}
|
||||
|
||||
function validateHandler(
|
||||
value: unknown,
|
||||
event: string,
|
||||
groupIndex: number,
|
||||
handlerIndex: number,
|
||||
): AugmentHandler {
|
||||
const label = `Auggie settings handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.type !== 'command') throw new Error(`${label} type must be "command"`);
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
if (value.args !== undefined
|
||||
&& (!Array.isArray(value.args) || !value.args.every((argument) => typeof argument === 'string'))) {
|
||||
throw new Error(`${label} args must be an array of strings`);
|
||||
}
|
||||
if (value.timeout !== undefined
|
||||
&& (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) {
|
||||
throw new Error(`${label} timeout must be a positive finite number`);
|
||||
}
|
||||
return value as AugmentHandler;
|
||||
}
|
||||
|
||||
function validateMetadata(value: unknown, label: string): void {
|
||||
if (!isObject(value)) throw new Error(`${label} metadata must be an object`);
|
||||
for (const key of ['includeConversationData', 'includeMCPMetadata', 'includeUserContext']) {
|
||||
if (value[key] !== undefined && typeof value[key] !== 'boolean') {
|
||||
throw new Error(`${label} metadata.${key} must be a boolean`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, event: string, groupIndex: number): AugmentGroup {
|
||||
const label = `Auggie settings group hooks.${event}[${groupIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (SESSION_EVENTS.has(event) && hasOwn(value, 'matcher')) {
|
||||
throw new Error(`${label} matcher is only supported for tool events`);
|
||||
}
|
||||
if (value.matcher !== undefined) {
|
||||
if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`);
|
||||
try {
|
||||
new RegExp(value.matcher);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${label} matcher must be a valid regular expression: ${message}`);
|
||||
}
|
||||
}
|
||||
if (value.metadata !== undefined) validateMetadata(value.metadata, label);
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
const handlers = value.hooks.map(
|
||||
(handler, handlerIndex) => validateHandler(handler, event, groupIndex, handlerIndex),
|
||||
);
|
||||
return { ...value, hooks: handlers } as AugmentGroup;
|
||||
}
|
||||
|
||||
export function readHooks(root: JsonObject): AugmentHooks {
|
||||
if (root.hooks === undefined) return {};
|
||||
if (!isObject(root.hooks)) throw new Error('Auggie settings field "hooks" must be an object');
|
||||
const hooks: AugmentHooks = {};
|
||||
for (const [event, value] of Object.entries(root.hooks)) {
|
||||
if (!TOOL_EVENTS.has(event) && !SESSION_EVENTS.has(event)) {
|
||||
throw new Error(`Auggie settings field "hooks.${event}" uses an unsupported event`);
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Auggie settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = value.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function parseAugmentDocument(configPath: string, raw: string): AugmentDocument {
|
||||
const root = parseStrictJsonObject(raw, `Auggie user settings at ${configPath}`);
|
||||
return { exists: true, configPath, root, hooks: readHooks(root), raw };
|
||||
}
|
||||
|
||||
export function createAugmentDocument(configPath: string): AugmentDocument {
|
||||
return { exists: false, configPath, root: {}, hooks: {} };
|
||||
}
|
||||
|
||||
export function renderAugmentDocument(
|
||||
document: AugmentDocument,
|
||||
hooks: AugmentHooks,
|
||||
): RenderedAugmentDocument {
|
||||
if (!document.exists && Object.keys(hooks).length === 0) {
|
||||
return { document, changed: false };
|
||||
}
|
||||
const root: JsonObject = { ...document.root };
|
||||
if (Object.keys(hooks).length > 0) root.hooks = hooks;
|
||||
else delete root.hooks;
|
||||
if (Object.keys(root).length === 0) return { document, changed: true };
|
||||
const next = `${JSON.stringify(root, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
function removeManaged(
|
||||
groups: AugmentGroup[],
|
||||
wrapperName: string,
|
||||
agentId?: string,
|
||||
): { readonly groups: AugmentGroup[]; readonly changed: boolean } {
|
||||
let changed = false;
|
||||
const result: AugmentGroup[] = [];
|
||||
for (const group of groups) {
|
||||
let groupChanged = false;
|
||||
const handlers = group.hooks.filter((handler) => {
|
||||
const managedId = managedAgentId(handler, wrapperName);
|
||||
const remove = managedId !== undefined
|
||||
&& (agentId === undefined || sameAgentId(managedId, agentId));
|
||||
if (remove) {
|
||||
changed = true;
|
||||
groupChanged = true;
|
||||
}
|
||||
return !remove;
|
||||
});
|
||||
if (!groupChanged) result.push(group);
|
||||
else if (handlers.length > 0) result.push({ ...group, hooks: handlers });
|
||||
}
|
||||
return { groups: result, changed };
|
||||
}
|
||||
|
||||
export function removeManagedHooks(hooks: AugmentHooks, agentId?: string): {
|
||||
readonly hooks: AugmentHooks;
|
||||
readonly changed: boolean;
|
||||
} {
|
||||
const next = { ...hooks };
|
||||
let changed = false;
|
||||
for (const [event, wrapperName] of [
|
||||
['PreToolUse', GUARD_WRAPPER],
|
||||
['PostToolUse', AUDIT_WRAPPER],
|
||||
] as const) {
|
||||
const result = removeManaged(next[event] ?? [], wrapperName, agentId);
|
||||
if (!result.changed) continue;
|
||||
changed = true;
|
||||
if (result.groups.length > 0) next[event] = result.groups;
|
||||
else delete next[event];
|
||||
}
|
||||
return { hooks: next, changed };
|
||||
}
|
||||
|
||||
function managedIds(groups: AugmentGroup[], wrapperName: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const group of groups) {
|
||||
for (const handler of group.hooks) {
|
||||
const agentId = managedAgentId(handler, wrapperName);
|
||||
if (agentId) ids.add(agentId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function runtimeContracts(hooks: AugmentHooks): RuntimeContract[] {
|
||||
const guards = managedIds(hooks.PreToolUse ?? [], GUARD_WRAPPER);
|
||||
const audits = managedIds(hooks.PostToolUse ?? [], AUDIT_WRAPPER);
|
||||
const root = path.join(os.homedir(), '.elydora');
|
||||
return [...guards]
|
||||
.filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId)))
|
||||
.map((agentId) => {
|
||||
const agentDirectory = path.join(root, agentId);
|
||||
return {
|
||||
agentId,
|
||||
guardPath: path.join(agentDirectory, GUARD_SCRIPT),
|
||||
auditPath: path.join(agentDirectory, AUDIT_SCRIPT),
|
||||
guardWrapperPath: path.join(agentDirectory, GUARD_WRAPPER),
|
||||
auditWrapperPath: path.join(agentDirectory, AUDIT_WRAPPER),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_WRAPPER,
|
||||
GUARD_SCRIPT,
|
||||
GUARD_WRAPPER,
|
||||
buildWrapper,
|
||||
type RenderedAugmentDocument,
|
||||
} from './augment-contract.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
managedRuntimePaths,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
|
||||
const DISPLAY_NAME = 'Augment Code CLI';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Auggie configuration directory';
|
||||
const SETTINGS_LABEL = 'Auggie user settings';
|
||||
|
||||
export async function preflightAugmentInstallation(
|
||||
config: InstallConfig,
|
||||
settingsPath: string,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareAugmentInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: RenderedAugmentDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
if (!rendered.changed && rendered.document.raw === undefined) {
|
||||
throw new Error('Auggie hook installation did not produce a settings document');
|
||||
}
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
if (settingsSource === undefined) throw new Error('Auggie hook settings are missing');
|
||||
const paths = managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: SETTINGS_LABEL,
|
||||
filePath: rendered.document.configPath,
|
||||
expectedSource: rendered.document.raw,
|
||||
source: settingsSource,
|
||||
}],
|
||||
runtimeFiles: [{
|
||||
fileName: GUARD_WRAPPER,
|
||||
label: 'Auggie guard wrapper',
|
||||
source: buildWrapper(paths.guardPath),
|
||||
mode: 0o700,
|
||||
}, {
|
||||
fileName: AUDIT_WRAPPER,
|
||||
label: 'Auggie audit wrapper',
|
||||
source: buildWrapper(paths.auditPath),
|
||||
mode: 0o700,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitAugmentInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_WRAPPER,
|
||||
GUARD_WRAPPER,
|
||||
buildWrapper,
|
||||
createAugmentDocument,
|
||||
parseAugmentDocument,
|
||||
type AugmentDocument,
|
||||
type RenderedAugmentDocument,
|
||||
type RuntimeContract,
|
||||
} from './augment-contract.js';
|
||||
import { MAX_CONFIG_BYTES, samePath } from './common.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
export function resolveConfigPath(): string {
|
||||
return path.join(os.homedir(), '.augment', 'settings.json');
|
||||
}
|
||||
|
||||
export async function readConfig(): Promise<AugmentDocument> {
|
||||
const configPath = resolveConfigPath();
|
||||
await inspectPhysicalDirectory(path.dirname(configPath), 'Auggie configuration directory');
|
||||
const snapshot = await readPhysicalFile(configPath, 'Auggie user settings', MAX_CONFIG_BYTES);
|
||||
return snapshot
|
||||
? parseAugmentDocument(configPath, snapshot.contents)
|
||||
: createAugmentDocument(configPath);
|
||||
}
|
||||
|
||||
export async function writeAugmentDocument(rendered: RenderedAugmentDocument): Promise<void> {
|
||||
if (!rendered.changed) return;
|
||||
const change = await prepareManagedFileChange({
|
||||
filePath: rendered.document.configPath,
|
||||
label: 'Auggie user settings',
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_CONFIG_BYTES,
|
||||
expectedSource: rendered.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (!change) return;
|
||||
await commitManagedTransaction({
|
||||
displayName: 'Augment Code CLI',
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.configPath),
|
||||
label: 'Auggie configuration directory',
|
||||
}],
|
||||
changes: [change],
|
||||
});
|
||||
}
|
||||
|
||||
function wrappersValid(contract: RuntimeContract): boolean {
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
return samePath(contract.guardWrapperPath, path.join(agentDirectory, GUARD_WRAPPER))
|
||||
&& samePath(contract.auditWrapperPath, path.join(agentDirectory, AUDIT_WRAPPER));
|
||||
}
|
||||
|
||||
async function runtimeContractExists(contract: RuntimeContract): Promise<boolean> {
|
||||
if (!wrappersValid(contract)) return false;
|
||||
if (!await managedRuntimeFilesExist(contract, AGENT_KEY)) return false;
|
||||
const [guardWrapper, auditWrapper] = await Promise.all([
|
||||
readPhysicalFile(contract.guardWrapperPath, 'Auggie guard wrapper'),
|
||||
readPhysicalFile(contract.auditWrapperPath, 'Auggie audit wrapper'),
|
||||
]);
|
||||
return guardWrapper?.contents === buildWrapper(contract.guardPath)
|
||||
&& auditWrapper?.contents === buildWrapper(contract.auditPath);
|
||||
}
|
||||
|
||||
export async function augmentRuntimeFilesExist(
|
||||
contracts: readonly RuntimeContract[],
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await runtimeContractExists(contract)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildHandler,
|
||||
removeManagedHooks,
|
||||
renderAugmentDocument,
|
||||
runtimeContracts,
|
||||
wrapperPaths,
|
||||
} from './augment-contract.js';
|
||||
import {
|
||||
commitAugmentInstallation,
|
||||
preflightAugmentInstallation,
|
||||
prepareAugmentInstallation,
|
||||
} from './augment-installation.js';
|
||||
import {
|
||||
augmentRuntimeFilesExist,
|
||||
readConfig,
|
||||
writeAugmentDocument,
|
||||
} from './augment-io.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
export const augmentPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const document = await readConfig();
|
||||
await preflightAugmentInstallation(config, document.configPath);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const document = await readConfig();
|
||||
const paths = await preflightAugmentInstallation(config, document.configPath);
|
||||
const wrappers = wrapperPaths(paths.agentDirectory);
|
||||
|
||||
const cleaned = removeManagedHooks(document.hooks).hooks;
|
||||
const hooks = {
|
||||
...cleaned,
|
||||
PreToolUse: [
|
||||
...(cleaned.PreToolUse ?? []),
|
||||
{ matcher: '.*', hooks: [buildHandler(wrappers.guardPath)] },
|
||||
],
|
||||
PostToolUse: [
|
||||
...(cleaned.PostToolUse ?? []),
|
||||
{ matcher: '.*', hooks: [buildHandler(wrappers.auditPath)] },
|
||||
],
|
||||
};
|
||||
const rendered = renderAugmentDocument(document, hooks);
|
||||
const prepared = await prepareAugmentInstallation(config, rendered);
|
||||
await commitAugmentInstallation(prepared);
|
||||
console.log(' Auggie: user-level PreToolUse and PostToolUse hooks installed.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const document = await readConfig();
|
||||
if (!document.exists) return;
|
||||
const result = removeManagedHooks(document.hooks, agentId);
|
||||
if (!result.changed) return;
|
||||
await writeAugmentDocument(renderAugmentDocument(document, result.hooks));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const document = await readConfig();
|
||||
const contracts = runtimeContracts(document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
? await augmentRuntimeFilesExist(contracts)
|
||||
: false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: document.configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,8 @@ export interface PluginStatus {
|
||||
}
|
||||
|
||||
export interface AgentPlugin {
|
||||
readonly managesRuntime?: boolean;
|
||||
preflightInstall?(config: InstallConfig): Promise<void>;
|
||||
install(config: InstallConfig): Promise<void>;
|
||||
uninstall(agentId?: string): Promise<void>;
|
||||
status(): Promise<PluginStatus>;
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'claudecode';
|
||||
export const CONFIG_FILE = 'settings.json';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
export const GUARD_STATUS = 'Checking Elydora agent state';
|
||||
export const AUDIT_STATUS = 'Recording Elydora tool use';
|
||||
|
||||
const HOOK_EVENTS = new Set([
|
||||
'SessionStart', 'Setup', 'InstructionsLoaded', 'UserPromptSubmit',
|
||||
'UserPromptExpansion', 'MessageDisplay', 'PreToolUse', 'PermissionRequest',
|
||||
'PostToolUse', 'PostToolUseFailure', 'PostToolBatch', 'PermissionDenied',
|
||||
'Notification', 'SubagentStart', 'SubagentStop', 'TaskCreated', 'TaskCompleted',
|
||||
'Stop', 'StopFailure', 'TeammateIdle', 'ConfigChange', 'CwdChanged',
|
||||
'FileChanged', 'WorktreeCreate', 'WorktreeRemove', 'PreCompact', 'PostCompact',
|
||||
'SessionEnd', 'Elicitation', 'ElicitationResult',
|
||||
]);
|
||||
|
||||
const COMMON_HANDLER_KEYS = ['if', 'once', 'statusMessage', 'timeout'];
|
||||
const HANDLER_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
command: [
|
||||
...COMMON_HANDLER_KEYS,
|
||||
'args',
|
||||
'async',
|
||||
'asyncRewake',
|
||||
'command',
|
||||
'rewakeMessage',
|
||||
'rewakeSummary',
|
||||
'shell',
|
||||
'type',
|
||||
],
|
||||
prompt: [...COMMON_HANDLER_KEYS, 'continueOnBlock', 'model', 'prompt', 'type'],
|
||||
agent: [...COMMON_HANDLER_KEYS, 'model', 'prompt', 'type'],
|
||||
http: [...COMMON_HANDLER_KEYS, 'allowedEnvVars', 'headers', 'type', 'url'],
|
||||
mcp_tool: [...COMMON_HANDLER_KEYS, 'input', 'server', 'tool', 'type'],
|
||||
};
|
||||
|
||||
export interface ClaudeHandler extends JsonObject {
|
||||
readonly type: 'command' | 'prompt' | 'agent' | 'http' | 'mcp_tool';
|
||||
}
|
||||
|
||||
export interface ClaudeGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: ClaudeHandler[];
|
||||
}
|
||||
|
||||
export type ClaudeHooks = Record<string, ClaudeGroup[]>;
|
||||
|
||||
export interface ClaudeDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: ClaudeHooks;
|
||||
readonly hooksDisabled: boolean;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedClaudeDocument {
|
||||
readonly document: ClaudeDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
interface RuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
function requireKnownKeys(value: JsonObject, allowed: readonly string[], label: string): void {
|
||||
const keys = new Set(allowed);
|
||||
const extra = Object.keys(value).find((key) => !keys.has(key));
|
||||
if (extra) throw new Error(`${label} contains unsupported field "${extra}"`);
|
||||
}
|
||||
|
||||
function requireNonEmptyString(value: unknown, field: string, label: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${label} field "${field}" must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined && typeof value[field] !== 'string') {
|
||||
throw new Error(`${label} field "${field}" must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function optionalNonEmptyString(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined) requireNonEmptyString(value[field], field, label);
|
||||
}
|
||||
|
||||
function optionalBoolean(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
|
||||
throw new Error(`${label} field "${field}" must be a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCommonHandlerFields(value: JsonObject, label: string): void {
|
||||
optionalString(value, 'if', label);
|
||||
optionalString(value, 'statusMessage', label);
|
||||
optionalBoolean(value, 'once', label);
|
||||
if (value.timeout !== undefined && (
|
||||
typeof value.timeout !== 'number'
|
||||
|| !Number.isFinite(value.timeout)
|
||||
|| value.timeout <= 0
|
||||
)) {
|
||||
throw new Error(`${label} timeout must be a positive finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStringArray(value: unknown, field: string, label: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
||||
throw new Error(`${label} field "${field}" must be an array of strings`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, event: string, group: number, index: number): ClaudeHandler {
|
||||
const label = `Claude Code settings handler hooks.${event}[${group}].hooks[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (typeof value.type !== 'string' || !HANDLER_KEYS[value.type]) {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
requireKnownKeys(value, HANDLER_KEYS[value.type], label);
|
||||
validateCommonHandlerFields(value, label);
|
||||
if (value.type === 'command') {
|
||||
requireNonEmptyString(value.command, 'command', label);
|
||||
if (value.args !== undefined) validateStringArray(value.args, 'args', label);
|
||||
optionalBoolean(value, 'async', label);
|
||||
optionalBoolean(value, 'asyncRewake', label);
|
||||
optionalNonEmptyString(value, 'rewakeMessage', label);
|
||||
optionalNonEmptyString(value, 'rewakeSummary', label);
|
||||
if (value.shell !== undefined && value.shell !== 'bash' && value.shell !== 'powershell') {
|
||||
throw new Error(`${label} field "shell" must be "bash" or "powershell"`);
|
||||
}
|
||||
} else if (value.type === 'prompt' || value.type === 'agent') {
|
||||
requireNonEmptyString(value.prompt, 'prompt', label);
|
||||
optionalString(value, 'model', label);
|
||||
if (value.type === 'prompt') optionalBoolean(value, 'continueOnBlock', label);
|
||||
} else if (value.type === 'http') {
|
||||
const rawUrl = requireNonEmptyString(value.url, 'url', label);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} field "url" must be a valid URL`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) {
|
||||
throw new Error(`${label} field "url" must use HTTP or HTTPS`);
|
||||
}
|
||||
if (value.headers !== undefined && (
|
||||
!isObject(value.headers)
|
||||
|| Object.values(value.headers).some((item) => typeof item !== 'string')
|
||||
)) {
|
||||
throw new Error(`${label} field "headers" must map names to strings`);
|
||||
}
|
||||
if (value.allowedEnvVars !== undefined) {
|
||||
const allowedEnvVars = validateStringArray(value.allowedEnvVars, 'allowedEnvVars', label);
|
||||
if (allowedEnvVars.some((item) => item.length === 0)) {
|
||||
throw new Error(`${label} field "allowedEnvVars" must contain non-empty strings`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requireNonEmptyString(value.server, 'server', label);
|
||||
requireNonEmptyString(value.tool, 'tool', label);
|
||||
if (value.input !== undefined && !isObject(value.input)) {
|
||||
throw new Error(`${label} field "input" must be an object`);
|
||||
}
|
||||
}
|
||||
return value as ClaudeHandler;
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, event: string, index: number): ClaudeGroup {
|
||||
const label = `Claude Code settings matcher group hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
requireKnownKeys(value, ['hooks', 'matcher'], label);
|
||||
if (value.matcher !== undefined) {
|
||||
if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||
validateHandler(handler, event, index, handlerIndex)
|
||||
)),
|
||||
} as ClaudeGroup;
|
||||
}
|
||||
|
||||
function readHooks(value: unknown): ClaudeHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Claude Code settings field "hooks" must be an object');
|
||||
const hooks: ClaudeHooks = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (!HOOK_EVENTS.has(event)) {
|
||||
throw new Error(`Claude Code settings contains unsupported hook event "${event}"`);
|
||||
}
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Claude Code settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function parseClaudeDocument(filePath: string, raw: string): ClaudeDocument {
|
||||
const root = parseStrictJsonObject(raw, `Claude Code user settings at ${filePath}`);
|
||||
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
|
||||
throw new Error('Claude Code settings field "disableAllHooks" must be a boolean');
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
root,
|
||||
hooks: readHooks(root.hooks),
|
||||
hooksDisabled: root.disableAllHooks === true,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createClaudeDocument(filePath: string): ClaudeDocument {
|
||||
return { exists: false, filePath, root: {}, hooks: {}, hooksDisabled: false };
|
||||
}
|
||||
|
||||
export function buildClaudeGroup(scriptPath: string, statusMessage: string): ClaudeGroup {
|
||||
return {
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: process.execPath,
|
||||
args: [scriptPath],
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
statusMessage,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactManagedGroup(group: ClaudeGroup): boolean {
|
||||
return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks');
|
||||
}
|
||||
|
||||
function runtimeReference(scriptPath: string, scriptName: string): RuntimeReference | undefined {
|
||||
if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId && agentId !== '.' && agentId !== '..' ? { agentId, scriptPath } : undefined;
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: ClaudeHandler,
|
||||
scriptName: string,
|
||||
statusMessage: string,
|
||||
includeLegacy = false,
|
||||
): RuntimeReference | undefined {
|
||||
const keys = Object.keys(handler).sort().join('|');
|
||||
if (keys === 'args|command|statusMessage|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.timeout === HOOK_TIMEOUT_SECONDS
|
||||
&& handler.statusMessage === statusMessage
|
||||
&& typeof handler.command === 'string'
|
||||
&& samePath(handler.command, process.execPath)
|
||||
&& Array.isArray(handler.args)
|
||||
&& handler.args.length === 1
|
||||
&& typeof handler.args[0] === 'string') {
|
||||
return runtimeReference(handler.args[0], scriptName);
|
||||
}
|
||||
if (!includeLegacy || keys !== 'command|type' || handler.type !== 'command') return undefined;
|
||||
const match = typeof handler.command === 'string'
|
||||
? /^node "([^"\r\n]+)"$/.exec(handler.command)
|
||||
: undefined;
|
||||
return match ? runtimeReference(match[1], scriptName) : undefined;
|
||||
}
|
||||
|
||||
function removeFromGroups(
|
||||
groups: readonly ClaudeGroup[],
|
||||
scriptName: string,
|
||||
statusMessage: string,
|
||||
agentId?: string,
|
||||
): { groups: ClaudeGroup[]; removed: boolean } {
|
||||
const result: ClaudeGroup[] = [];
|
||||
let removed = false;
|
||||
for (const group of groups) {
|
||||
const handlers = group.hooks.filter((handler) => {
|
||||
const reference = managedReference(handler, scriptName, statusMessage, true);
|
||||
const owned = reference !== undefined
|
||||
&& (agentId === undefined || sameAgentId(reference.agentId, agentId));
|
||||
if (owned) removed = true;
|
||||
return !owned;
|
||||
});
|
||||
if (handlers.length > 0) result.push({ ...group, hooks: handlers });
|
||||
else if (!exactManagedGroup(group)) result.push({ ...group, hooks: [] });
|
||||
}
|
||||
return { groups: result, removed };
|
||||
}
|
||||
|
||||
export function removeManagedClaudeHooks(hooks: ClaudeHooks, agentId?: string): ClaudeHooks {
|
||||
const next: ClaudeHooks = Object.fromEntries(
|
||||
Object.entries(hooks).map(([event, groups]) => [event, [...groups]]),
|
||||
);
|
||||
for (const [event, scriptName, statusMessage] of [
|
||||
['PreToolUse', GUARD_SCRIPT, GUARD_STATUS],
|
||||
['PostToolUse', AUDIT_SCRIPT, AUDIT_STATUS],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT, AUDIT_STATUS],
|
||||
] as const) {
|
||||
if (!next[event]) continue;
|
||||
const result = removeFromGroups(next[event], scriptName, statusMessage, agentId);
|
||||
if (!result.removed) continue;
|
||||
if (result.groups.length > 0) next[event] = result.groups;
|
||||
else delete next[event];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function managedEvent(event: string): readonly [string, string] | undefined {
|
||||
if (event === 'PreToolUse') return [GUARD_SCRIPT, GUARD_STATUS];
|
||||
if (event === 'PostToolUse' || event === 'PostToolUseFailure') {
|
||||
return [AUDIT_SCRIPT, AUDIT_STATUS];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function entirelyManaged(document: ClaudeDocument): boolean {
|
||||
if (!document.exists || !Object.keys(document.root).every((key) => key === 'hooks')) return false;
|
||||
const events = Object.entries(document.hooks);
|
||||
if (events.length === 0) return false;
|
||||
let handlers = 0;
|
||||
for (const [event, groups] of events) {
|
||||
const contract = managedEvent(event);
|
||||
if (!contract || groups.length === 0) return false;
|
||||
for (const group of groups) {
|
||||
if (!exactManagedGroup(group)
|
||||
|| group.hooks.length === 0
|
||||
|| group.hooks.some((handler) => (
|
||||
!managedReference(handler, contract[0], contract[1], true)
|
||||
))) return false;
|
||||
handlers += group.hooks.length;
|
||||
}
|
||||
}
|
||||
return handlers > 0;
|
||||
}
|
||||
|
||||
export function renderClaudeDocument(
|
||||
document: ClaudeDocument,
|
||||
hooks: ClaudeHooks,
|
||||
): RenderedClaudeDocument {
|
||||
if (!document.exists && Object.keys(hooks).length === 0) {
|
||||
return { document, changed: false };
|
||||
}
|
||||
if (Object.keys(hooks).length === 0 && entirelyManaged(document)) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
const root: JsonObject = { ...document.root };
|
||||
if (Object.keys(hooks).length > 0) root.hooks = hooks;
|
||||
else delete root.hooks;
|
||||
const next = `${JSON.stringify(root, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly ClaudeGroup[],
|
||||
scriptName: string,
|
||||
statusMessage: string,
|
||||
): Map<string, RuntimeReference[]> {
|
||||
const references = new Map<string, RuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactManagedGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = managedReference(handler, scriptName, statusMessage);
|
||||
if (!reference) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function claudeRuntimeContracts(hooks: ClaudeHooks): ClaudeRuntimeContract[] {
|
||||
const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_STATUS);
|
||||
const successes = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_STATUS);
|
||||
const failures = referencesForEvent(
|
||||
hooks.PostToolUseFailure ?? [],
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_STATUS,
|
||||
);
|
||||
const contracts: ClaudeRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const success = successes.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || success?.length !== 1 || failure?.length !== 1) continue;
|
||||
if (!samePath(success[0].scriptPath, failure[0].scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: success[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type RenderedClaudeDocument,
|
||||
} from './claudecode-contract.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
|
||||
const DISPLAY_NAME = 'Claude Code';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Claude Code configuration directory';
|
||||
const SETTINGS_LABEL = 'Claude Code user settings';
|
||||
|
||||
export async function preflightClaudeInstallation(
|
||||
config: InstallConfig,
|
||||
settingsPath: string,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareClaudeInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: RenderedClaudeDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
if (!rendered.changed && rendered.document.raw === undefined) {
|
||||
throw new Error('Claude Code hook installation did not produce a settings document');
|
||||
}
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
if (settingsSource === undefined) throw new Error('Claude Code hook settings are missing');
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: SETTINGS_LABEL,
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.raw,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitClaudeInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
createClaudeDocument,
|
||||
parseClaudeDocument,
|
||||
type ClaudeDocument,
|
||||
type ClaudeRuntimeContract,
|
||||
type RenderedClaudeDocument,
|
||||
} from './claudecode-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
export function claudeConfigDirectory(): string {
|
||||
const configured = process.env.CLAUDE_CONFIG_DIR;
|
||||
return configured === undefined
|
||||
? path.join(os.homedir(), '.claude')
|
||||
: path.resolve(configured);
|
||||
}
|
||||
|
||||
export function claudeSettingsPath(): string {
|
||||
return path.join(claudeConfigDirectory(), CONFIG_FILE);
|
||||
}
|
||||
|
||||
export async function readClaudeDocument(): Promise<ClaudeDocument> {
|
||||
const filePath = claudeSettingsPath();
|
||||
await inspectPhysicalDirectory(path.dirname(filePath), 'Claude Code configuration directory');
|
||||
const snapshot = await readPhysicalFile(filePath, 'Claude Code user settings');
|
||||
return snapshot
|
||||
? parseClaudeDocument(filePath, snapshot.contents)
|
||||
: createClaudeDocument(filePath);
|
||||
}
|
||||
|
||||
export async function writeClaudeDocument(rendered: RenderedClaudeDocument): Promise<void> {
|
||||
if (!rendered.changed) return;
|
||||
const change = await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: 'Claude Code user settings',
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: rendered.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (!change) return;
|
||||
await commitManagedTransaction({
|
||||
displayName: 'Claude Code',
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: 'Claude Code configuration directory',
|
||||
}],
|
||||
changes: [change],
|
||||
});
|
||||
}
|
||||
|
||||
export async function claudeRuntimeFilesExist(
|
||||
contracts: readonly ClaudeRuntimeContract[],
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+61
-146
@@ -1,171 +1,86 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_STATUS,
|
||||
GUARD_STATUS,
|
||||
buildClaudeGroup,
|
||||
claudeRuntimeContracts,
|
||||
removeManagedClaudeHooks,
|
||||
renderClaudeDocument,
|
||||
} from './claudecode-contract.js';
|
||||
import {
|
||||
commitClaudeInstallation,
|
||||
preflightClaudeInstallation,
|
||||
prepareClaudeInstallation,
|
||||
} from './claudecode-installation.js';
|
||||
import {
|
||||
claudeRuntimeFilesExist,
|
||||
readClaudeDocument,
|
||||
writeClaudeDocument,
|
||||
} from './claudecode-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'claudecode';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
function requireEnabledHooks(disabled: boolean, filePath: string): void {
|
||||
if (disabled) {
|
||||
throw new Error(`Claude Code hooks are disabled by disableAllHooks: ${filePath}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const claudecodePlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const document = await readClaudeDocument();
|
||||
requireEnabledHooks(document.hooksDisabled, document.filePath);
|
||||
await preflightClaudeInstallation(config, document.filePath);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const document = await readClaudeDocument();
|
||||
requireEnabledHooks(document.hooksDisabled, document.filePath);
|
||||
const paths = await preflightClaudeInstallation(config, document.filePath);
|
||||
const hooks = removeManagedClaudeHooks(document.hooks);
|
||||
hooks.PreToolUse = [
|
||||
...(hooks.PreToolUse ?? []),
|
||||
buildClaudeGroup(paths.guardPath, GUARD_STATUS),
|
||||
];
|
||||
hooks.PostToolUse = [
|
||||
...(hooks.PostToolUse ?? []),
|
||||
buildClaudeGroup(paths.auditPath, AUDIT_STATUS),
|
||||
];
|
||||
hooks.PostToolUseFailure = [
|
||||
...(hooks.PostToolUseFailure ?? []),
|
||||
buildClaudeGroup(paths.auditPath, AUDIT_STATUS),
|
||||
];
|
||||
const rendered = renderClaudeDocument(document, hooks);
|
||||
const prepared = await prepareClaudeInstallation(config, rendered);
|
||||
await commitClaudeInstallation(prepared);
|
||||
console.log(` Claude Code hooks: ${document.filePath}`);
|
||||
console.log(' Claude Code verification: run /hooks and claude doctor.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const document = await readClaudeDocument();
|
||||
const hooks = removeManagedClaudeHooks(document.hooks, agentId);
|
||||
await writeClaudeDocument(renderClaudeDocument(document, hooks));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const document = await readClaudeDocument();
|
||||
const contracts = claudeRuntimeContracts(document.hooks);
|
||||
const hookConfigured = !document.hooksDisabled && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
? await claudeRuntimeFilesExist(contracts)
|
||||
: false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: document.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { asError, errorMessage, sameAgentId, samePath } from './common.js';
|
||||
import { isObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'cline';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_FILE_NAME = 'PreToolUse.mjs';
|
||||
export const AUDIT_FILE_NAME = 'PostToolUse.mjs';
|
||||
|
||||
const METADATA_MARKER = '// @elydora-cline-hook ';
|
||||
const METADATA_VERSION = 1;
|
||||
|
||||
export type ClineHookKind = 'guard' | 'audit';
|
||||
|
||||
export interface ClineHookMetadata {
|
||||
readonly version: 1;
|
||||
readonly kind: ClineHookKind;
|
||||
readonly agentId: string;
|
||||
readonly runtimePath: string;
|
||||
}
|
||||
|
||||
export interface ClineHookFile {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly source?: string;
|
||||
readonly metadata?: ClineHookMetadata;
|
||||
}
|
||||
|
||||
export interface ClineRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly agentDirectory: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export function resolveHooksDirectory(): string {
|
||||
const configuredDirectory = process.env.CLINE_DIR?.trim();
|
||||
const clineDirectory = configuredDirectory
|
||||
? path.resolve(configuredDirectory)
|
||||
: path.join(os.homedir(), '.cline');
|
||||
return path.join(clineDirectory, 'hooks');
|
||||
}
|
||||
|
||||
export function resolveHookFiles(): {
|
||||
readonly hooksDirectory: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
} {
|
||||
const hooksDirectory = resolveHooksDirectory();
|
||||
return {
|
||||
hooksDirectory,
|
||||
guardPath: path.join(hooksDirectory, GUARD_FILE_NAME),
|
||||
auditPath: path.join(hooksDirectory, AUDIT_FILE_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMetadata(
|
||||
kind: ClineHookKind,
|
||||
agentId: string,
|
||||
runtimePath: string,
|
||||
): ClineHookMetadata {
|
||||
if (!agentId) throw new Error('agentId is required');
|
||||
if (!path.isAbsolute(runtimePath)) throw new Error(`Cline ${kind} runtime path must be absolute`);
|
||||
return { version: METADATA_VERSION, kind, agentId, runtimePath };
|
||||
}
|
||||
|
||||
function encodeMetadata(metadata: ClineHookMetadata): string {
|
||||
return Buffer.from(JSON.stringify(metadata), 'utf-8').toString('base64url');
|
||||
}
|
||||
|
||||
export function buildWrapper(metadata: ClineHookMetadata): string {
|
||||
return `#!/usr/bin/env node
|
||||
${METADATA_MARKER}${encodeMetadata(metadata)}
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const hookKind = ${JSON.stringify(metadata.kind)};
|
||||
const runtimePath = ${JSON.stringify(metadata.runtimePath)};
|
||||
|
||||
function runRuntime(input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [runtimePath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
let stdinError;
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.stdin.on('error', (error) => {
|
||||
if (error?.code !== 'EPIPE') stdinError = error;
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => {
|
||||
if (stdinError) {
|
||||
reject(stdinError);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
code,
|
||||
signal,
|
||||
stdout: Buffer.concat(stdout).toString('utf-8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf-8'),
|
||||
});
|
||||
});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const result = await runRuntime(Buffer.concat(chunks));
|
||||
if (result.stdout) process.stderr.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
|
||||
if (hookKind === 'guard' && result.code === 2) {
|
||||
const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.';
|
||||
process.stdout.write(JSON.stringify({ cancel: true, errorMessage }) + '\\n');
|
||||
return;
|
||||
}
|
||||
if (result.signal) throw new Error('runtime terminated by signal ' + result.signal);
|
||||
if (result.code !== 0) throw new Error('runtime exited with code ' + result.code);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.stack || error.message : String(error);
|
||||
process.stderr.write('Elydora Cline ' + hookKind + ' wrapper failed: ' + message + '\\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function validateMetadata(value: unknown): ClineHookMetadata {
|
||||
if (!isObject(value)) throw new Error('metadata must be an object');
|
||||
const keys = Object.keys(value).sort();
|
||||
if (keys.join(',') !== 'agentId,kind,runtimePath,version') {
|
||||
throw new Error('metadata contains an unexpected field set');
|
||||
}
|
||||
if (value.version !== METADATA_VERSION) throw new Error('metadata version must be 1');
|
||||
if (value.kind !== 'guard' && value.kind !== 'audit') {
|
||||
throw new Error('metadata kind must be "guard" or "audit"');
|
||||
}
|
||||
if (typeof value.agentId !== 'string' || value.agentId.length === 0) {
|
||||
throw new Error('metadata agentId must be a non-empty string');
|
||||
}
|
||||
if (typeof value.runtimePath !== 'string' || !path.isAbsolute(value.runtimePath)) {
|
||||
throw new Error('metadata runtimePath must be an absolute path');
|
||||
}
|
||||
return value as unknown as ClineHookMetadata;
|
||||
}
|
||||
|
||||
export function parseMetadata(filePath: string, source: string): ClineHookMetadata | undefined {
|
||||
const markerLine = source.split(/\r?\n/, 3)[1];
|
||||
if (!markerLine?.startsWith(METADATA_MARKER)) return undefined;
|
||||
try {
|
||||
const encoded = markerLine.slice(METADATA_MARKER.length);
|
||||
const decoded = Buffer.from(encoded, 'base64url').toString('utf-8');
|
||||
return validateMetadata(JSON.parse(decoded));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse Elydora Cline hook metadata at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWrapperIntegrity(file: ClineHookFile): void {
|
||||
if (!file.metadata || file.source === undefined) return;
|
||||
if (file.source !== buildWrapper(file.metadata)) {
|
||||
throw new Error(`Elydora Cline hook at ${file.filePath} does not match the managed template`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAgentSegment(agentId: string): void {
|
||||
if (agentId === '.' || agentId === '..' || path.basename(agentId) !== agentId) {
|
||||
throw new Error('Elydora Cline hook metadata contains an invalid agentId');
|
||||
}
|
||||
}
|
||||
|
||||
export function runtimeContract(
|
||||
guardFile: ClineHookFile,
|
||||
auditFile: ClineHookFile,
|
||||
): ClineRuntimeContract | undefined {
|
||||
if (!guardFile.metadata || !auditFile.metadata) return undefined;
|
||||
assertWrapperIntegrity(guardFile);
|
||||
assertWrapperIntegrity(auditFile);
|
||||
const guard = guardFile.metadata;
|
||||
const audit = auditFile.metadata;
|
||||
if (guard.kind !== 'guard' || audit.kind !== 'audit') {
|
||||
throw new Error('Elydora Cline hook files contain mismatched event metadata');
|
||||
}
|
||||
if (!sameAgentId(guard.agentId, audit.agentId)) {
|
||||
throw new Error('Elydora Cline hook files reference different agents');
|
||||
}
|
||||
validateAgentSegment(guard.agentId);
|
||||
const agentDirectory = path.join(os.homedir(), '.elydora', guard.agentId);
|
||||
const expectedGuard = path.join(agentDirectory, GUARD_SCRIPT);
|
||||
const expectedAudit = path.join(agentDirectory, AUDIT_SCRIPT);
|
||||
if (!samePath(guard.runtimePath, expectedGuard) || !samePath(audit.runtimePath, expectedAudit)) {
|
||||
throw new Error('Elydora Cline hook metadata references an unexpected runtime path');
|
||||
}
|
||||
return {
|
||||
agentId: guard.agentId,
|
||||
agentDirectory,
|
||||
guardPath: expectedGuard,
|
||||
auditPath: expectedAudit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
assertWrapperIntegrity,
|
||||
buildMetadata,
|
||||
buildWrapper,
|
||||
type ClineHookFile,
|
||||
runtimeContract,
|
||||
} from './cline-contract.js';
|
||||
import { sameAgentId } from './common.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Cline';
|
||||
const DIRECTORY_LABEL = 'Cline hooks directory';
|
||||
|
||||
function hookLocations(files: readonly ClineHookFile[]) {
|
||||
return files.map((file) => ({
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
filePath: file.filePath,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function preflightClineInstallation(
|
||||
config: InstallConfig,
|
||||
files: readonly ClineHookFile[],
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocations(files),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareClineInstallation(
|
||||
config: InstallConfig,
|
||||
guardFile: ClineHookFile,
|
||||
auditFile: ClineHookFile,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
const paths = await preflightClineInstallation(config, [guardFile, auditFile]);
|
||||
const guardMetadata = buildMetadata('guard', config.agentId, paths.guardPath);
|
||||
const auditMetadata = buildMetadata('audit', config.agentId, paths.auditPath);
|
||||
const guardSource = buildWrapper(guardMetadata);
|
||||
const auditSource = buildWrapper(auditMetadata);
|
||||
runtimeContract(
|
||||
{ exists: true, filePath: guardFile.filePath, source: guardSource, metadata: guardMetadata },
|
||||
{ exists: true, filePath: auditFile.filePath, source: auditSource, metadata: auditMetadata },
|
||||
);
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'Cline PreToolUse hook',
|
||||
filePath: guardFile.filePath,
|
||||
expectedSource: guardFile.source,
|
||||
source: guardSource,
|
||||
}, {
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'Cline PostToolUse hook',
|
||||
filePath: auditFile.filePath,
|
||||
expectedSource: auditFile.source,
|
||||
source: auditSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitClineInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareClineUninstall(
|
||||
files: readonly ClineHookFile[],
|
||||
agentId?: string,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const owned = files.filter((file) => {
|
||||
const ownedAgentId = file.metadata?.agentId;
|
||||
return ownedAgentId !== undefined
|
||||
&& (agentId === undefined || sameAgentId(ownedAgentId, agentId));
|
||||
});
|
||||
for (const file of owned) assertWrapperIntegrity(file);
|
||||
const changes = await Promise.all(owned.map((file) => prepareManagedFileChange({
|
||||
filePath: file.filePath,
|
||||
label: `Cline ${file.metadata!.kind} hook`,
|
||||
next: undefined,
|
||||
mode: 0o600,
|
||||
expectedSource: file.source,
|
||||
verifyExpectedSource: true,
|
||||
})));
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: owned.map((file) => ({
|
||||
path: path.dirname(file.filePath),
|
||||
label: DIRECTORY_LABEL,
|
||||
})),
|
||||
changes: changes.filter((change) => change !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitClineUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
type ClineHookFile,
|
||||
type ClineRuntimeContract,
|
||||
parseMetadata,
|
||||
} from './cline-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
export async function readHookFile(filePath: string): Promise<ClineHookFile> {
|
||||
const directory = path.dirname(filePath);
|
||||
if (!await inspectPhysicalDirectory(directory, 'Cline hooks directory')) {
|
||||
return { exists: false, filePath };
|
||||
}
|
||||
const snapshot = await readPhysicalFile(filePath, 'Cline hook');
|
||||
if (!snapshot) return { exists: false, filePath };
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
source: snapshot.contents,
|
||||
metadata: parseMetadata(filePath, snapshot.contents),
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAvailableHookFile(file: ClineHookFile): void {
|
||||
if (file.exists && !file.metadata) {
|
||||
throw new Error(`Cline hook at ${file.filePath} already exists and is owned by another integration`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(contract: ClineRuntimeContract): Promise<boolean> {
|
||||
return managedRuntimeFilesExist(contract, AGENT_KEY);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { AGENT_KEY, resolveHookFiles, runtimeContract } from './cline-contract.js';
|
||||
import {
|
||||
commitClineInstallation,
|
||||
commitClineUninstall,
|
||||
preflightClineInstallation,
|
||||
prepareClineInstallation,
|
||||
prepareClineUninstall,
|
||||
} from './cline-installation.js';
|
||||
import { readHookFile, requireAvailableHookFile, runtimeFilesExist } from './cline-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
async function readHookPair() {
|
||||
const paths = resolveHookFiles();
|
||||
const [guardFile, auditFile] = await Promise.all([
|
||||
readHookFile(paths.guardPath),
|
||||
readHookFile(paths.auditPath),
|
||||
]);
|
||||
return { paths, guardFile, auditFile };
|
||||
}
|
||||
|
||||
function requireAvailablePair(
|
||||
guardFile: Awaited<ReturnType<typeof readHookFile>>,
|
||||
auditFile: Awaited<ReturnType<typeof readHookFile>>,
|
||||
): void {
|
||||
requireAvailableHookFile(guardFile);
|
||||
requireAvailableHookFile(auditFile);
|
||||
}
|
||||
|
||||
export const clinePlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const { guardFile, auditFile } = await readHookPair();
|
||||
requireAvailablePair(guardFile, auditFile);
|
||||
await preflightClineInstallation(config, [guardFile, auditFile]);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const { guardFile, auditFile } = await readHookPair();
|
||||
requireAvailablePair(guardFile, auditFile);
|
||||
const prepared = await prepareClineInstallation(config, guardFile, auditFile);
|
||||
await commitClineInstallation(prepared);
|
||||
console.log(' Cline: user-level PreToolUse and PostToolUse hooks installed.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const { guardFile, auditFile } = await readHookPair();
|
||||
const prepared = await prepareClineUninstall([guardFile, auditFile], agentId);
|
||||
await commitClineUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const { paths, guardFile, auditFile } = await readHookPair();
|
||||
const contract = runtimeContract(guardFile, auditFile);
|
||||
const hookConfigured = contract !== undefined;
|
||||
const hookScriptExists = contract ? await runtimeFilesExist(contract) : false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: paths.hooksDirectory,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import {
|
||||
encodedWindowsCommand,
|
||||
isNodeExecutable,
|
||||
parseEncodedWindowsCommand,
|
||||
parseLegacyWindowsCommand,
|
||||
parsePosixCommand,
|
||||
posixSource,
|
||||
} from './shell-command.js';
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildHandler(scriptPath: string, statusMessage: string): JsonObject {
|
||||
return {
|
||||
type: 'command',
|
||||
command: posixSource(scriptPath),
|
||||
commandWindows: encodedWindowsCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
statusMessage,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
if (typeof handler.command !== 'string' || typeof handler.commandWindows !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const posix = parsePosixCommand(handler.command);
|
||||
const windows = parseEncodedWindowsCommand(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,61 @@
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type RenderedDocument,
|
||||
} from './codex-contract.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
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 async function preflightCodexInstallation(
|
||||
config: InstallConfig,
|
||||
hooksPath: string,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareCodexInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: RenderedDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
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,
|
||||
hookSources: [{
|
||||
directoryLabel: HOOKS_DIRECTORY_LABEL,
|
||||
label: HOOKS_LABEL,
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.raw,
|
||||
source: hooksSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitCodexInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import fsp 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 { asError, errorMessage } from './common.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimePresent } from './managed-runtime-status.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
|
||||
if (!rendered.changed) return;
|
||||
const change = await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: 'Codex user hooks',
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: rendered.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (!change) return;
|
||||
await commitManagedTransaction({
|
||||
displayName: 'Codex',
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: 'Codex hooks directory',
|
||||
}],
|
||||
changes: [change],
|
||||
});
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimePresent(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function matcherGroup(handler: ReturnType<typeof buildHandler>): Record<string, unknown> {
|
||||
return { matcher: '*', hooks: [handler] };
|
||||
}
|
||||
|
||||
export const codexPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
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 ?? []),
|
||||
matcherGroup(buildHandler(paths.guardPath, GUARD_STATUS)),
|
||||
];
|
||||
hooks.PostToolUse = [
|
||||
...(hooks.PostToolUse ?? []),
|
||||
matcherGroup(buildHandler(paths.auditPath, AUDIT_STATUS)),
|
||||
];
|
||||
const prepared = await prepareCodexInstallation(config, renderDocument(document, hooks));
|
||||
await commitCodexInstallation(prepared);
|
||||
console.log(` Codex hooks: ${document.filePath}`);
|
||||
console.log(' Codex trust: run /hooks and approve both Elydora command hooks.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const document = await readDocument();
|
||||
const hooks = removeManagedHooks(document.hooks, agentId);
|
||||
await writeDocument(renderDocument(document, hooks));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const document = await readDocument();
|
||||
const contracts = runtimeContracts(document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: document.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MAX_SECRET_BYTES = 64 * 1024;
|
||||
export const MAX_CONFIG_BYTES = 512 * 1024;
|
||||
export const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function hasCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
export function pathKey(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
export function samePath(left: string, right: string): boolean {
|
||||
return pathKey(left) === pathKey(right);
|
||||
}
|
||||
|
||||
export function sameAgentId(left: unknown, right: string): boolean {
|
||||
return typeof left === 'string' && (process.platform === 'win32'
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right);
|
||||
}
|
||||
|
||||
export interface ManagedScriptReference {
|
||||
readonly agentId: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
// Accepts only ~/.elydora/<agentId>/<scriptName>.
|
||||
export function managedScriptReference(
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
): ManagedScriptReference | undefined {
|
||||
if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId && agentId !== '.' && agentId !== '..' ? { agentId, scriptPath } : undefined;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import {
|
||||
isNodeExecutable,
|
||||
parsePosixCommand,
|
||||
parsePowerShellSource,
|
||||
posixSource,
|
||||
powerShellSource,
|
||||
} from './shell-command.js';
|
||||
import { validateHooks, type CopilotHooks } from './copilot-schema.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'copilot';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
export const CONFIG_FILE = 'elydora-audit.json';
|
||||
|
||||
const MANAGED_EVENTS = [
|
||||
['preToolUse', GUARD_SCRIPT],
|
||||
['postToolUse', AUDIT_SCRIPT],
|
||||
['postToolUseFailure', AUDIT_SCRIPT],
|
||||
] as const;
|
||||
|
||||
export interface CopilotDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: CopilotHooks;
|
||||
readonly hooksDisabled: boolean;
|
||||
readonly raw?: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface CopilotSources {
|
||||
readonly user: CopilotDocument;
|
||||
readonly legacy?: CopilotDocument;
|
||||
readonly disabledBy?: string;
|
||||
readonly settingsPreconditions: readonly CopilotSourcePrecondition[];
|
||||
}
|
||||
|
||||
export interface CopilotSourcePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
readonly document: CopilotDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
interface ManagedEntry {
|
||||
readonly agentId: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
function agentKey(agentId: string): string {
|
||||
return process.platform === 'win32' ? agentId.toLowerCase() : agentId;
|
||||
}
|
||||
|
||||
export function buildHandler(scriptPath: string): JsonObject {
|
||||
return {
|
||||
type: 'command',
|
||||
bash: posixSource(scriptPath),
|
||||
powershell: powerShellSource(scriptPath),
|
||||
timeoutSec: HOOK_TIMEOUT_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
function exactHandlerKeys(handler: JsonObject): boolean {
|
||||
return Object.keys(handler).sort().join(',') === 'bash,powershell,timeoutSec,type';
|
||||
}
|
||||
|
||||
function currentManagedScriptPath(handler: JsonObject): string | undefined {
|
||||
if (!exactHandlerKeys(handler)
|
||||
|| handler.type !== 'command'
|
||||
|| handler.timeoutSec !== HOOK_TIMEOUT_SECONDS) return undefined;
|
||||
if (typeof handler.bash !== 'string' || typeof handler.powershell !== 'string') return undefined;
|
||||
const bash = parsePosixCommand(handler.bash);
|
||||
const powershell = parsePowerShellSource(handler.powershell);
|
||||
if (!bash || !powershell
|
||||
|| !path.isAbsolute(bash[0])
|
||||
|| !isNodeExecutable(bash[0])
|
||||
|| !samePath(bash[0], powershell[0])
|
||||
|| !samePath(bash[1], powershell[1])) return undefined;
|
||||
return bash[1];
|
||||
}
|
||||
|
||||
function parseLegacyCommand(command: unknown): string | undefined {
|
||||
if (typeof command !== 'string') return undefined;
|
||||
return /^node(?:\.exe)?\s+"([^"]+)"$/i.exec(command)?.[1];
|
||||
}
|
||||
|
||||
function legacyManagedScriptPath(handler: JsonObject): string | undefined {
|
||||
if (!exactHandlerKeys(handler)
|
||||
|| handler.type !== 'command'
|
||||
|| handler.timeoutSec !== 5) return undefined;
|
||||
const bash = parseLegacyCommand(handler.bash);
|
||||
const powershell = parseLegacyCommand(handler.powershell);
|
||||
return bash && powershell && samePath(bash, powershell) ? bash : undefined;
|
||||
}
|
||||
|
||||
function managedEntry(handler: JsonObject, scriptName: string): ManagedEntry | undefined {
|
||||
const scriptPath = currentManagedScriptPath(handler) ?? legacyManagedScriptPath(handler);
|
||||
if (!scriptPath || path.basename(scriptPath).toLowerCase() !== scriptName.toLowerCase()) {
|
||||
return undefined;
|
||||
}
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
if (!agentId || agentId === '.' || agentId === '..') return undefined;
|
||||
return { agentId, scriptPath };
|
||||
}
|
||||
|
||||
export function parseDocument(
|
||||
filePath: string,
|
||||
snapshot: FileSnapshot,
|
||||
label: string,
|
||||
): CopilotDocument {
|
||||
const root = parseStrictJsonObject(snapshot.contents, `${label} at ${filePath}`);
|
||||
if (root.version !== 1) throw new Error(`${label} at ${filePath} must declare version 1`);
|
||||
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
|
||||
throw new Error(`${label} at ${filePath} field "disableAllHooks" must be a boolean`);
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
root,
|
||||
hooks: validateHooks(root.hooks, `${label} at ${filePath}`),
|
||||
hooksDisabled: root.disableAllHooks === true,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDocument(filePath: string): CopilotDocument {
|
||||
return { exists: false, filePath, root: {}, hooks: {}, hooksDisabled: false };
|
||||
}
|
||||
|
||||
export function removeManagedHooks(hooks: CopilotHooks, agentId?: string): CopilotHooks {
|
||||
const next: CopilotHooks = { ...hooks };
|
||||
for (const [event, scriptName] of MANAGED_EVENTS) {
|
||||
const handlers = (next[event] ?? []).filter((handler) => {
|
||||
const owned = managedEntry(handler, scriptName);
|
||||
return !owned || (agentId !== undefined && !sameAgentId(owned.agentId, agentId));
|
||||
});
|
||||
if (handlers.length > 0) next[event] = handlers;
|
||||
else delete next[event];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isEmptyOwnedDocument(root: JsonObject, hooks: CopilotHooks): boolean {
|
||||
return Object.keys(hooks).length === 0
|
||||
&& Object.keys(root).every((key) => key === 'version' || key === 'hooks');
|
||||
}
|
||||
|
||||
export function renderDocument(
|
||||
document: CopilotDocument,
|
||||
hooks: CopilotHooks,
|
||||
): RenderedDocument {
|
||||
if (!document.exists && Object.keys(hooks).length === 0) {
|
||||
return { document, changed: false };
|
||||
}
|
||||
if (document.exists && isEmptyOwnedDocument(document.root, hooks)) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
const root: JsonObject = { ...document.root, version: 1 };
|
||||
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 managedEntries(handlers: JsonObject[], scriptName: string): Map<string, ManagedEntry> {
|
||||
const entries = new Map<string, ManagedEntry>();
|
||||
for (const handler of handlers) {
|
||||
const entry = managedEntry(handler, scriptName);
|
||||
if (entry) entries.set(agentKey(entry.agentId), entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function runtimeContracts(hooks: CopilotHooks): RuntimeContract[] {
|
||||
const guards = managedEntries(hooks.preToolUse ?? [], GUARD_SCRIPT);
|
||||
const successes = managedEntries(hooks.postToolUse ?? [], AUDIT_SCRIPT);
|
||||
const failures = managedEntries(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT);
|
||||
const contracts: RuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const success = successes.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (!success || !failure || !samePath(success.scriptPath, failure.scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard.agentId,
|
||||
guardPath: guard.scriptPath,
|
||||
auditPath: success.scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type CopilotSources,
|
||||
type RenderedDocument,
|
||||
} from './copilot-contract.js';
|
||||
import { MAX_SOURCE_BYTES, samePath } from './common.js';
|
||||
import { requireHooksEnabled } from './copilot-io.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'GitHub Copilot CLI';
|
||||
const DIRECTORY_LABEL = 'GitHub Copilot hooks directory';
|
||||
const SOURCE_LABEL = 'GitHub Copilot hook source';
|
||||
|
||||
function hookLocations(sources: CopilotSources) {
|
||||
return [sources.user, ...(sources.legacy ? [sources.legacy] : [])].map((document) => ({
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
filePath: document.filePath,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function preflightCopilotInstallation(
|
||||
config: InstallConfig,
|
||||
sources: CopilotSources,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
requireHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocations(sources),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function installationDocuments(
|
||||
sources: CopilotSources,
|
||||
rendered: readonly RenderedDocument[],
|
||||
): readonly RenderedDocument[] {
|
||||
return rendered.filter((item) => item.changed
|
||||
|| samePath(item.document.filePath, sources.user.filePath));
|
||||
}
|
||||
|
||||
export async function prepareCopilotInstallation(
|
||||
config: InstallConfig,
|
||||
sources: CopilotSources,
|
||||
rendered: readonly RenderedDocument[],
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
await preflightCopilotInstallation(config, sources);
|
||||
const documents = installationDocuments(sources, rendered);
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: documents.map((item) => ({
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: SOURCE_LABEL,
|
||||
filePath: item.document.filePath,
|
||||
expectedSource: item.document.raw,
|
||||
expectedSnapshot: item.document.snapshot,
|
||||
source: item.changed ? item.next : item.document.raw,
|
||||
})),
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
const legacyPrecondition = sources.legacy
|
||||
&& !documents.some((item) => samePath(item.document.filePath, sources.legacy!.filePath))
|
||||
? [{
|
||||
filePath: sources.legacy.filePath,
|
||||
label: 'GitHub Copilot legacy project hooks',
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: sources.legacy.snapshot,
|
||||
}]
|
||||
: [];
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: [
|
||||
...sources.settingsPreconditions.map((condition) => ({
|
||||
filePath: condition.filePath,
|
||||
label: condition.label,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: condition.snapshot,
|
||||
})),
|
||||
...legacyPrecondition,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitCopilotInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareCopilotUninstall(
|
||||
rendered: readonly RenderedDocument[],
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const changed = rendered.filter((item) => item.changed);
|
||||
const changes = await Promise.all(changed.map((item) => prepareManagedFileChange({
|
||||
filePath: item.document.filePath,
|
||||
label: SOURCE_LABEL,
|
||||
next: item.next,
|
||||
mode: 0o600,
|
||||
expectedSource: item.document.raw,
|
||||
expectedSnapshot: item.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})));
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: changed.map((item) => ({
|
||||
path: path.dirname(item.document.filePath),
|
||||
label: DIRECTORY_LABEL,
|
||||
})),
|
||||
changes: changes.filter((change) => change !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitCopilotUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
type CopilotDocument,
|
||||
type CopilotSources,
|
||||
type RuntimeContract,
|
||||
createDocument,
|
||||
parseDocument,
|
||||
} from './copilot-contract.js';
|
||||
import { MAX_SOURCE_BYTES } from './common.js';
|
||||
import {
|
||||
inspectPhysicalDirectory,
|
||||
readPhysicalFile,
|
||||
type FileSnapshot,
|
||||
} from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import {
|
||||
parseStrictJsonObject,
|
||||
parseStrictJsoncObject,
|
||||
type JsonObject,
|
||||
} from './strict-json.js';
|
||||
|
||||
export interface CopilotPaths {
|
||||
readonly copilotHome: string;
|
||||
readonly userHooksDirectory: string;
|
||||
readonly userHookPath: string;
|
||||
readonly legacyHookPath: string;
|
||||
readonly settingsLayers: readonly SettingsLayer[];
|
||||
readonly inspectedDirectories: readonly DirectoryLocation[];
|
||||
}
|
||||
|
||||
interface DirectoryLocation {
|
||||
readonly path: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
interface SettingsLayer {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly jsonc: boolean;
|
||||
}
|
||||
|
||||
interface ParsedSettingsLayer extends SettingsLayer {
|
||||
readonly disableAllHooks?: boolean;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export function resolveCopilotPaths(): CopilotPaths {
|
||||
const override = process.env.COPILOT_HOME;
|
||||
const copilotHome = override ? override : path.join(os.homedir(), '.copilot');
|
||||
const project = process.cwd();
|
||||
const github = path.join(project, '.github');
|
||||
const githubCopilot = path.join(github, 'copilot');
|
||||
const githubHooks = path.join(github, 'hooks');
|
||||
const claude = path.join(project, '.claude');
|
||||
return {
|
||||
copilotHome,
|
||||
userHooksDirectory: path.join(copilotHome, 'hooks'),
|
||||
userHookPath: path.join(copilotHome, 'hooks', CONFIG_FILE),
|
||||
legacyHookPath: path.join(githubHooks, 'hooks.json'),
|
||||
settingsLayers: [
|
||||
{ filePath: path.join(copilotHome, 'config.json'), label: 'legacy Copilot user config', jsonc: false },
|
||||
{ filePath: path.join(copilotHome, 'settings.json'), label: 'Copilot user settings', jsonc: true },
|
||||
{ filePath: path.join(claude, 'settings.json'), label: 'Claude repository settings', jsonc: true },
|
||||
{ filePath: path.join(claude, 'settings.local.json'), label: 'Claude local settings', jsonc: true },
|
||||
{ filePath: path.join(githubCopilot, 'settings.json'), label: 'Copilot repository settings', jsonc: true },
|
||||
{ filePath: path.join(githubCopilot, 'settings.local.json'), label: 'Copilot local settings', jsonc: true },
|
||||
],
|
||||
inspectedDirectories: [
|
||||
{ path: project, label: 'Copilot working directory' },
|
||||
{ path: copilotHome, label: 'COPILOT_HOME' },
|
||||
{ path: path.join(copilotHome, 'hooks'), label: 'Copilot user hooks directory' },
|
||||
{ path: github, label: 'GitHub configuration directory' },
|
||||
{ path: githubHooks, label: 'GitHub repository hooks directory' },
|
||||
{ path: githubCopilot, label: 'Copilot repository settings directory' },
|
||||
{ path: claude, label: 'Claude repository settings directory' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectDirectories(locations: readonly DirectoryLocation[]): Promise<void> {
|
||||
for (const location of locations) {
|
||||
await inspectPhysicalDirectory(location.path, location.label);
|
||||
}
|
||||
}
|
||||
|
||||
async function readHookDocument(
|
||||
filePath: string,
|
||||
label: string,
|
||||
): Promise<CopilotDocument | undefined> {
|
||||
const snapshot = await readPhysicalFile(filePath, label, MAX_SOURCE_BYTES);
|
||||
return snapshot ? parseDocument(filePath, snapshot, label) : undefined;
|
||||
}
|
||||
|
||||
function parseSettings(raw: string, layer: SettingsLayer): JsonObject {
|
||||
if (raw.trim().length === 0) return {};
|
||||
const label = `${layer.label} at ${layer.filePath}`;
|
||||
return layer.jsonc
|
||||
? parseStrictJsoncObject(raw, label)
|
||||
: parseStrictJsonObject(raw, label);
|
||||
}
|
||||
|
||||
async function readSettingsLayer(layer: SettingsLayer): Promise<ParsedSettingsLayer> {
|
||||
const snapshot = await readPhysicalFile(layer.filePath, layer.label, MAX_SOURCE_BYTES);
|
||||
if (!snapshot) return layer;
|
||||
const root = parseSettings(snapshot.contents, layer);
|
||||
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
|
||||
throw new Error(
|
||||
`${layer.label} at ${layer.filePath} field "disableAllHooks" must be a boolean`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...layer,
|
||||
disableAllHooks: root.disableAllHooks as boolean | undefined,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function effectiveDisabledSource(layers: readonly ParsedSettingsLayer[]): string | undefined {
|
||||
let disabledBy: string | undefined;
|
||||
for (const layer of layers) {
|
||||
if (layer.disableAllHooks === true) disabledBy = `${layer.label} at ${layer.filePath}`;
|
||||
else if (layer.disableAllHooks === false) disabledBy = undefined;
|
||||
}
|
||||
return disabledBy;
|
||||
}
|
||||
|
||||
export async function readSources(): Promise<CopilotSources> {
|
||||
const paths = resolveCopilotPaths();
|
||||
await inspectDirectories(paths.inspectedDirectories);
|
||||
const [user, legacy] = await Promise.all([
|
||||
readHookDocument(paths.userHookPath, 'GitHub Copilot user hooks'),
|
||||
readHookDocument(paths.legacyHookPath, 'GitHub Copilot legacy project hooks'),
|
||||
]);
|
||||
const layers = await Promise.all(paths.settingsLayers.map(readSettingsLayer));
|
||||
const userDocument = user ?? createDocument(paths.userHookPath);
|
||||
const disabledBy = userDocument.hooksDisabled
|
||||
? `GitHub Copilot user hooks at ${paths.userHookPath}`
|
||||
: effectiveDisabledSource(layers);
|
||||
return {
|
||||
user: userDocument,
|
||||
legacy,
|
||||
disabledBy,
|
||||
settingsPreconditions: layers.map((layer) => ({
|
||||
filePath: layer.filePath,
|
||||
label: layer.label,
|
||||
snapshot: layer.snapshot,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function requireHooksEnabled(sources: CopilotSources): void {
|
||||
if (sources.disabledBy) {
|
||||
throw new Error(
|
||||
`GitHub Copilot hooks are disabled by ${sources.disabledBy}; set disableAllHooks to false before installation`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export type CopilotHooks = Record<string, JsonObject[]>;
|
||||
|
||||
const SUPPORTED_EVENTS = new Set([
|
||||
'agentStop',
|
||||
'Stop',
|
||||
'errorOccurred',
|
||||
'ErrorOccurred',
|
||||
'notification',
|
||||
'Notification',
|
||||
'permissionRequest',
|
||||
'PermissionRequest',
|
||||
'postToolUse',
|
||||
'PostToolUse',
|
||||
'postToolUseFailure',
|
||||
'PostToolUseFailure',
|
||||
'preCompact',
|
||||
'PreCompact',
|
||||
'preToolUse',
|
||||
'PreToolUse',
|
||||
'sessionEnd',
|
||||
'SessionEnd',
|
||||
'sessionStart',
|
||||
'SessionStart',
|
||||
'subagentStart',
|
||||
'SubagentStart',
|
||||
'subagentStop',
|
||||
'SubagentStop',
|
||||
'userPromptSubmitted',
|
||||
'UserPromptSubmit',
|
||||
'userPromptTransformed',
|
||||
]);
|
||||
|
||||
function fieldLabel(label: string, field: string): string {
|
||||
return `${label} field "${field}"`;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateOptionalString(value: unknown, label: string): void {
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTimeout(handler: JsonObject, label: string): void {
|
||||
for (const field of ['timeout', 'timeoutSec']) {
|
||||
const value = handler[field];
|
||||
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)) {
|
||||
throw new Error(`${fieldLabel(label, field)} must be a positive number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMatcher(handler: JsonObject, event: string, label: string): void {
|
||||
if (handler.matcher === undefined) return;
|
||||
const matcher = requireString(handler.matcher, fieldLabel(label, 'matcher'));
|
||||
if ((event === 'PreToolUse' || event === 'PermissionRequest')
|
||||
&& (matcher === '*' || matcher === '**')) return;
|
||||
try {
|
||||
new RegExp(`^(?:${matcher})$`);
|
||||
} catch (error) {
|
||||
throw new Error(`${fieldLabel(label, 'matcher')} is not a valid regular expression`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateStringMap(value: unknown, label: string): void {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
requireString(item, `${label}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCommand(handler: JsonObject, event: string, label: string): void {
|
||||
const commands = ['bash', 'powershell', 'command']
|
||||
.map((field) => handler[field])
|
||||
.filter((value) => value !== undefined);
|
||||
if (commands.length === 0) {
|
||||
throw new Error(`${label} must define bash, powershell, or command`);
|
||||
}
|
||||
for (const field of ['bash', 'powershell', 'command', 'cwd']) {
|
||||
validateOptionalString(handler[field], fieldLabel(label, field));
|
||||
}
|
||||
if (handler.env !== undefined) validateStringMap(handler.env, fieldLabel(label, 'env'));
|
||||
validateTimeout(handler, label);
|
||||
validateMatcher(handler, event, label);
|
||||
}
|
||||
|
||||
function isLoopback(hostname: string): boolean {
|
||||
return hostname === 'localhost'
|
||||
|| hostname === '::1'
|
||||
|| hostname.startsWith('127.');
|
||||
}
|
||||
|
||||
function validateHttp(handler: JsonObject, event: string, label: string): void {
|
||||
const rawUrl = requireString(handler.url, fieldLabel(label, 'url'));
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`${fieldLabel(label, 'url')} is invalid`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
const localhostAllowed = process.env.COPILOT_HOOK_ALLOW_LOCALHOST === '1'
|
||||
&& url.protocol === 'http:'
|
||||
&& isLoopback(url.hostname);
|
||||
if (url.protocol !== 'https:' && !localhostAllowed) {
|
||||
throw new Error(`${fieldLabel(label, 'url')} must use HTTPS`);
|
||||
}
|
||||
if (handler.headers !== undefined) {
|
||||
validateStringMap(handler.headers, fieldLabel(label, 'headers'));
|
||||
}
|
||||
if (handler.allowedEnvVars !== undefined) {
|
||||
if (!Array.isArray(handler.allowedEnvVars)
|
||||
|| handler.allowedEnvVars.some((value) => typeof value !== 'string' || !value)) {
|
||||
throw new Error(`${fieldLabel(label, 'allowedEnvVars')} must be an array of strings`);
|
||||
}
|
||||
}
|
||||
validateTimeout(handler, label);
|
||||
validateMatcher(handler, event, label);
|
||||
}
|
||||
|
||||
function validatePrompt(handler: JsonObject, event: string, label: string): void {
|
||||
if (event !== 'sessionStart' && event !== 'SessionStart') {
|
||||
throw new Error(`${label} prompt hooks are supported only for sessionStart`);
|
||||
}
|
||||
requireString(handler.prompt, fieldLabel(label, 'prompt'));
|
||||
}
|
||||
|
||||
function validateHandler(handler: JsonObject, event: string, label: string): void {
|
||||
const type = handler.type ?? 'command';
|
||||
if (type === 'command') validateCommand(handler, event, label);
|
||||
else if (type === 'http') validateHttp(handler, event, label);
|
||||
else if (type === 'prompt') validatePrompt(handler, event, label);
|
||||
else throw new Error(`${fieldLabel(label, 'type')} is unsupported`);
|
||||
}
|
||||
|
||||
export function validateHooks(value: unknown, label: string): CopilotHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
|
||||
const hooks: CopilotHooks = {};
|
||||
for (const [event, handlers] of Object.entries(value)) {
|
||||
if (!SUPPORTED_EVENTS.has(event)) throw new Error(`${label} hook event "${event}" is unsupported`);
|
||||
if (!Array.isArray(handlers)) throw new Error(`${label} field "hooks.${event}" must be an array`);
|
||||
hooks[event] = handlers.map((handler, index) => {
|
||||
const itemLabel = `${label} handler hooks.${event}[${index}]`;
|
||||
if (!isObject(handler)) throw new Error(`${itemLabel} must be an object`);
|
||||
validateHandler(handler, event, itemLabel);
|
||||
return handler;
|
||||
});
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
+81
-137
@@ -1,164 +1,108 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildHandler,
|
||||
removeManagedHooks,
|
||||
renderDocument,
|
||||
runtimeContracts,
|
||||
type CopilotSources,
|
||||
type RenderedDocument,
|
||||
} from './copilot-contract.js';
|
||||
import {
|
||||
commitCopilotInstallation,
|
||||
commitCopilotUninstall,
|
||||
preflightCopilotInstallation,
|
||||
prepareCopilotInstallation,
|
||||
prepareCopilotUninstall,
|
||||
} from './copilot-installation.js';
|
||||
import { readSources, runtimeFilesExist } from './copilot-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'copilot';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return path.join(process.cwd(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
function renderInstallation(
|
||||
sources: CopilotSources,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): RenderedDocument[] {
|
||||
const userHooks = removeManagedHooks(sources.user.hooks);
|
||||
userHooks.preToolUse = [...(userHooks.preToolUse ?? []), buildHandler(guardPath)];
|
||||
userHooks.postToolUse = [...(userHooks.postToolUse ?? []), buildHandler(auditPath)];
|
||||
userHooks.postToolUseFailure = [
|
||||
...(userHooks.postToolUseFailure ?? []),
|
||||
buildHandler(auditPath),
|
||||
];
|
||||
const rendered = [renderDocument(sources.user, userHooks)];
|
||||
if (sources.legacy) {
|
||||
rendered.push(renderDocument(
|
||||
sources.legacy,
|
||||
removeManagedHooks(sources.legacy.hooks),
|
||||
));
|
||||
}
|
||||
return true;
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((h) => {
|
||||
if (typeof h.bash === 'string' && isElydoraCommand(h.bash, agentId)) return false;
|
||||
if (typeof h.powershell === 'string' && isElydoraCommand(h.powershell, agentId)) return false;
|
||||
return true;
|
||||
});
|
||||
function renderUninstall(sources: CopilotSources, agentId?: string): RenderedDocument[] {
|
||||
const rendered = [
|
||||
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)),
|
||||
];
|
||||
if (sources.legacy) {
|
||||
rendered.push(renderDocument(
|
||||
sources.legacy,
|
||||
removeManagedHooks(sources.legacy.hooks, agentId),
|
||||
));
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function mergedContracts(sources: CopilotSources) {
|
||||
const contracts = [
|
||||
...runtimeContracts(sources.user.hooks),
|
||||
...runtimeContracts(sources.legacy?.hooks ?? {}),
|
||||
];
|
||||
const unique = new Map(contracts.map((contract) => [
|
||||
process.platform === 'win32' ? contract.agentId.toLowerCase() : contract.agentId,
|
||||
contract,
|
||||
]));
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
export const copilotPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readSources();
|
||||
await preflightCopilotInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = { version: 1 };
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
settings.version = 1;
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
type: 'command',
|
||||
bash: buildHookCommand(config.guardScriptPath),
|
||||
powershell: buildHookCommand(config.guardScriptPath),
|
||||
timeoutSec: 5,
|
||||
});
|
||||
hooks.preToolUse = preFiltered;
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
type: 'command',
|
||||
bash: buildHookCommand(config.hookScriptPath),
|
||||
powershell: buildHookCommand(config.hookScriptPath),
|
||||
timeoutSec: 5,
|
||||
});
|
||||
hooks.postToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readSources();
|
||||
const paths = await preflightCopilotInstallation(config, sources);
|
||||
const rendered = renderInstallation(sources, paths.guardPath, paths.auditPath);
|
||||
const prepared = await prepareCopilotInstallation(config, sources, rendered);
|
||||
await commitCopilotInstallation(prepared);
|
||||
console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`);
|
||||
console.log(' GitHub Copilot CLI: restart active sessions to load the updated hooks.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readSources();
|
||||
const prepared = await prepareCopilotUninstall(renderUninstall(sources, agentId));
|
||||
await commitCopilotUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const check = (arr: unknown) =>
|
||||
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
||||
(h) =>
|
||||
(typeof h.bash === 'string' && h.bash.includes('elydora')) ||
|
||||
(typeof h.powershell === 'string' && h.powershell.includes('elydora')),
|
||||
);
|
||||
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
||||
|
||||
// Extract hook script path from postToolUse bash/powershell command
|
||||
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
|
||||
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
|
||||
const cmd = (h.bash || h.powershell) as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readSources();
|
||||
const contracts = mergedContracts(sources);
|
||||
const hookConfigured = sources.disabledBy === undefined && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.user.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import type { GuardScriptOptions } from './guard-template.js';
|
||||
import type { HookScriptOptions } from './hook-template.js';
|
||||
import {
|
||||
isNodeExecutable,
|
||||
parsePosixCommand,
|
||||
parsePowerShellSource,
|
||||
posixSource,
|
||||
powerShellSource,
|
||||
} from './shell-command.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'cursor';
|
||||
export const CONFIG_FILE = 'hooks.json';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
export const GUARD_OPTIONS: GuardScriptOptions = {
|
||||
failClosed: true,
|
||||
successOutput: '{"permission":"allow"}\n',
|
||||
denyProtocol: 'cursor',
|
||||
};
|
||||
export const AUDIT_OPTIONS: HookScriptOptions = {
|
||||
failClosed: true,
|
||||
nativePayload: true,
|
||||
successOutput: '{}\n',
|
||||
};
|
||||
|
||||
export type CursorHooks = Record<string, JsonObject[]>;
|
||||
|
||||
export interface CursorDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: CursorHooks;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
readonly document: CursorDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export function buildHandler(scriptPath: string): JsonObject {
|
||||
const command = process.platform === 'win32'
|
||||
? powerShellSource(scriptPath)
|
||||
: posixSource(scriptPath);
|
||||
return {
|
||||
command,
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
failClosed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyCommand(command: string): readonly [string, string] | undefined {
|
||||
const match = /^(node(?:\.exe)?)\s+"([^"\r\n]+)"$/i.exec(command);
|
||||
return match ? [match[1], match[2]] : undefined;
|
||||
}
|
||||
|
||||
function managedScriptPath(handler: JsonObject): string | undefined {
|
||||
const keys = Object.keys(handler);
|
||||
if (keys.length === 3
|
||||
&& typeof handler.command === 'string'
|
||||
&& handler.timeout === HOOK_TIMEOUT_SECONDS
|
||||
&& handler.failClosed === true) {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellSource(handler.command)
|
||||
: parsePosixCommand(handler.command);
|
||||
if (parsed
|
||||
&& path.isAbsolute(parsed[0])
|
||||
&& path.isAbsolute(parsed[1])
|
||||
&& isNodeExecutable(parsed[0])) return parsed[1];
|
||||
}
|
||||
if (keys.length !== 1 || typeof handler.command !== 'string') return undefined;
|
||||
const legacy = parseLegacyCommand(handler.command);
|
||||
return legacy && isNodeExecutable(legacy[0]) && path.isAbsolute(legacy[1])
|
||||
? legacy[1]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedAgentId(handler: JsonObject, scriptName: string): string | undefined {
|
||||
const scriptPath = managedScriptPath(handler);
|
||||
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): CursorHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
|
||||
const hooks: CursorHooks = {};
|
||||
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((handler, index) => {
|
||||
if (!isObject(handler)) {
|
||||
throw new Error(`${label} handler hooks.${event}[${index}] must be an object`);
|
||||
}
|
||||
return handler;
|
||||
});
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function parseDocument(filePath: string, raw: string): CursorDocument {
|
||||
const label = `Cursor user hooks at ${filePath}`;
|
||||
const root = parseStrictJsonObject(raw, label);
|
||||
const hooks = readHooks(root.hooks, label);
|
||||
const hasVersion = Object.prototype.hasOwnProperty.call(root, 'version');
|
||||
if (root.version !== 1 && (hasVersion || !containsManagedHook(hooks))) {
|
||||
throw new Error(`${label} must declare version 1`);
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
root,
|
||||
hooks,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDocument(filePath: string): CursorDocument {
|
||||
return { exists: false, filePath, root: {}, hooks: {} };
|
||||
}
|
||||
|
||||
export function removeManagedHooks(hooks: CursorHooks, agentId?: string): CursorHooks {
|
||||
const next: CursorHooks = Object.fromEntries(
|
||||
Object.entries(hooks).map(([event, handlers]) => [event, [...handlers]]),
|
||||
);
|
||||
for (const [event, scriptName] of [
|
||||
['preToolUse', GUARD_SCRIPT],
|
||||
['postToolUse', AUDIT_SCRIPT],
|
||||
['postToolUseFailure', AUDIT_SCRIPT],
|
||||
] as const) {
|
||||
const handlers = (next[event] ?? []).filter((handler) => {
|
||||
const managedId = managedAgentId(handler, scriptName);
|
||||
return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId));
|
||||
});
|
||||
if (handlers.length > 0) next[event] = handlers;
|
||||
else delete next[event];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function entirelyManaged(document: CursorDocument): boolean {
|
||||
if (!document.exists
|
||||
|| !Object.keys(document.root).every((key) => key === 'version' || key === 'hooks')) {
|
||||
return false;
|
||||
}
|
||||
const events = Object.entries(document.hooks);
|
||||
if (events.length === 0) return false;
|
||||
let handlerCount = 0;
|
||||
for (const [event, handlers] of events) {
|
||||
const scriptName = event === 'preToolUse'
|
||||
? GUARD_SCRIPT
|
||||
: event === 'postToolUse' || event === 'postToolUseFailure'
|
||||
? AUDIT_SCRIPT
|
||||
: undefined;
|
||||
if (!scriptName || handlers.length === 0) return false;
|
||||
handlerCount += handlers.length;
|
||||
if (handlers.some((handler) => !managedAgentId(handler, scriptName))) return false;
|
||||
}
|
||||
return handlerCount > 0;
|
||||
}
|
||||
|
||||
export function renderDocument(
|
||||
document: CursorDocument,
|
||||
hooks: CursorHooks,
|
||||
): 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, version: 1 };
|
||||
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 managedIds(handlers: JsonObject[], scriptName: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const handler of handlers) {
|
||||
const agentId = managedAgentId(handler, scriptName);
|
||||
if (!agentId) continue;
|
||||
const key = process.platform === 'win32' ? agentId.toLowerCase() : agentId;
|
||||
result.set(key, agentId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function containsManagedHook(hooks: CursorHooks): boolean {
|
||||
return [
|
||||
...(hooks.preToolUse ?? []).map((handler) => managedAgentId(handler, GUARD_SCRIPT)),
|
||||
...(hooks.postToolUse ?? []).map((handler) => managedAgentId(handler, AUDIT_SCRIPT)),
|
||||
...(hooks.postToolUseFailure ?? []).map((handler) => managedAgentId(handler, AUDIT_SCRIPT)),
|
||||
].some((agentId) => agentId !== undefined);
|
||||
}
|
||||
|
||||
export function runtimeContracts(hooks: CursorHooks): RuntimeContract[] {
|
||||
const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT);
|
||||
const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT);
|
||||
const failures = managedIds(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT);
|
||||
const root = path.join(os.homedir(), '.elydora');
|
||||
return [...guards]
|
||||
.filter(([key]) => audits.has(key) && failures.has(key))
|
||||
.map(([, agentId]) => ({
|
||||
agentId,
|
||||
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
||||
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_OPTIONS,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_OPTIONS,
|
||||
GUARD_SCRIPT,
|
||||
type RenderedDocument,
|
||||
} from './cursor-contract.js';
|
||||
import { cursorConfigPath } from './cursor-io.js';
|
||||
|
||||
const DISPLAY_NAME = 'Cursor';
|
||||
const HOOKS_DIRECTORY_LABEL = 'Cursor hooks directory';
|
||||
const HOOKS_LABEL = 'Cursor user hooks';
|
||||
|
||||
export async function preflightCursorInstallation(
|
||||
config: InstallConfig,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: HOOKS_DIRECTORY_LABEL,
|
||||
filePath: cursorConfigPath(),
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareCursorInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: RenderedDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
if (!rendered.changed && rendered.document.raw === undefined) {
|
||||
throw new Error('Cursor hook installation did not produce a configuration document');
|
||||
}
|
||||
const hooksSource = rendered.next ?? rendered.document.raw;
|
||||
if (hooksSource === undefined) throw new Error('Cursor hook configuration is missing');
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: HOOKS_DIRECTORY_LABEL,
|
||||
label: HOOKS_LABEL,
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.raw,
|
||||
source: hooksSource,
|
||||
}],
|
||||
config,
|
||||
guardOptions: GUARD_OPTIONS,
|
||||
auditOptions: AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitCursorInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
type CursorDocument,
|
||||
type RenderedDocument,
|
||||
type RuntimeContract,
|
||||
createDocument,
|
||||
parseDocument,
|
||||
} from './cursor-contract.js';
|
||||
import { readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimePresent } from './managed-runtime-status.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
export function cursorConfigPath(): string {
|
||||
return path.join(os.homedir(), '.cursor', CONFIG_FILE);
|
||||
}
|
||||
|
||||
export async function readDocument(): Promise<CursorDocument> {
|
||||
const filePath = cursorConfigPath();
|
||||
const snapshot = await readPhysicalFile(filePath, 'Cursor user hooks');
|
||||
return snapshot ? parseDocument(filePath, snapshot.contents) : createDocument(filePath);
|
||||
}
|
||||
|
||||
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
|
||||
if (!rendered.changed) return;
|
||||
const change = await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: 'Cursor user hooks',
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: rendered.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (!change) return;
|
||||
await commitManagedTransaction({
|
||||
displayName: 'Cursor',
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: 'Cursor hooks directory',
|
||||
}],
|
||||
changes: [change],
|
||||
});
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimePresent(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+47
-128
@@ -1,151 +1,70 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildHandler,
|
||||
removeManagedHooks,
|
||||
renderDocument,
|
||||
runtimeContracts,
|
||||
} from './cursor-contract.js';
|
||||
import {
|
||||
commitCursorInstallation,
|
||||
preflightCursorInstallation,
|
||||
prepareCursorInstallation,
|
||||
} from './cursor-installation.js';
|
||||
import {
|
||||
readDocument,
|
||||
runtimeFilesExist,
|
||||
writeDocument,
|
||||
} from './cursor-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'cursor';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
// .cursor is project-relative, but for global install use home dir
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter(
|
||||
(h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId),
|
||||
);
|
||||
}
|
||||
|
||||
export const cursorPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
await readDocument();
|
||||
await preflightCursorInstallation(config);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
const document = await readDocument();
|
||||
const paths = await preflightCursorInstallation(config);
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({ command: buildHookCommand(config.guardScriptPath) });
|
||||
hooks.preToolUse = preFiltered;
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({ command: buildHookCommand(config.hookScriptPath) });
|
||||
hooks.postToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
settings.version = 1;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const hooks = removeManagedHooks(document.hooks);
|
||||
hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(paths.guardPath)];
|
||||
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(paths.auditPath)];
|
||||
hooks.postToolUseFailure = [
|
||||
...(hooks.postToolUseFailure ?? []),
|
||||
buildHandler(paths.auditPath),
|
||||
];
|
||||
const prepared = await prepareCursorInstallation(
|
||||
config,
|
||||
renderDocument(document, hooks),
|
||||
);
|
||||
await commitCursorInstallation(prepared);
|
||||
console.log(` Cursor hooks: ${document.filePath}`);
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const document = await readDocument();
|
||||
const hooks = removeManagedHooks(document.hooks, agentId);
|
||||
await writeDocument(renderDocument(document, hooks));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const check = (arr: unknown) =>
|
||||
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
||||
|
||||
// Extract hook script path from postToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
|
||||
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const document = await readDocument();
|
||||
const contracts = runtimeContracts(document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: document.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { JSONPath } from 'jsonc-parser';
|
||||
import { changeJsonc } from './jsonc-edit.js';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import type { DroidPolicyState } from './droid-policy.js';
|
||||
import {
|
||||
TOOL_EVENTS,
|
||||
type DroidGroup,
|
||||
type DroidHookMap,
|
||||
type ToolEvent,
|
||||
managedRemovals,
|
||||
readHookMap,
|
||||
} from './droid-contract.js';
|
||||
import {
|
||||
isObject,
|
||||
parseStrictJsoncObject,
|
||||
type JsonObject,
|
||||
} from './strict-json.js';
|
||||
|
||||
export const OWNED_FILE_MARKER = '// Managed by Elydora';
|
||||
|
||||
export type DroidDocumentKind = 'hooks' | 'legacy' | 'settings' | 'local-settings';
|
||||
|
||||
export interface DroidDocument {
|
||||
readonly kind: DroidDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: DroidHookMap;
|
||||
readonly basePath: JSONPath;
|
||||
readonly hasHooksContainer: boolean;
|
||||
readonly hooksDisabled?: boolean;
|
||||
readonly showHookOutput?: boolean;
|
||||
readonly ownedFile: boolean;
|
||||
}
|
||||
|
||||
export interface DroidSources {
|
||||
readonly root: DroidDocument;
|
||||
readonly legacy: DroidDocument;
|
||||
readonly settings: DroidDocument;
|
||||
readonly localSettings: DroidDocument;
|
||||
readonly policy: DroidPolicyState;
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
readonly document: DroidDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly kind: DroidDocumentKind;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
function hasOwn(value: JsonObject, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function labelFor(kind: DroidDocumentKind, filePath: string): string {
|
||||
if (kind === 'settings') return `Factory Droid settings at ${filePath}`;
|
||||
if (kind === 'local-settings') return `Factory Droid local settings at ${filePath}`;
|
||||
if (kind === 'legacy') return `Factory Droid legacy hooks at ${filePath}`;
|
||||
return `Factory Droid hooks at ${filePath}`;
|
||||
}
|
||||
|
||||
function optionalBoolean(root: JsonObject, field: string, label: string): boolean | undefined {
|
||||
const value = root[field];
|
||||
if (value !== undefined && typeof value !== 'boolean') {
|
||||
throw new Error(`${label} field "${field}" must be a boolean`);
|
||||
}
|
||||
return value as boolean | undefined;
|
||||
}
|
||||
|
||||
function legacyDirectHooks(root: JsonObject, label: string): {
|
||||
readonly hooks: DroidHookMap;
|
||||
readonly hooksDisabled?: boolean;
|
||||
readonly showHookOutput?: boolean;
|
||||
} {
|
||||
const hooksDisabled = optionalBoolean(root, 'hooksDisabled', label);
|
||||
const showHookOutput = optionalBoolean(root, 'showHookOutput', label);
|
||||
const hookEntries = Object.fromEntries(
|
||||
Object.entries(root).filter(([key]) => key !== 'hooksDisabled' && key !== 'showHookOutput'),
|
||||
);
|
||||
return { hooks: readHookMap(hookEntries, label), hooksDisabled, showHookOutput };
|
||||
}
|
||||
|
||||
export function parseDocument(options: DocumentOptions): DroidDocument {
|
||||
const label = labelFor(options.kind, options.filePath);
|
||||
const root = parseStrictJsoncObject(options.raw, label);
|
||||
if (options.kind === 'settings' || options.kind === 'local-settings') {
|
||||
const hasHooksContainer = hasOwn(root, 'hooks');
|
||||
const hooks = hasHooksContainer
|
||||
? readHookMap(root.hooks, `${label} field "hooks"`)
|
||||
: {};
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks,
|
||||
basePath: ['hooks'],
|
||||
hasHooksContainer,
|
||||
hooksDisabled: optionalBoolean(root, 'hooksDisabled', label),
|
||||
showHookOutput: optionalBoolean(root, 'showHookOutput', label),
|
||||
ownedFile: false,
|
||||
};
|
||||
}
|
||||
if (hasOwn(root, 'hooks')) {
|
||||
if (!isObject(root.hooks)) throw new Error(`${label} field "hooks" must be an object`);
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readHookMap(root.hooks, `${label} field "hooks"`),
|
||||
basePath: ['hooks'],
|
||||
hasHooksContainer: true,
|
||||
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
|
||||
};
|
||||
}
|
||||
const legacy = legacyDirectHooks(root, label);
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
...legacy,
|
||||
basePath: [],
|
||||
hasHooksContainer: false,
|
||||
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSettingsDocument(
|
||||
filePath: string,
|
||||
kind: 'settings' | 'local-settings' = 'settings',
|
||||
): DroidDocument {
|
||||
return parseDocument({ exists: false, filePath, kind, raw: '{}\n' });
|
||||
}
|
||||
|
||||
export function createLegacyHookDocument(filePath: string): DroidDocument {
|
||||
return parseDocument({ exists: false, filePath, kind: 'legacy', raw: '{}\n' });
|
||||
}
|
||||
|
||||
export function createOwnedHookDocument(filePath: string): DroidDocument {
|
||||
return parseDocument({
|
||||
exists: false,
|
||||
filePath,
|
||||
kind: 'hooks',
|
||||
raw: `${OWNED_FILE_MARKER}\n{\n "hooks": {}\n}\n`,
|
||||
});
|
||||
}
|
||||
|
||||
function eventPath(document: DroidDocument, event: string): JSONPath {
|
||||
return [...document.basePath, event];
|
||||
}
|
||||
|
||||
function currentDocument(document: DroidDocument, raw: string): DroidDocument {
|
||||
return parseDocument({
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
kind: document.kind,
|
||||
raw,
|
||||
snapshot: document.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function removeManagedEntries(document: DroidDocument, raw: string, agentId?: string): string {
|
||||
const removals = managedRemovals(document.hooks, agentId);
|
||||
for (const event of TOOL_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath = [...eventPath(document, event), removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = changeJsonc(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort((left, right) => right - left)) {
|
||||
raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if ((current.hooks[event] ?? []).length === 0) {
|
||||
raw = changeJsonc(raw, eventPath(document, event), undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: DroidDocument,
|
||||
raw: string,
|
||||
event: ToolEvent,
|
||||
group: DroidGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
if (Object.prototype.hasOwnProperty.call(current.hooks, event)) {
|
||||
const groups = current.hooks[event] ?? [];
|
||||
return changeJsonc(raw, [...eventPath(document, event), groups.length], group, true);
|
||||
}
|
||||
return changeJsonc(raw, eventPath(document, event), [group]);
|
||||
}
|
||||
|
||||
function hookFileIsEmpty(document: DroidDocument, raw: string): boolean {
|
||||
if (document.kind === 'settings' || document.kind === 'local-settings') return false;
|
||||
const current = currentDocument(document, raw);
|
||||
const remainingRootFields = Object.keys(current.root).filter((key) => key !== 'hooks');
|
||||
return Object.keys(current.hooks).length === 0 && remainingRootFields.length === 0;
|
||||
}
|
||||
|
||||
export function renderDocument(
|
||||
document: DroidDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ToolEvent, DroidGroup>,
|
||||
): RenderedDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of TOOL_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
currentDocument(document, raw);
|
||||
if (additions.size === 0
|
||||
&& document.exists
|
||||
&& document.ownedFile
|
||||
&& hookFileIsEmpty(document, raw)) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
export function activeDocument(sources: DroidSources): DroidDocument {
|
||||
if (sources.root.exists) return sources.root;
|
||||
if (sources.legacy.exists) return sources.legacy;
|
||||
if (sources.localSettings.hasHooksContainer) return sources.localSettings;
|
||||
if (sources.settings.hasHooksContainer) return sources.settings;
|
||||
return sources.root;
|
||||
}
|
||||
|
||||
export function effectiveHooks(sources: DroidSources): DroidHookMap {
|
||||
return activeDocument(sources).hooks;
|
||||
}
|
||||
|
||||
export interface DroidHookBlock {
|
||||
readonly field: 'allowManagedHooksOnly' | 'hooksDisabled';
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export function hookBlock(sources: DroidSources): DroidHookBlock | undefined {
|
||||
if (sources.policy.allowManagedHooksOnlyBy) {
|
||||
return {
|
||||
field: 'allowManagedHooksOnly',
|
||||
...sources.policy.allowManagedHooksOnlyBy,
|
||||
};
|
||||
}
|
||||
if (sources.policy.hooksDisabled !== undefined) {
|
||||
return sources.policy.hooksDisabled ? {
|
||||
field: 'hooksDisabled',
|
||||
...sources.policy.hooksDisabledBy!,
|
||||
} : undefined;
|
||||
}
|
||||
const selected = sources.localSettings.hooksDisabled !== undefined
|
||||
? sources.localSettings
|
||||
: sources.settings;
|
||||
if (selected.hooksDisabled === true) {
|
||||
return {
|
||||
field: 'hooksDisabled',
|
||||
filePath: selected.filePath,
|
||||
label: labelFor(selected.kind, selected.filePath),
|
||||
};
|
||||
}
|
||||
const active = activeDocument(sources);
|
||||
return active.hooksDisabled === true
|
||||
? {
|
||||
field: 'hooksDisabled',
|
||||
filePath: active.filePath,
|
||||
label: labelFor(active.kind, active.filePath),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function sourceDocuments(sources: DroidSources): DroidDocument[] {
|
||||
return [sources.root, sources.legacy, sources.settings, sources.localSettings];
|
||||
}
|
||||
|
||||
export function installationDocuments(sources: DroidSources): DroidDocument[] {
|
||||
const target = activeDocument(sources);
|
||||
const documents = [
|
||||
sources.root.exists || target === sources.root ? sources.root : undefined,
|
||||
sources.legacy.exists ? sources.legacy : undefined,
|
||||
sources.settings.hasHooksContainer ? sources.settings : undefined,
|
||||
sources.localSettings.hasHooksContainer ? sources.localSettings : undefined,
|
||||
];
|
||||
const unique = new Map<string, DroidDocument>();
|
||||
for (const document of documents) {
|
||||
if (document) unique.set(document.filePath, document);
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
export function additionsForTarget(
|
||||
document: DroidDocument,
|
||||
target: DroidDocument,
|
||||
groups: ReadonlyMap<ToolEvent, DroidGroup>,
|
||||
): ReadonlyMap<ToolEvent, DroidGroup> {
|
||||
return document === target ? groups : new Map();
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import {
|
||||
parseLegacyWindowsCommand,
|
||||
parsePosixCommand,
|
||||
parsePowerShellSource,
|
||||
posixSource,
|
||||
powerShellSource,
|
||||
} from './shell-command.js';
|
||||
import type { JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'droid';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
export const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'] as const;
|
||||
|
||||
const HANDLER_KEYS = ['command', 'timeout', 'type'];
|
||||
const GROUP_KEYS = ['hooks', 'matcher'];
|
||||
|
||||
export type ToolEvent = typeof TOOL_EVENTS[number];
|
||||
|
||||
export interface DroidHandler extends JsonObject {
|
||||
readonly type: 'command';
|
||||
readonly command: string;
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface DroidGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly commandRegex?: string;
|
||||
readonly hooks: DroidHandler[];
|
||||
}
|
||||
|
||||
export interface DroidHookMap extends JsonObject {
|
||||
readonly PreToolUse?: DroidGroup[];
|
||||
readonly PostToolUse?: DroidGroup[];
|
||||
}
|
||||
|
||||
export interface RuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRemoval {
|
||||
readonly event: ToolEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
export function buildCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Factory Droid hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32'
|
||||
? powerShellSource(scriptPath)
|
||||
: posixSource(scriptPath);
|
||||
}
|
||||
|
||||
export function buildGroup(scriptPath: string): DroidGroup {
|
||||
return {
|
||||
matcher: '*',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: buildCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function validateRegex(value: string, label: string, allowWildcard: boolean): void {
|
||||
if (allowWildcard && (value === '*' || value === '')) return;
|
||||
try {
|
||||
new RegExp(value);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${label} must be a valid regular expression: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(
|
||||
value: unknown,
|
||||
label: string,
|
||||
groupIndex: number,
|
||||
handlerIndex: number,
|
||||
): DroidHandler {
|
||||
const location = `${label}[${groupIndex}].hooks[${handlerIndex}]`;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${location} must be an object`);
|
||||
}
|
||||
const handler = value as JsonObject;
|
||||
if (handler.type !== 'command') throw new Error(`${location} type must be "command"`);
|
||||
if (typeof handler.command !== 'string' || handler.command.trim().length === 0) {
|
||||
throw new Error(`${location} command must be a non-empty string`);
|
||||
}
|
||||
if (handler.timeout !== undefined
|
||||
&& (typeof handler.timeout !== 'number'
|
||||
|| !Number.isFinite(handler.timeout)
|
||||
|| handler.timeout <= 0)) {
|
||||
throw new Error(`${location} timeout must be a positive finite number`);
|
||||
}
|
||||
return handler as DroidHandler;
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, label: string, groupIndex: number): DroidGroup {
|
||||
const location = `${label}[${groupIndex}]`;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${location} must be an object`);
|
||||
}
|
||||
const group = value as JsonObject;
|
||||
if (group.matcher !== undefined) {
|
||||
if (typeof group.matcher !== 'string') throw new Error(`${location} matcher must be a string`);
|
||||
validateRegex(group.matcher, `${location} matcher`, true);
|
||||
}
|
||||
if (group.commandRegex !== undefined) {
|
||||
if (typeof group.commandRegex !== 'string') {
|
||||
throw new Error(`${location} commandRegex must be a string`);
|
||||
}
|
||||
validateRegex(group.commandRegex, `${location} commandRegex`, false);
|
||||
}
|
||||
if (!Array.isArray(group.hooks)) throw new Error(`${location} must contain a hooks array`);
|
||||
return {
|
||||
...group,
|
||||
hooks: group.hooks.map(
|
||||
(handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex),
|
||||
),
|
||||
} as DroidGroup;
|
||||
}
|
||||
|
||||
export function readHookMap(value: unknown, label: string): DroidHookMap {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${label} must contain a JSON object`);
|
||||
}
|
||||
const hooks: DroidHookMap = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (!Array.isArray(groups)) throw new Error(`${label} field "${event}" must be an array`);
|
||||
hooks[event] = groups.map(
|
||||
(group, groupIndex) => validateGroup(group, `${label} field "${event}"`, groupIndex),
|
||||
);
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
function parseGeneratedCommand(
|
||||
command: string,
|
||||
includeLegacy: boolean,
|
||||
): readonly [string, string] | undefined {
|
||||
if (process.platform !== 'win32') return parsePosixCommand(command);
|
||||
return parsePowerShellSource(command)
|
||||
?? (includeLegacy ? parseLegacyWindowsCommand(command) : undefined);
|
||||
}
|
||||
|
||||
export function managedAgentId(
|
||||
handler: DroidHandler,
|
||||
scriptName: string,
|
||||
includeLegacy = false,
|
||||
): string | undefined {
|
||||
if (Object.keys(handler).sort().join('\0') !== HANDLER_KEYS.join('\0')
|
||||
|| handler.type !== 'command'
|
||||
|| handler.timeout !== HOOK_TIMEOUT_SECONDS) return undefined;
|
||||
const parsed = parseGeneratedCommand(handler.command, includeLegacy);
|
||||
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
|
||||
const scriptPath = parsed[1];
|
||||
if (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 exactOwnedGroup(group: DroidGroup, managedIndexes: number[]): boolean {
|
||||
return Object.keys(group).sort().join('\0') === GROUP_KEYS.join('\0')
|
||||
&& group.matcher === '*'
|
||||
&& managedIndexes.length > 0
|
||||
&& managedIndexes.length === group.hooks.length;
|
||||
}
|
||||
|
||||
export function managedRemovals(hooks: DroidHookMap, agentId?: string): ManagedRemoval[] {
|
||||
const removals: ManagedRemoval[] = [];
|
||||
for (const [event, scriptName] of [
|
||||
['PreToolUse', GUARD_SCRIPT],
|
||||
['PostToolUse', AUDIT_SCRIPT],
|
||||
] as const) {
|
||||
const groups = hooks[event] ?? [];
|
||||
groups.forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const managedId = managedAgentId(handler, scriptName, true);
|
||||
return managedId && (agentId === undefined || sameAgentId(managedId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactOwnedGroup(group, handlerIndexes),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function configuredIds(groups: DroidGroup[], scriptName: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const group of groups) {
|
||||
if (Object.keys(group).sort().join('\0') !== GROUP_KEYS.join('\0')
|
||||
|| group.matcher !== '*'
|
||||
|| group.hooks.length !== 1) continue;
|
||||
const agentId = managedAgentId(group.hooks[0], scriptName);
|
||||
if (agentId) ids.add(agentId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function runtimeContracts(hooks: DroidHookMap): RuntimeContract[] {
|
||||
const guards = configuredIds(hooks.PreToolUse ?? [], GUARD_SCRIPT);
|
||||
const audits = configuredIds(hooks.PostToolUse ?? [], AUDIT_SCRIPT);
|
||||
const root = path.join(os.homedir(), '.elydora');
|
||||
return [...guards]
|
||||
.filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId)))
|
||||
.map((agentId) => ({
|
||||
agentId,
|
||||
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
||||
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import { MAX_SOURCE_BYTES, samePath } from './common.js';
|
||||
import { AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT } from './droid-contract.js';
|
||||
import {
|
||||
activeDocument,
|
||||
installationDocuments,
|
||||
sourceDocuments,
|
||||
type DroidDocument,
|
||||
type DroidSources,
|
||||
type RenderedDocument,
|
||||
} from './droid-config.js';
|
||||
import { requireHooksEnabled } from './droid-io.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Factory Droid';
|
||||
const DIRECTORY_LABEL = 'Factory Droid user configuration directory';
|
||||
|
||||
function sourceLabel(document: DroidDocument): string {
|
||||
if (document.kind === 'settings') return 'Factory Droid user settings';
|
||||
if (document.kind === 'local-settings') return 'Factory Droid local settings';
|
||||
if (document.kind === 'legacy') return 'Factory Droid legacy hooks';
|
||||
return 'Factory Droid user hooks';
|
||||
}
|
||||
|
||||
function hookLocations(sources: DroidSources) {
|
||||
return installationDocuments(sources).map((document) => ({
|
||||
directoryLabel: document.kind === 'legacy'
|
||||
? 'Factory Droid legacy hooks directory'
|
||||
: DIRECTORY_LABEL,
|
||||
filePath: document.filePath,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function preflightDroidInstallation(
|
||||
config: InstallConfig,
|
||||
sources: DroidSources,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
requireHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocations(sources),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function installationChanges(
|
||||
sources: DroidSources,
|
||||
rendered: readonly RenderedDocument[],
|
||||
): readonly RenderedDocument[] {
|
||||
const target = activeDocument(sources);
|
||||
return rendered.filter((item) => item.changed || item.document === target);
|
||||
}
|
||||
|
||||
function sourcePreconditions(
|
||||
sources: DroidSources,
|
||||
changed: readonly RenderedDocument[],
|
||||
) {
|
||||
const changedPaths = changed.map((item) => item.document.filePath);
|
||||
return sourceDocuments(sources)
|
||||
.filter((document) => !changedPaths.some((filePath) => samePath(filePath, document.filePath)))
|
||||
.map((document) => ({
|
||||
filePath: document.filePath,
|
||||
label: sourceLabel(document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: document.snapshot,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function prepareDroidInstallation(
|
||||
config: InstallConfig,
|
||||
sources: DroidSources,
|
||||
rendered: readonly RenderedDocument[],
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
await preflightDroidInstallation(config, sources);
|
||||
const changed = installationChanges(sources, rendered);
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: changed.map((item) => ({
|
||||
directoryLabel: item.document.kind === 'legacy'
|
||||
? 'Factory Droid legacy hooks directory'
|
||||
: DIRECTORY_LABEL,
|
||||
label: sourceLabel(item.document),
|
||||
filePath: item.document.filePath,
|
||||
expectedSource: item.document.exists ? item.document.raw : undefined,
|
||||
expectedSnapshot: item.document.snapshot,
|
||||
source: item.changed ? item.next : item.document.raw,
|
||||
})),
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: [
|
||||
...sourcePreconditions(sources, changed),
|
||||
...sources.policy.preconditions,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitDroidInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareDroidUninstall(
|
||||
rendered: readonly RenderedDocument[],
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const changed = rendered.filter((item) => item.changed);
|
||||
const changes = await Promise.all(changed.map((item) => prepareManagedFileChange({
|
||||
filePath: item.document.filePath,
|
||||
label: sourceLabel(item.document),
|
||||
next: item.next,
|
||||
mode: 0o600,
|
||||
expectedSource: item.document.raw,
|
||||
expectedSnapshot: item.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})));
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: changed.map((item) => ({
|
||||
path: path.dirname(item.document.filePath),
|
||||
label: item.document.kind === 'legacy'
|
||||
? 'Factory Droid legacy hooks directory'
|
||||
: DIRECTORY_LABEL,
|
||||
})),
|
||||
changes: changes.filter((change) => change !== undefined),
|
||||
preconditions: rendered
|
||||
.filter((item) => !item.changed)
|
||||
.map((item) => ({
|
||||
filePath: item.document.filePath,
|
||||
label: sourceLabel(item.document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: item.document.snapshot,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitDroidUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { MAX_SOURCE_BYTES } from './common.js';
|
||||
import { AGENT_KEY, type RuntimeContract } from './droid-contract.js';
|
||||
import {
|
||||
activeDocument,
|
||||
createLegacyHookDocument,
|
||||
createOwnedHookDocument,
|
||||
createSettingsDocument,
|
||||
hookBlock,
|
||||
parseDocument,
|
||||
type DroidDocument,
|
||||
type DroidDocumentKind,
|
||||
type DroidSources,
|
||||
} from './droid-config.js';
|
||||
import { readDroidPolicy } from './droid-policy.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
interface FactoryPaths {
|
||||
readonly directory: string;
|
||||
readonly root: string;
|
||||
readonly legacyDirectory: string;
|
||||
readonly legacy: string;
|
||||
readonly settings: string;
|
||||
readonly localSettings: string;
|
||||
}
|
||||
|
||||
function factoryPaths(): FactoryPaths {
|
||||
const directory = path.join(os.homedir(), '.factory');
|
||||
const legacyDirectory = path.join(directory, 'hooks');
|
||||
return {
|
||||
directory,
|
||||
root: path.join(directory, 'hooks.json'),
|
||||
legacyDirectory,
|
||||
legacy: path.join(legacyDirectory, 'hooks.json'),
|
||||
settings: path.join(directory, 'settings.json'),
|
||||
localSettings: path.join(directory, 'settings.local.json'),
|
||||
};
|
||||
}
|
||||
|
||||
async function readDocument(
|
||||
filePath: string,
|
||||
kind: DroidDocumentKind,
|
||||
label: string,
|
||||
): Promise<DroidDocument | undefined> {
|
||||
const snapshot = await readPhysicalFile(filePath, label, MAX_SOURCE_BYTES);
|
||||
return snapshot ? parseDocument({
|
||||
exists: true,
|
||||
filePath,
|
||||
kind,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
}) : undefined;
|
||||
}
|
||||
|
||||
export async function readSources(): Promise<DroidSources> {
|
||||
const paths = factoryPaths();
|
||||
await inspectPhysicalDirectory(paths.directory, 'Factory Droid user configuration directory');
|
||||
await inspectPhysicalDirectory(paths.legacyDirectory, 'Factory Droid legacy hooks directory');
|
||||
const [root, legacy, settings, localSettings, policy] = await Promise.all([
|
||||
readDocument(paths.root, 'hooks', 'Factory Droid user hooks'),
|
||||
readDocument(paths.legacy, 'legacy', 'Factory Droid legacy hooks'),
|
||||
readDocument(paths.settings, 'settings', 'Factory Droid user settings'),
|
||||
readDocument(paths.localSettings, 'local-settings', 'Factory Droid local settings'),
|
||||
readDroidPolicy(),
|
||||
]);
|
||||
return {
|
||||
root: root ?? createOwnedHookDocument(paths.root),
|
||||
legacy: legacy ?? createLegacyHookDocument(paths.legacy),
|
||||
settings: settings ?? createSettingsDocument(paths.settings),
|
||||
localSettings: localSettings
|
||||
?? createSettingsDocument(paths.localSettings, 'local-settings'),
|
||||
policy,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireHooksEnabled(sources: DroidSources): void {
|
||||
const blocked = hookBlock(sources);
|
||||
if (blocked) {
|
||||
throw new Error(
|
||||
`Factory Droid user hooks are disabled by ${blocked.field} in ${blocked.label} at ${blocked.filePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function displayConfigPath(sources: DroidSources): string {
|
||||
return activeDocument(sources).filePath;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { hasCode, MAX_SOURCE_BYTES } from './common.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||
import { parseStrictJsoncObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
interface PolicyLocation {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
interface PolicyLayer extends PolicyLocation {
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly hooksDisabled?: boolean;
|
||||
readonly allowManagedHooksOnly?: boolean;
|
||||
readonly showHookOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface DroidPolicyPrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface DroidPolicyOrigin {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface DroidPolicyState {
|
||||
readonly allowManagedHooksOnlyBy?: DroidPolicyOrigin;
|
||||
readonly hooksDisabled?: boolean;
|
||||
readonly hooksDisabledBy?: DroidPolicyOrigin;
|
||||
readonly preconditions: readonly DroidPolicyPrecondition[];
|
||||
}
|
||||
|
||||
function optionalBoolean(root: JsonObject, field: string, label: string): boolean | undefined {
|
||||
const value = root[field];
|
||||
if (value !== undefined && typeof value !== 'boolean') {
|
||||
throw new Error(`${label} field "${field}" must be a boolean`);
|
||||
}
|
||||
return value as boolean | undefined;
|
||||
}
|
||||
|
||||
function managedSettingsPath(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return '/Library/Application Support/Factory/settings.json';
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Factory', 'settings.json');
|
||||
}
|
||||
return '/etc/factory/settings.json';
|
||||
}
|
||||
|
||||
async function readLayer(location: PolicyLocation): Promise<PolicyLayer> {
|
||||
await inspectPhysicalDirectory(path.dirname(location.filePath), `${location.label} directory`);
|
||||
const snapshot = await readPhysicalFile(location.filePath, location.label, MAX_SOURCE_BYTES);
|
||||
if (!snapshot) return location;
|
||||
const root = parseStrictJsoncObject(snapshot.contents, `${location.label} at ${location.filePath}`);
|
||||
return {
|
||||
...location,
|
||||
snapshot,
|
||||
hooksDisabled: optionalBoolean(root, 'hooksDisabled', location.label),
|
||||
allowManagedHooksOnly: optionalBoolean(root, 'allowManagedHooksOnly', location.label),
|
||||
showHookOutput: optionalBoolean(root, 'showHookOutput', location.label),
|
||||
};
|
||||
}
|
||||
|
||||
async function gitRoot(start: string): Promise<string | undefined> {
|
||||
let current = path.resolve(start);
|
||||
for (;;) {
|
||||
const marker = path.join(current, '.git');
|
||||
try {
|
||||
const metadata = await fsp.lstat(marker);
|
||||
if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) {
|
||||
throw new Error(`Factory Droid project marker is not physical: ${marker}`);
|
||||
}
|
||||
return current;
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT') && !hasCode(error, 'ENOTDIR')) throw error;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function projectDirectories(root: string, current: string): string[] {
|
||||
const directories = [root];
|
||||
const relative = path.relative(root, current);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return directories;
|
||||
let directory = root;
|
||||
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
||||
directory = path.join(directory, segment);
|
||||
directories.push(directory);
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
async function projectLocations(): Promise<PolicyLocation[][]> {
|
||||
const current = path.resolve(process.cwd());
|
||||
const root = await gitRoot(current) ?? current;
|
||||
return projectDirectories(root, current).map((directory, index) => {
|
||||
const factory = path.join(directory, '.factory');
|
||||
const scope = index === 0 ? 'project' : `folder ${directory}`;
|
||||
return [{
|
||||
filePath: path.join(factory, 'settings.json'),
|
||||
label: `Factory Droid ${scope} settings`,
|
||||
}, {
|
||||
filePath: path.join(factory, 'settings.local.json'),
|
||||
label: `Factory Droid ${scope} local settings`,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function scopeValue(layers: readonly PolicyLayer[], field: 'hooksDisabled'): PolicyLayer | undefined {
|
||||
const [settings, local] = layers;
|
||||
return local[field] !== undefined ? local : settings[field] !== undefined ? settings : undefined;
|
||||
}
|
||||
|
||||
export async function readDroidPolicy(): Promise<DroidPolicyState> {
|
||||
const managedLocation: PolicyLocation = {
|
||||
filePath: managedSettingsPath(),
|
||||
label: 'Factory Droid system-managed settings',
|
||||
};
|
||||
const scopes = await projectLocations();
|
||||
const [managed, ...projectLayers] = await Promise.all([
|
||||
readLayer(managedLocation),
|
||||
...scopes.map(async (scope) => Promise.all(scope.map(readLayer))),
|
||||
]);
|
||||
const flattened = [managed, ...projectLayers.flat()];
|
||||
const allowManagedHooksOnlyBy = managed.allowManagedHooksOnly === true
|
||||
? { filePath: managed.filePath, label: managed.label }
|
||||
: undefined;
|
||||
const selected = [managed, ...projectLayers.map((scope) => scopeValue(scope, 'hooksDisabled'))]
|
||||
.find((layer) => layer?.hooksDisabled !== undefined);
|
||||
return {
|
||||
allowManagedHooksOnlyBy,
|
||||
hooksDisabled: selected?.hooksDisabled,
|
||||
hooksDisabledBy: selected
|
||||
? { filePath: selected.filePath, label: selected.label }
|
||||
: undefined,
|
||||
preconditions: flattened.map((layer) => ({
|
||||
filePath: layer.filePath,
|
||||
label: layer.label,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: layer.snapshot,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildGroup,
|
||||
runtimeContracts,
|
||||
type DroidGroup,
|
||||
type ToolEvent,
|
||||
} from './droid-contract.js';
|
||||
import {
|
||||
activeDocument,
|
||||
additionsForTarget,
|
||||
effectiveHooks,
|
||||
hookBlock,
|
||||
installationDocuments,
|
||||
renderDocument,
|
||||
sourceDocuments,
|
||||
type DroidSources,
|
||||
type RenderedDocument,
|
||||
} from './droid-config.js';
|
||||
import {
|
||||
commitDroidInstallation,
|
||||
commitDroidUninstall,
|
||||
preflightDroidInstallation,
|
||||
prepareDroidInstallation,
|
||||
prepareDroidUninstall,
|
||||
} from './droid-installation.js';
|
||||
import { displayConfigPath, readSources, runtimeFilesExist } from './droid-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function renderInstallation(
|
||||
sources: DroidSources,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): RenderedDocument[] {
|
||||
const target = activeDocument(sources);
|
||||
const groups = new Map<ToolEvent, DroidGroup>([
|
||||
['PreToolUse', buildGroup(guardPath)],
|
||||
['PostToolUse', buildGroup(auditPath)],
|
||||
]);
|
||||
return installationDocuments(sources).map((document) => renderDocument(
|
||||
document,
|
||||
undefined,
|
||||
additionsForTarget(document, target, groups),
|
||||
));
|
||||
}
|
||||
|
||||
function renderUninstall(sources: DroidSources, agentId?: string): RenderedDocument[] {
|
||||
return sourceDocuments(sources).map(
|
||||
(document) => renderDocument(document, agentId, new Map()),
|
||||
);
|
||||
}
|
||||
|
||||
export const droidPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readSources();
|
||||
await preflightDroidInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const sources = await readSources();
|
||||
const paths = await preflightDroidInstallation(config, sources);
|
||||
const rendered = renderInstallation(sources, paths.guardPath, paths.auditPath);
|
||||
const prepared = await prepareDroidInstallation(config, sources, rendered);
|
||||
await commitDroidInstallation(prepared);
|
||||
console.log(` Factory Droid hooks: ${activeDocument(sources).filePath}`);
|
||||
console.log(' Factory Droid: run /hooks to review the Elydora hook changes.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readSources();
|
||||
const prepared = await prepareDroidUninstall(renderUninstall(sources, agentId));
|
||||
await commitDroidUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const sources = await readSources();
|
||||
const contracts = runtimeContracts(effectiveHooks(sources));
|
||||
const hookConfigured = hookBlock(sources) === undefined && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: displayConfigPath(sources),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { samePath } from './common.js';
|
||||
import {
|
||||
decodePowerShellSource,
|
||||
encodePowerShellSource,
|
||||
isNodeExecutable,
|
||||
isPowerShellExecutable,
|
||||
parsePosixCommand,
|
||||
parsePowerShellSource,
|
||||
posixSource,
|
||||
quotePowerShell,
|
||||
windowsPowerShellPath,
|
||||
} from './shell-command.js';
|
||||
|
||||
export interface GeminiRuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
function windowsCommand(scriptPath: string): string {
|
||||
return `& ${quotePowerShell(windowsPowerShellPath())} -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellSource(scriptPath)}`;
|
||||
}
|
||||
|
||||
export function buildGeminiCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Gemini CLI hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32'
|
||||
? windowsCommand(scriptPath)
|
||||
: posixSource(scriptPath);
|
||||
}
|
||||
|
||||
function parseWindowsCommand(command: string): readonly [string, string] | undefined {
|
||||
const match = /^& ('(?:[^']|'')+') -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command);
|
||||
if (!match) return undefined;
|
||||
const powershell = parsePowerShellSource(`& ${match[1]} ''; exit $LASTEXITCODE`);
|
||||
if (!powershell || !isPowerShellExecutable(powershell[0])) return undefined;
|
||||
return decodePowerShellSource(match[2]);
|
||||
}
|
||||
|
||||
function parseLegacyCommand(command: string): readonly [string, string] | undefined {
|
||||
const match = /^node "([^"\r\n]+)"$/.exec(command);
|
||||
return match ? ['node', match[1]] : undefined;
|
||||
}
|
||||
|
||||
export function geminiRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
includeLegacy = false,
|
||||
): GeminiRuntimeReference | undefined {
|
||||
const parsed = parsePosixCommand(command)
|
||||
?? parseWindowsCommand(command)
|
||||
?? (includeLegacy ? parseLegacyCommand(command) : undefined);
|
||||
if (!parsed
|
||||
|| !isNodeExecutable(parsed[0])
|
||||
|| !path.isAbsolute(parsed[1])
|
||||
|| path.basename(parsed[1]) !== scriptName) return undefined;
|
||||
if (parsed[0] !== 'node' && !path.isAbsolute(parsed[0])) return undefined;
|
||||
const agentDirectory = path.dirname(parsed[1]);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
if (!agentId || agentId === '.' || agentId === '..') return undefined;
|
||||
return { agentId, scriptPath: parsed[1] };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { JSONPath } from 'jsonc-parser';
|
||||
import {
|
||||
MANAGED_EVENTS,
|
||||
managedGeminiRemovals,
|
||||
readGeminiHookControls,
|
||||
readGeminiHooks,
|
||||
type GeminiGroup,
|
||||
type GeminiHookControls,
|
||||
type GeminiHooks,
|
||||
type ManagedGeminiEvent,
|
||||
} from './gemini-contract.js';
|
||||
import { changeJsonc } from './jsonc-edit.js';
|
||||
import { parseCommentedJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const GEMINI_OWNED_FILE_MARKER = '// Managed by Elydora';
|
||||
|
||||
export interface GeminiDocument {
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: GeminiHooks;
|
||||
readonly hookControls: GeminiHookControls;
|
||||
readonly hasHooksContainer: boolean;
|
||||
readonly ownedFile: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedGeminiDocument {
|
||||
readonly document: GeminiDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw: string;
|
||||
}
|
||||
|
||||
export function parseGeminiDocument(options: DocumentOptions): GeminiDocument {
|
||||
const label = `Gemini CLI user settings at ${options.filePath}`;
|
||||
const root = parseCommentedJsonObject(options.raw, label);
|
||||
const hasHooksContainer = Object.hasOwn(root, 'hooks');
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readGeminiHooks(root.hooks),
|
||||
hookControls: readGeminiHookControls(root.hooksConfig),
|
||||
hasHooksContainer,
|
||||
ownedFile: options.raw.startsWith(GEMINI_OWNED_FILE_MARKER),
|
||||
};
|
||||
}
|
||||
|
||||
export function createGeminiDocument(filePath: string): GeminiDocument {
|
||||
return parseGeminiDocument({
|
||||
exists: false,
|
||||
filePath,
|
||||
raw: `${GEMINI_OWNED_FILE_MARKER}\n{}\n`,
|
||||
});
|
||||
}
|
||||
|
||||
function currentDocument(document: GeminiDocument, raw: string): GeminiDocument {
|
||||
return parseGeminiDocument({
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
function removeManagedEntries(
|
||||
document: GeminiDocument,
|
||||
raw: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
const removals = managedGeminiRemovals(document.hooks, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = changeJsonc(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||
(left, right) => right - left,
|
||||
)) {
|
||||
raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if ((current.hooks[event] ?? []).length === 0) {
|
||||
raw = changeJsonc(raw, ['hooks', event], undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||
raw = changeJsonc(raw, ['hooks'], undefined);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: GeminiDocument,
|
||||
raw: string,
|
||||
event: ManagedGeminiEvent,
|
||||
group: GeminiGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hooks[event]) {
|
||||
return changeJsonc(raw, ['hooks', event, current.hooks[event].length], group, true);
|
||||
}
|
||||
return changeJsonc(raw, ['hooks', event], [group]);
|
||||
}
|
||||
|
||||
export function renderGeminiDocument(
|
||||
document: GeminiDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ManagedGeminiEvent, GeminiGroup>,
|
||||
): RenderedGeminiDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (additions.size === 0
|
||||
&& document.ownedFile
|
||||
&& Object.keys(current.root).length === 0) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { sameAgentId } from './common.js';
|
||||
import {
|
||||
buildGeminiCommand,
|
||||
geminiRuntimeReference,
|
||||
type GeminiRuntimeReference,
|
||||
} from './gemini-command.js';
|
||||
import type { GuardScriptOptions } from './guard-template.js';
|
||||
import type { HookScriptOptions } from './hook-template.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'gemini';
|
||||
export const CONFIG_FILE = 'settings.json';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_HOOK_NAME = 'elydora-guard';
|
||||
export const AUDIT_HOOK_NAME = 'elydora-audit';
|
||||
export const HOOK_TIMEOUT_MS = 10_000;
|
||||
export const MANAGED_EVENTS = ['BeforeTool', 'AfterTool'] as const;
|
||||
export const GUARD_OPTIONS: GuardScriptOptions = { successOutput: '{}\n' };
|
||||
export const AUDIT_OPTIONS: HookScriptOptions = { nativePayload: true, successOutput: '{}\n' };
|
||||
|
||||
const KNOWN_EVENTS = new Set([
|
||||
'BeforeTool',
|
||||
'AfterTool',
|
||||
'BeforeAgent',
|
||||
'Notification',
|
||||
'AfterAgent',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'PreCompress',
|
||||
'BeforeModel',
|
||||
'AfterModel',
|
||||
'BeforeToolSelection',
|
||||
]);
|
||||
|
||||
export type ManagedGeminiEvent = (typeof MANAGED_EVENTS)[number];
|
||||
|
||||
export interface GeminiHandler extends JsonObject {
|
||||
readonly type: 'command';
|
||||
readonly command?: string;
|
||||
readonly name?: string;
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface GeminiGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly sequential?: boolean;
|
||||
readonly hooks: GeminiHandler[];
|
||||
}
|
||||
|
||||
export type GeminiHooks = Record<string, GeminiGroup[]>;
|
||||
|
||||
export interface GeminiHookControls {
|
||||
readonly enabled: boolean;
|
||||
readonly disabled: readonly string[];
|
||||
}
|
||||
|
||||
export interface GeminiRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRemoval {
|
||||
readonly event: ManagedGeminiEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: readonly number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
function optionalString(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined && typeof value[field] !== 'string') {
|
||||
throw new Error(`${label} field "${field}" must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEnvironment(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (!isObject(value)
|
||||
|| Object.values(value).some((entry) => typeof entry !== 'string')) {
|
||||
throw new Error(`${label} env must map names to strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(
|
||||
value: unknown,
|
||||
event: string,
|
||||
groupIndex: number,
|
||||
handlerIndex: number,
|
||||
): GeminiHandler {
|
||||
const label = `Gemini CLI settings handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.type !== 'command') {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
optionalString(value, 'name', label);
|
||||
optionalString(value, 'description', label);
|
||||
optionalString(value, 'source', label);
|
||||
validateTimeout(value.timeout, label);
|
||||
validateEnvironment(value.env, label);
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
return value as GeminiHandler;
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, event: string, groupIndex: number): GeminiGroup {
|
||||
const label = `Gemini CLI settings group hooks.${event}[${groupIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
if (value.sequential !== undefined && typeof value.sequential !== 'boolean') {
|
||||
throw new Error(`${label} sequential must be a boolean`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map(
|
||||
(handler, handlerIndex) => validateHandler(handler, event, groupIndex, handlerIndex),
|
||||
),
|
||||
} as GeminiGroup;
|
||||
}
|
||||
|
||||
export function readGeminiHooks(value: unknown): GeminiHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Gemini CLI settings field "hooks" must be an object');
|
||||
const hooks: GeminiHooks = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Gemini CLI settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = KNOWN_EVENTS.has(event)
|
||||
? groups.map((group, index) => validateGroup(group, event, index))
|
||||
: groups as GeminiGroup[];
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function readGeminiHookControls(value: unknown): GeminiHookControls {
|
||||
if (value === undefined) return { enabled: true, disabled: [] };
|
||||
if (!isObject(value)) {
|
||||
throw new Error('Gemini CLI settings field "hooksConfig" must be an object');
|
||||
}
|
||||
const supported = new Set(['enabled', 'disabled', 'notifications']);
|
||||
const extra = Object.keys(value).find((field) => !supported.has(field));
|
||||
if (extra) {
|
||||
throw new Error(`Gemini CLI settings field "hooksConfig" contains unsupported field "${extra}"`);
|
||||
}
|
||||
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
||||
throw new Error('Gemini CLI settings field "hooksConfig.enabled" must be a boolean');
|
||||
}
|
||||
if (value.notifications !== undefined && typeof value.notifications !== 'boolean') {
|
||||
throw new Error('Gemini CLI settings field "hooksConfig.notifications" must be a boolean');
|
||||
}
|
||||
if (value.disabled !== undefined && (
|
||||
!Array.isArray(value.disabled)
|
||||
|| value.disabled.some((item) => typeof item !== 'string')
|
||||
)) {
|
||||
throw new Error('Gemini CLI settings field "hooksConfig.disabled" must be an array of strings');
|
||||
}
|
||||
return {
|
||||
enabled: value.enabled !== false,
|
||||
disabled: value.disabled === undefined ? [] : value.disabled as string[],
|
||||
};
|
||||
}
|
||||
|
||||
export function managedGeminiHooksEnabled(controls: GeminiHookControls): boolean {
|
||||
return controls.enabled
|
||||
&& !controls.disabled.includes(GUARD_HOOK_NAME)
|
||||
&& !controls.disabled.includes(AUDIT_HOOK_NAME);
|
||||
}
|
||||
|
||||
export function disabledManagedGeminiEntries(
|
||||
controls: GeminiHookControls,
|
||||
): readonly string[] {
|
||||
return controls.disabled.filter((entry) => (
|
||||
entry === GUARD_HOOK_NAME
|
||||
|| entry === AUDIT_HOOK_NAME
|
||||
|| geminiRuntimeReference(entry, GUARD_SCRIPT, true) !== undefined
|
||||
|| geminiRuntimeReference(entry, AUDIT_SCRIPT, true) !== undefined
|
||||
));
|
||||
}
|
||||
|
||||
export function buildGeminiGroup(scriptPath: string, name: string): GeminiGroup {
|
||||
return {
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
name,
|
||||
command: buildGeminiCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactManagedGroup(group: GeminiGroup): boolean {
|
||||
return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks');
|
||||
}
|
||||
|
||||
function currentManagedReference(
|
||||
handler: GeminiHandler,
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
): GeminiRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|name|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.name === hookName
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? geminiRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function legacyManagedReference(
|
||||
handler: GeminiHandler,
|
||||
scriptName: string,
|
||||
): GeminiRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|type'
|
||||
&& handler.type === 'command'
|
||||
&& typeof handler.command === 'string'
|
||||
? geminiRuntimeReference(handler.command, scriptName, true)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: GeminiHandler,
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
includeLegacy: boolean,
|
||||
): GeminiRuntimeReference | undefined {
|
||||
return currentManagedReference(handler, scriptName, hookName)
|
||||
?? (includeLegacy ? legacyManagedReference(handler, scriptName) : undefined);
|
||||
}
|
||||
|
||||
export function managedGeminiRemovals(
|
||||
hooks: GeminiHooks,
|
||||
agentId?: string,
|
||||
): ManagedRemoval[] {
|
||||
const removals: ManagedRemoval[] = [];
|
||||
for (const [event, scriptName, hookName] of [
|
||||
['BeforeTool', GUARD_SCRIPT, GUARD_HOOK_NAME],
|
||||
['AfterTool', AUDIT_SCRIPT, AUDIT_HOOK_NAME],
|
||||
] as const) {
|
||||
(hooks[event] ?? []).forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const reference = managedReference(handler, scriptName, hookName, true);
|
||||
return reference
|
||||
&& (agentId === undefined || sameAgentId(reference.agentId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactManagedGroup(group) && handlerIndexes.length === group.hooks.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly GeminiGroup[],
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
): Map<string, GeminiRuntimeReference[]> {
|
||||
const references = new Map<string, GeminiRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactManagedGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = currentManagedReference(handler, scriptName, hookName);
|
||||
if (!reference) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function geminiRuntimeContracts(hooks: GeminiHooks): GeminiRuntimeContract[] {
|
||||
const guards = referencesForEvent(hooks.BeforeTool ?? [], GUARD_SCRIPT, GUARD_HOOK_NAME);
|
||||
const audits = referencesForEvent(hooks.AfterTool ?? [], AUDIT_SCRIPT, AUDIT_HOOK_NAME);
|
||||
const contracts: GeminiRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const audit = audits.get(key);
|
||||
if (guard.length !== 1 || audit?.length !== 1) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: audit[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_OPTIONS,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_OPTIONS,
|
||||
GUARD_SCRIPT,
|
||||
} from './gemini-contract.js';
|
||||
import type { RenderedGeminiDocument } from './gemini-config.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
|
||||
const DISPLAY_NAME = 'Gemini CLI';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Gemini CLI configuration directory';
|
||||
const SETTINGS_LABEL = 'Gemini CLI user settings';
|
||||
|
||||
export async function preflightGeminiInstallation(
|
||||
config: InstallConfig,
|
||||
settingsPath: string,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareGeminiInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: RenderedGeminiDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
if (settingsSource === undefined) throw new Error('Gemini CLI hook settings are missing');
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: SETTINGS_LABEL,
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
guardOptions: GUARD_OPTIONS,
|
||||
auditOptions: AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitGeminiInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_OPTIONS,
|
||||
CONFIG_FILE,
|
||||
GUARD_OPTIONS,
|
||||
type GeminiRuntimeContract,
|
||||
} from './gemini-contract.js';
|
||||
import {
|
||||
createGeminiDocument,
|
||||
parseGeminiDocument,
|
||||
type GeminiDocument,
|
||||
type RenderedGeminiDocument,
|
||||
} from './gemini-config.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
export function geminiConfigurationDirectory(): string {
|
||||
const configuredHome = process.env.GEMINI_CLI_HOME;
|
||||
const home = configuredHome ? configuredHome : os.homedir();
|
||||
return path.join(home, '.gemini');
|
||||
}
|
||||
|
||||
export function geminiSettingsPath(): string {
|
||||
return path.join(geminiConfigurationDirectory(), CONFIG_FILE);
|
||||
}
|
||||
|
||||
export async function readGeminiDocument(): Promise<GeminiDocument> {
|
||||
const filePath = geminiSettingsPath();
|
||||
await inspectPhysicalDirectory(path.dirname(filePath), 'Gemini CLI configuration directory');
|
||||
const snapshot = await readPhysicalFile(filePath, 'Gemini CLI user settings');
|
||||
return snapshot
|
||||
? parseGeminiDocument({
|
||||
exists: true,
|
||||
filePath,
|
||||
raw: snapshot.contents,
|
||||
})
|
||||
: createGeminiDocument(filePath);
|
||||
}
|
||||
|
||||
export async function writeGeminiDocument(rendered: RenderedGeminiDocument): Promise<void> {
|
||||
if (!rendered.changed) return;
|
||||
const change = await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: 'Gemini CLI user settings',
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (!change) return;
|
||||
await commitManagedTransaction({
|
||||
displayName: 'Gemini CLI',
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: 'Gemini CLI configuration directory',
|
||||
}],
|
||||
changes: [change],
|
||||
});
|
||||
}
|
||||
|
||||
export async function geminiRuntimeFilesExist(
|
||||
contracts: readonly GeminiRuntimeContract[],
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
const exists = await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
guardOptions: GUARD_OPTIONS,
|
||||
auditOptions: AUDIT_OPTIONS,
|
||||
});
|
||||
if (exists) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+68
-142
@@ -1,169 +1,95 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_HOOK_NAME,
|
||||
GUARD_HOOK_NAME,
|
||||
buildGeminiGroup,
|
||||
disabledManagedGeminiEntries,
|
||||
geminiRuntimeContracts,
|
||||
managedGeminiHooksEnabled,
|
||||
type GeminiGroup,
|
||||
type ManagedGeminiEvent,
|
||||
} from './gemini-contract.js';
|
||||
import { renderGeminiDocument, type GeminiDocument } from './gemini-config.js';
|
||||
import {
|
||||
commitGeminiInstallation,
|
||||
preflightGeminiInstallation,
|
||||
prepareGeminiInstallation,
|
||||
} from './gemini-installation.js';
|
||||
import {
|
||||
geminiRuntimeFilesExist,
|
||||
readGeminiDocument,
|
||||
writeGeminiDocument,
|
||||
} from './gemini-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'gemini';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
function requireEnabledHooks(document: GeminiDocument): void {
|
||||
if (!document.hookControls.enabled) {
|
||||
throw new Error(`Gemini CLI hooks are disabled by hooksConfig.enabled: ${document.filePath}`);
|
||||
}
|
||||
const disabled = disabledManagedGeminiEntries(document.hookControls);
|
||||
if (disabled.length > 0) {
|
||||
throw new Error(
|
||||
`Gemini CLI hooks are disabled by hooksConfig.disabled: ${disabled.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') return !isElydoraCommand(entry.command, agentId);
|
||||
return true;
|
||||
});
|
||||
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||
ManagedGeminiEvent,
|
||||
GeminiGroup
|
||||
> {
|
||||
return new Map([
|
||||
['BeforeTool', buildGeminiGroup(guardPath, GUARD_HOOK_NAME)],
|
||||
['AfterTool', buildGeminiGroup(auditPath, AUDIT_HOOK_NAME)],
|
||||
]);
|
||||
}
|
||||
|
||||
export const geminiPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const document = await readGeminiDocument();
|
||||
requireEnabledHooks(document);
|
||||
await preflightGeminiInstallation(config, document.filePath);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- BeforeTool (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.BeforeTool)) {
|
||||
hooks.BeforeTool = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.BeforeTool = preFiltered;
|
||||
|
||||
// --- AfterTool (audit logging) ---
|
||||
if (!Array.isArray(hooks.AfterTool)) {
|
||||
hooks.AfterTool = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.AfterTool = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const document = await readGeminiDocument();
|
||||
requireEnabledHooks(document);
|
||||
const paths = await preflightGeminiInstallation(config, document.filePath);
|
||||
const rendered = renderGeminiDocument(
|
||||
document,
|
||||
undefined,
|
||||
installedGroups(paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitGeminiInstallation(await prepareGeminiInstallation(config, rendered));
|
||||
console.log(` Gemini CLI hooks: ${document.filePath}`);
|
||||
console.log(' Gemini CLI verification: run /hooks list.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.BeforeTool)) {
|
||||
hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.AfterTool)) {
|
||||
hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const document = await readGeminiDocument();
|
||||
await writeGeminiDocument(renderGeminiDocument(document, agentId, new Map()));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.BeforeTool) && checkArr(hooks.AfterTool);
|
||||
|
||||
// Extract hook script path from AfterTool command
|
||||
if (hookConfigured && Array.isArray(hooks.AfterTool)) {
|
||||
for (const e of hooks.AfterTool as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const document = await readGeminiDocument();
|
||||
const contracts = geminiRuntimeContracts(document.hooks);
|
||||
const hookConfigured = managedGeminiHooksEnabled(document.hookControls)
|
||||
&& contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
? await geminiRuntimeFilesExist(contracts)
|
||||
: false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: document.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { managedScriptReference, type ManagedScriptReference } from './common.js';
|
||||
import {
|
||||
encodedWindowsCommand,
|
||||
isNodeExecutable,
|
||||
parseEncodedWindowsCommand,
|
||||
parseLegacyWindowsCommand,
|
||||
parsePosixCommand,
|
||||
posixSource,
|
||||
} from './shell-command.js';
|
||||
|
||||
export type GrokRuntimeReference = ManagedScriptReference;
|
||||
|
||||
export function buildGrokCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Grok hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32' ? encodedWindowsCommand(scriptPath) : posixSource(scriptPath);
|
||||
}
|
||||
|
||||
export function grokRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): GrokRuntimeReference | undefined {
|
||||
const parsed = parsePosixCommand(command)
|
||||
?? parseEncodedWindowsCommand(command)
|
||||
?? parseLegacyWindowsCommand(command);
|
||||
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||
return managedScriptReference(parsed[1], scriptName);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { sameAgentId } from './common.js';
|
||||
import {
|
||||
buildGrokCommand,
|
||||
grokRuntimeReference,
|
||||
type GrokRuntimeReference,
|
||||
} from './grok-command.js';
|
||||
import type { GuardScriptOptions } from './guard-template.js';
|
||||
import type { HookScriptOptions } from './hook-template.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'grok';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
export const GUARD_OPTIONS: GuardScriptOptions = { denyProtocol: 'grok' };
|
||||
export const AUDIT_OPTIONS: HookScriptOptions = { nativePayload: true };
|
||||
|
||||
const MATCHER_REJECTING_EVENTS = new Set([
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'Stop',
|
||||
'UserPromptSubmit',
|
||||
]);
|
||||
|
||||
export type ManagedGrokEvent = 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure';
|
||||
|
||||
export interface GrokHandler extends JsonObject {
|
||||
readonly type: 'command' | 'http';
|
||||
readonly command?: string;
|
||||
readonly url?: string;
|
||||
readonly timeout?: number;
|
||||
readonly env?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface GrokGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: GrokHandler[];
|
||||
}
|
||||
|
||||
export type GrokHooks = Record<string, GrokGroup[]>;
|
||||
|
||||
export interface GrokDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: GrokHooks;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedGrokDocument {
|
||||
readonly document: GrokDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface GrokRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(
|
||||
value: unknown,
|
||||
event: string,
|
||||
groupIndex: number,
|
||||
handlerIndex: number,
|
||||
): GrokHandler {
|
||||
const label = `Grok user hooks handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.type !== 'command' && value.type !== 'http') {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
if (value.type === 'command' && (typeof value.command !== 'string' || !value.command)) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
if (value.type === 'http' && (typeof value.url !== 'string' || !value.url)) {
|
||||
throw new Error(`${label} requires a non-empty url`);
|
||||
}
|
||||
validateTimeout(value.timeout, label);
|
||||
if (value.env !== undefined && value.env !== null) {
|
||||
if (!isObject(value.env)
|
||||
|| Object.values(value.env).some((item) => typeof item !== 'string')) {
|
||||
throw new Error(`${label} env must map names to strings`);
|
||||
}
|
||||
}
|
||||
return value as GrokHandler;
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, event: string, groupIndex: number): GrokGroup {
|
||||
const label = `Grok user hooks group hooks.${event}[${groupIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
if (value.matcher !== undefined && MATCHER_REJECTING_EVENTS.has(event)) {
|
||||
throw new Error(`${label} cannot declare a matcher for ${event}`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map(
|
||||
(handler, handlerIndex) => validateHandler(handler, event, groupIndex, handlerIndex),
|
||||
),
|
||||
} as GrokGroup;
|
||||
}
|
||||
|
||||
function readHooks(value: unknown): GrokHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Grok user hooks field "hooks" must be an object');
|
||||
const hooks: GrokHooks = {};
|
||||
for (const [event, candidate] of Object.entries(value)) {
|
||||
if (!Array.isArray(candidate)) {
|
||||
throw new Error(`Grok user hooks field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = candidate.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function parseGrokDocument(filePath: string, raw: string): GrokDocument {
|
||||
const root = parseStrictJsonObject(raw, `Grok user hooks at ${filePath}`);
|
||||
return { exists: true, filePath, root, hooks: readHooks(root.hooks), raw };
|
||||
}
|
||||
|
||||
export function createGrokDocument(filePath: string): GrokDocument {
|
||||
return { exists: false, filePath, root: {}, hooks: {} };
|
||||
}
|
||||
|
||||
export function buildGrokGroup(scriptPath: string): GrokGroup {
|
||||
return {
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: buildGrokCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactManagedGroup(group: GrokGroup): boolean {
|
||||
return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks');
|
||||
}
|
||||
|
||||
function exactManagedHandler(handler: GrokHandler): boolean {
|
||||
return Object.keys(handler).sort().join('|') === 'command|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.timeout === HOOK_TIMEOUT_SECONDS
|
||||
&& typeof handler.command === 'string';
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: GrokHandler,
|
||||
scriptName: string,
|
||||
): GrokRuntimeReference | undefined {
|
||||
if (!exactManagedHandler(handler)) return undefined;
|
||||
return grokRuntimeReference(handler.command as string, scriptName);
|
||||
}
|
||||
|
||||
function removeFromGroups(
|
||||
groups: readonly GrokGroup[],
|
||||
scriptName: string,
|
||||
agentId?: string,
|
||||
): GrokGroup[] {
|
||||
const result: GrokGroup[] = [];
|
||||
for (const group of groups) {
|
||||
if (!exactManagedGroup(group)) {
|
||||
result.push(group);
|
||||
continue;
|
||||
}
|
||||
const handlers = group.hooks.filter((handler) => {
|
||||
const reference = managedReference(handler, scriptName);
|
||||
return !reference
|
||||
|| (agentId !== undefined && !sameAgentId(reference.agentId, agentId));
|
||||
});
|
||||
if (handlers.length > 0) result.push({ hooks: handlers });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function managedEvent(event: string): readonly [ManagedGrokEvent, string] | undefined {
|
||||
if (event === 'PreToolUse') return ['PreToolUse', GUARD_SCRIPT];
|
||||
if (event === 'PostToolUse') return ['PostToolUse', AUDIT_SCRIPT];
|
||||
if (event === 'PostToolUseFailure') return ['PostToolUseFailure', AUDIT_SCRIPT];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function removeManagedGrokHooks(hooks: GrokHooks, agentId?: string): GrokHooks {
|
||||
const next: GrokHooks = Object.fromEntries(
|
||||
Object.entries(hooks).map(([event, groups]) => [event, [...groups]]),
|
||||
);
|
||||
for (const [event, scriptName] of [
|
||||
['PreToolUse', GUARD_SCRIPT],
|
||||
['PostToolUse', AUDIT_SCRIPT],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT],
|
||||
] as const) {
|
||||
const groups = removeFromGroups(next[event] ?? [], scriptName, agentId);
|
||||
if (groups.length > 0) next[event] = groups;
|
||||
else delete next[event];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function entirelyManaged(document: GrokDocument): boolean {
|
||||
if (!document.exists || !Object.keys(document.root).every((key) => key === 'hooks')) return false;
|
||||
const events = Object.entries(document.hooks);
|
||||
if (events.length === 0) return false;
|
||||
let handlerCount = 0;
|
||||
for (const [event, groups] of events) {
|
||||
const contract = managedEvent(event);
|
||||
if (!contract || groups.length === 0) return false;
|
||||
for (const group of groups) {
|
||||
if (!exactManagedGroup(group)
|
||||
|| group.hooks.length === 0
|
||||
|| group.hooks.some((handler) => !managedReference(handler, contract[1]))) return false;
|
||||
handlerCount += group.hooks.length;
|
||||
}
|
||||
}
|
||||
return handlerCount > 0;
|
||||
}
|
||||
|
||||
export function renderGrokDocument(
|
||||
document: GrokDocument,
|
||||
hooks: GrokHooks,
|
||||
): RenderedGrokDocument {
|
||||
if (!document.exists && Object.keys(hooks).length === 0) {
|
||||
return { document, changed: false };
|
||||
}
|
||||
if (Object.keys(hooks).length === 0 && entirelyManaged(document)) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
const root: JsonObject = { ...document.root };
|
||||
if (Object.keys(hooks).length > 0) root.hooks = hooks;
|
||||
else delete root.hooks;
|
||||
const next = `${JSON.stringify(root, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly GrokGroup[],
|
||||
scriptName: string,
|
||||
): Map<string, GrokRuntimeReference[]> {
|
||||
const result = new Map<string, GrokRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactManagedGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = managedReference(handler, scriptName);
|
||||
if (!reference) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
const entries = result.get(key) ?? [];
|
||||
entries.push(reference);
|
||||
result.set(key, entries);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function grokRuntimeContracts(hooks: GrokHooks): GrokRuntimeContract[] {
|
||||
const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT);
|
||||
const successes = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT);
|
||||
const failures = referencesForEvent(hooks.PostToolUseFailure ?? [], AUDIT_SCRIPT);
|
||||
const contracts: GrokRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const success = successes.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || success?.length !== 1 || failure?.length !== 1) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: success[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_OPTIONS,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_OPTIONS,
|
||||
GUARD_SCRIPT,
|
||||
type RenderedGrokDocument,
|
||||
} from './grok-contract.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Grok Build';
|
||||
const HOOKS_DIRECTORY_LABEL = 'Grok hooks directory';
|
||||
const HOOKS_LABEL = 'Grok user hooks';
|
||||
|
||||
export async function preflightGrokInstallation(
|
||||
config: InstallConfig,
|
||||
hooksPath: string,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareGrokInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: RenderedGrokDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
const hooksSource = rendered.next ?? rendered.document.raw;
|
||||
if (hooksSource === undefined) throw new Error('Grok hook configuration is missing');
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: HOOKS_DIRECTORY_LABEL,
|
||||
label: HOOKS_LABEL,
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.raw,
|
||||
source: hooksSource,
|
||||
}],
|
||||
config,
|
||||
guardOptions: GUARD_OPTIONS,
|
||||
auditOptions: AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
const hooksDirectory = path.dirname(rendered.document.filePath);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
directories: [
|
||||
{ path: path.dirname(hooksDirectory), label: 'Grok home directory' },
|
||||
...prepared.transaction.directories,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitGrokInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareGrokUninstall(
|
||||
rendered: RenderedGrokDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const change = rendered.changed
|
||||
? await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: HOOKS_LABEL,
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: rendered.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: rendered.changed
|
||||
? [
|
||||
{
|
||||
path: path.dirname(path.dirname(rendered.document.filePath)),
|
||||
label: 'Grok home directory',
|
||||
},
|
||||
{ path: path.dirname(rendered.document.filePath), label: HOOKS_DIRECTORY_LABEL },
|
||||
]
|
||||
: [],
|
||||
changes: change ? [change] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitGrokUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_OPTIONS,
|
||||
GUARD_OPTIONS,
|
||||
createGrokDocument,
|
||||
parseGrokDocument,
|
||||
type GrokDocument,
|
||||
type GrokRuntimeContract,
|
||||
} from './grok-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
const CONFIG_FILE = 'elydora-audit.json';
|
||||
|
||||
export function grokConfigPath(): string {
|
||||
const configured = process.env.GROK_HOME;
|
||||
if (configured === '') {
|
||||
throw new Error(
|
||||
'GROK_HOME is empty; unset GROK_HOME or set it to an absolute home directory',
|
||||
);
|
||||
}
|
||||
const grokHome = configured === undefined
|
||||
? path.join(os.homedir(), '.grok')
|
||||
: path.resolve(configured);
|
||||
return path.join(grokHome, 'hooks', CONFIG_FILE);
|
||||
}
|
||||
|
||||
export async function readGrokDocument(): Promise<GrokDocument> {
|
||||
const filePath = grokConfigPath();
|
||||
await inspectPhysicalDirectory(path.dirname(path.dirname(filePath)), 'Grok home directory');
|
||||
await inspectPhysicalDirectory(path.dirname(filePath), 'Grok hooks directory');
|
||||
const snapshot = await readPhysicalFile(filePath, 'Grok user hooks');
|
||||
return snapshot
|
||||
? parseGrokDocument(filePath, snapshot.contents)
|
||||
: createGrokDocument(filePath);
|
||||
}
|
||||
|
||||
export async function grokRuntimeFilesExist(
|
||||
contracts: readonly GrokRuntimeContract[],
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
const exists = await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
guardOptions: GUARD_OPTIONS,
|
||||
auditOptions: AUDIT_OPTIONS,
|
||||
});
|
||||
if (exists) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildGrokGroup,
|
||||
grokRuntimeContracts,
|
||||
removeManagedGrokHooks,
|
||||
renderGrokDocument,
|
||||
type GrokHooks,
|
||||
} from './grok-contract.js';
|
||||
import {
|
||||
commitGrokInstallation,
|
||||
commitGrokUninstall,
|
||||
preflightGrokInstallation,
|
||||
prepareGrokInstallation,
|
||||
prepareGrokUninstall,
|
||||
} from './grok-installation.js';
|
||||
import { grokRuntimeFilesExist, readGrokDocument } from './grok-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function installedHooks(
|
||||
hooks: GrokHooks,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): GrokHooks {
|
||||
const cleaned = removeManagedGrokHooks(hooks);
|
||||
return {
|
||||
...cleaned,
|
||||
PreToolUse: [...(cleaned.PreToolUse ?? []), buildGrokGroup(guardPath)],
|
||||
PostToolUse: [...(cleaned.PostToolUse ?? []), buildGrokGroup(auditPath)],
|
||||
PostToolUseFailure: [
|
||||
...(cleaned.PostToolUseFailure ?? []),
|
||||
buildGrokGroup(auditPath),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const grokPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const document = await readGrokDocument();
|
||||
await preflightGrokInstallation(config, document.filePath);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const document = await readGrokDocument();
|
||||
const paths = await preflightGrokInstallation(config, document.filePath);
|
||||
const rendered = renderGrokDocument(
|
||||
document,
|
||||
installedHooks(document.hooks, paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitGrokInstallation(await prepareGrokInstallation(config, rendered));
|
||||
console.log(
|
||||
' Grok Build: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.',
|
||||
);
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const document = await readGrokDocument();
|
||||
const rendered = renderGrokDocument(
|
||||
document,
|
||||
removeManagedGrokHooks(document.hooks, agentId),
|
||||
);
|
||||
await commitGrokUninstall(await prepareGrokUninstall(rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const document = await readGrokDocument();
|
||||
const contracts = grokRuntimeContracts(document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured && await grokRuntimeFilesExist(contracts);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: document.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js';
|
||||
|
||||
export interface GuardScriptOptions {
|
||||
readonly failClosed?: boolean;
|
||||
readonly successOutput?: string;
|
||||
readonly denyProtocol?: 'cursor' | 'grok';
|
||||
}
|
||||
|
||||
export function generateGuardScript(
|
||||
agentName: string,
|
||||
agentId: string,
|
||||
options: GuardScriptOptions = {},
|
||||
): string {
|
||||
const failClosed = options.failClosed ?? false;
|
||||
const successOutput = options.successOutput ?? '';
|
||||
const denyProtocol = options.denyProtocol ?? '';
|
||||
return `#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// Elydora agent guard for ${agentName}. Generated by @elydora/sdk.
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = ${JSON.stringify(agentName)};
|
||||
const AGENT_ID = ${JSON.stringify(agentId)};
|
||||
const SUCCESS_OUTPUT = ${JSON.stringify(successOutput)};
|
||||
const FAIL_CLOSED = ${JSON.stringify(failClosed)};
|
||||
const DENY_PROTOCOL = ${JSON.stringify(denyProtocol)};
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||
const CACHE_TTL_MS = 60000;
|
||||
|
||||
${PROTECTED_RUNTIME_READER}
|
||||
|
||||
function validateStatus(value, source) {
|
||||
if (!['active', 'frozen', 'revoked'].includes(value)) {
|
||||
throw new Error(source + ' contains an invalid agent status');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function blockAgent(status) {
|
||||
const state = status === 'frozen' ? 'frozen' : 'revoked';
|
||||
const message = 'Agent "' + AGENT_NAME + '" is ' + state + ' in Elydora.';
|
||||
if (DENY_PROTOCOL === 'cursor') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
permission: 'deny',
|
||||
userMessage: message,
|
||||
agentMessage: message + ' Tool execution is blocked.',
|
||||
}) + '\\n');
|
||||
} else if (DENY_PROTOCOL === 'grok') {
|
||||
process.stdout.write(JSON.stringify({ decision: 'deny', reason: message }) + '\\n');
|
||||
}
|
||||
process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
function readStatusCache() {
|
||||
let raw;
|
||||
try {
|
||||
raw = readProtectedFile(
|
||||
STATUS_CACHE_PATH,
|
||||
'Status cache',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8');
|
||||
} catch (error) {
|
||||
if (hasRuntimeErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw new Error('Failed to read status cache: ' + error.message);
|
||||
}
|
||||
let cache;
|
||||
try {
|
||||
cache = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error('Status cache is invalid JSON: ' + error.message);
|
||||
}
|
||||
if (!cache || typeof cache !== 'object' || !Number.isFinite(cache.cached_at)) {
|
||||
throw new Error('Status cache has an invalid shape');
|
||||
}
|
||||
const age = Date.now() - cache.cached_at;
|
||||
if (age < 0) throw new Error('Status cache timestamp is in the future');
|
||||
return { status: validateStatus(cache.status, 'Status cache'), age };
|
||||
}
|
||||
|
||||
function writeStatusCache(status) {
|
||||
writeProtectedJson(STATUS_CACHE_PATH, 'Status cache', {
|
||||
status,
|
||||
cached_at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function readInput() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (!raw) {
|
||||
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;
|
||||
}
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch (error) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const raw = readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8');
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error('Agent config is invalid JSON: ' + error.message);
|
||||
}
|
||||
if (!config || typeof config !== 'object') throw new Error('Agent config must be an object');
|
||||
if (config.agent_id !== AGENT_ID
|
||||
|| (config.agent_name !== undefined && config.agent_name !== AGENT_NAME)) {
|
||||
throw new Error('Agent config identity does not match the generated guard');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await readInput();
|
||||
let config;
|
||||
try {
|
||||
config = readConfig();
|
||||
} catch (error) {
|
||||
if (FAIL_CLOSED) throw new Error('Failed to read agent config: ' + error.message);
|
||||
process.stderr.write('[Elydora guard] Failed to read agent config: ' + error.message + '\\n');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cache = readStatusCache();
|
||||
if (cache && cache.age < CACHE_TTL_MS) {
|
||||
if (cache.status !== 'active') blockAgent(cache.status);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (FAIL_CLOSED) throw error;
|
||||
process.stderr.write('[Elydora guard] ' + error.message + '\\n');
|
||||
}
|
||||
|
||||
const baseUrl = String(config.base_url || 'https://api.elydora.com').replace(/\\\/+$/, '');
|
||||
const token = config.token || '';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
const response = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(AGENT_ID), {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error('Agent status API returned HTTP ' + response.status);
|
||||
const data = await response.json();
|
||||
const status = validateStatus(data && data.agent && data.agent.status, 'Agent status API');
|
||||
writeStatusCache(status);
|
||||
if (status !== 'active') blockAgent(status);
|
||||
} catch (error) {
|
||||
if (FAIL_CLOSED) throw new Error('Failed to resolve agent status: ' + error.message);
|
||||
process.stderr.write('[Elydora guard] Failed to resolve agent status: ' + error.message + '\\n');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => {
|
||||
if ((!process.exitCode || process.exitCode === 0) && SUCCESS_OUTPUT) {
|
||||
process.stdout.write(SUCCESS_OUTPUT);
|
||||
}
|
||||
}).catch((error) => {
|
||||
process.stderr.write('[Elydora guard] ' + (error.stack || error.message || String(error)) + '\\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`;
|
||||
}
|
||||
+295
-285
@@ -1,49 +1,58 @@
|
||||
/**
|
||||
* Generates a self-contained Node.js hook script that embeds all crypto logic
|
||||
* inline so it has zero runtime dependency on the SDK.
|
||||
*/
|
||||
import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js';
|
||||
|
||||
export function generateHookScript(agentName: string, agentId: string): string {
|
||||
export interface HookScriptOptions {
|
||||
readonly failClosed?: boolean;
|
||||
readonly nativePayload?: boolean;
|
||||
readonly successOutput?: string;
|
||||
}
|
||||
|
||||
/** Generates a self-contained Node.js audit hook with no SDK runtime dependency. */
|
||||
export function generateHookScript(
|
||||
agentName: string,
|
||||
agentId: string,
|
||||
options: HookScriptOptions = {},
|
||||
): string {
|
||||
const failClosed = options.failClosed ?? false;
|
||||
const nativePayload = options.nativePayload ?? false;
|
||||
const successOutput = options.successOutput ?? '';
|
||||
return `#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-contained Elydora audit hook for: ${agentName}
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Elydora audit hook for ${agentName}. Generated by @elydora/sdk.
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = ${JSON.stringify(agentName)};
|
||||
const AGENT_ID = ${JSON.stringify(agentId)};
|
||||
const SUCCESS_OUTPUT = ${JSON.stringify(successOutput)};
|
||||
const FAIL_CLOSED = ${JSON.stringify(failClosed)};
|
||||
const NATIVE_PAYLOAD = ${JSON.stringify(nativePayload)};
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
|
||||
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const MAX_CHAIN_ATTEMPTS = 5;
|
||||
const SUBMIT_BUDGET_MS = 4000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
${PROTECTED_RUNTIME_READER}
|
||||
|
||||
function base64urlEncode(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
return buf.toString('base64url');
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
return buffer.toString('base64url');
|
||||
}
|
||||
|
||||
function jcsCanonicalise(value) {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
|
||||
if (typeof value === 'string') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']';
|
||||
}
|
||||
if (Array.isArray(value)) return '[' + value.map(jcsCanonicalise).join(',') + ']';
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value).sort();
|
||||
const pairs = [];
|
||||
for (const key of keys) {
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
if (value[key] !== undefined) {
|
||||
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
|
||||
}
|
||||
@@ -62,9 +71,8 @@ function computePayloadHash(payload) {
|
||||
return sha256Base64url(jcsCanonicalise(payload));
|
||||
}
|
||||
|
||||
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) {
|
||||
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt;
|
||||
return sha256Base64url(input);
|
||||
function computeChainHash(previous, payload, operationId, issuedAt) {
|
||||
return sha256Base64url(previous + '|' + payload + '|' + operationId + '|' + issuedAt);
|
||||
}
|
||||
|
||||
const PKCS8_ED25519_PREFIX = Buffer.from([
|
||||
@@ -72,15 +80,25 @@ const PKCS8_ED25519_PREFIX = Buffer.from([
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
]);
|
||||
|
||||
function signEd25519(privateKeyBase64url, data) {
|
||||
const seed = Buffer.from(privateKeyBase64url, 'base64url');
|
||||
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
|
||||
const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
||||
const signature = crypto.sign(null, data, keyObject);
|
||||
return base64urlEncode(signature);
|
||||
function parsePrivateKey(raw) {
|
||||
let value = raw.toString('utf-8');
|
||||
if (value.endsWith('\\r\\n')) value = value.slice(0, -2);
|
||||
else if (value.endsWith('\\n')) value = value.slice(0, -1);
|
||||
if (!value || value.includes('\\r') || value.includes('\\n') || value.includes('\\0')) {
|
||||
throw new Error('Private key file must contain exactly one line');
|
||||
}
|
||||
const seed = Buffer.from(value, 'base64url');
|
||||
if (seed.length !== 32 || base64urlEncode(seed) !== value) {
|
||||
throw new Error('Private key must be a canonical 32-byte base64url value');
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
function signEd25519(seed, data) {
|
||||
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
|
||||
const key = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
||||
return base64urlEncode(crypto.sign(null, data, key));
|
||||
}
|
||||
|
||||
function uuidv7() {
|
||||
const now = Date.now();
|
||||
@@ -103,299 +121,291 @@ function uuidv7() {
|
||||
bytes[14] = random[8];
|
||||
bytes[15] = random[9];
|
||||
const hex = bytes.toString('hex');
|
||||
return (
|
||||
hex.slice(0, 8) + '-' +
|
||||
hex.slice(8, 12) + '-' +
|
||||
hex.slice(12, 16) + '-' +
|
||||
hex.slice(16, 20) + '-' +
|
||||
hex.slice(20, 32)
|
||||
);
|
||||
return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' +
|
||||
hex.slice(16, 20) + '-' + hex.slice(20);
|
||||
}
|
||||
|
||||
function generateNonce() {
|
||||
return base64urlEncode(crypto.randomBytes(16));
|
||||
function logError(error) {
|
||||
try {
|
||||
const message = new Date().toISOString() + ' [' + AGENT_NAME + '] ' +
|
||||
(error.stack || error.message || String(error)) + '\\n';
|
||||
appendProtectedText(ERROR_LOG_PATH, 'Error log', message);
|
||||
} catch (logFailure) {
|
||||
process.stderr.write('[Elydora audit] Failed to write error log: ' + logFailure.message + '\\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain state persistence (atomic writes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readChainState() {
|
||||
let raw;
|
||||
try {
|
||||
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
|
||||
const state = JSON.parse(raw);
|
||||
return state.prev_chain_hash || ZERO_CHAIN_HASH;
|
||||
} catch {
|
||||
return ZERO_CHAIN_HASH;
|
||||
raw = readProtectedFile(
|
||||
CHAIN_STATE_PATH,
|
||||
'Chain state',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8');
|
||||
} catch (error) {
|
||||
if (hasRuntimeErrorCode(error, 'ENOENT')) return ZERO_CHAIN_HASH;
|
||||
throw new Error('Failed to read chain state: ' + error.message);
|
||||
}
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error('Chain state is invalid JSON: ' + error.message);
|
||||
}
|
||||
if (!state || typeof state.prev_chain_hash !== 'string' ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(state.prev_chain_hash)) {
|
||||
throw new Error('Chain state contains an invalid prev_chain_hash');
|
||||
}
|
||||
return state.prev_chain_hash;
|
||||
}
|
||||
|
||||
function writeChainState(chainHash) {
|
||||
const tmpPath = CHAIN_STATE_PATH + '.tmp';
|
||||
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
|
||||
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
||||
}
|
||||
|
||||
function lockOwnerAlive(lockPath) {
|
||||
let owner;
|
||||
try {
|
||||
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||
} catch (err) {
|
||||
if (err.code === 'EPERM' || err.code === 'EEXIST') {
|
||||
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch { /* ignore */ }
|
||||
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10);
|
||||
} catch (error) {
|
||||
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
|
||||
return false;
|
||||
}
|
||||
if (!Number.isInteger(owner) || owner <= 0) return false;
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasRuntimeErrorCode(error, 'ESRCH')) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function logError(err) {
|
||||
try {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\\n';
|
||||
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
|
||||
} catch {
|
||||
// Silently ignore logging failures
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main hook logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Read JSON from stdin
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (!rawInput) return;
|
||||
|
||||
let hookData;
|
||||
function withChainLock(update) {
|
||||
const lockPath = CHAIN_STATE_PATH + '.lock';
|
||||
const deadline = performance.now() + 1000;
|
||||
for (;;) {
|
||||
let descriptor;
|
||||
try {
|
||||
hookData = JSON.parse(rawInput);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract tool_name and tool_input from hook data
|
||||
// Different agents may pass data in different shapes
|
||||
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
|
||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
|
||||
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
|
||||
|
||||
// Read agent config and private key
|
||||
let config;
|
||||
let privateKey;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
|
||||
} catch (err) {
|
||||
logError(new Error('Failed to read agent config/key: ' + err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = config.org_id;
|
||||
const agentId = config.agent_id;
|
||||
const kid = config.kid;
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
|
||||
const token = config.token || '';
|
||||
|
||||
// Read chain state
|
||||
const prevChainHash = readChainState();
|
||||
|
||||
// Build EOR
|
||||
const operationId = uuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const nonce = generateNonce();
|
||||
|
||||
const payload = {
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
};
|
||||
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
||||
|
||||
const eorWithoutSig = {
|
||||
op_version: '1.0',
|
||||
operation_id: operationId,
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
issued_at: issuedAt,
|
||||
ttl_ms: 30000,
|
||||
nonce: nonce,
|
||||
operation_type: 'ai.tool_use',
|
||||
subject: { session_id: sessionId },
|
||||
action: { tool: toolName },
|
||||
payload: payload,
|
||||
payload_hash: payloadHash,
|
||||
prev_chain_hash: prevChainHash,
|
||||
agent_pubkey_kid: kid,
|
||||
};
|
||||
|
||||
const canonical = jcsCanonicalise(eorWithoutSig);
|
||||
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
|
||||
|
||||
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
||||
|
||||
// POST to /v1/operations (5s timeout)
|
||||
// Only update local chain state on success to prevent desync
|
||||
const url = baseUrl + '/v1/operations';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
||||
fs.writeSync(descriptor, String(process.pid));
|
||||
} catch (error) {
|
||||
if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error;
|
||||
if (lockOwnerAlive(lockPath)) {
|
||||
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(eor),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Server accepted — safe to advance local chain state
|
||||
writeChainState(chainHash);
|
||||
} else if (res.status === 400) {
|
||||
// Possible chain hash mismatch — try to resync from server response
|
||||
let age = 0;
|
||||
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) {
|
||||
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||
}
|
||||
if (age > 5000) {
|
||||
const reclaimed = lockPath + '.' + process.pid + '.stale';
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
||||
// Extract the expected hash from the error message
|
||||
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
||||
if (match) {
|
||||
writeChainState(match[1]);
|
||||
logError(new Error('Chain hash resynced to server: ' + match[1]));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
fs.renameSync(lockPath, reclaimed);
|
||||
fs.unlinkSync(reclaimed);
|
||||
} catch (reclaimError) {
|
||||
if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Network error — don't advance chain state, will retry with same prev_chain_hash
|
||||
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return update();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
let current = null;
|
||||
try {
|
||||
current = fs.lstatSync(lockPath);
|
||||
} catch (statError) {
|
||||
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||
}
|
||||
const own = fs.fstatSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
`;
|
||||
function advanceChainState(fromHash, toHash) {
|
||||
return withChainLock(() => {
|
||||
if (readChainState() !== fromHash) return false;
|
||||
writeChainState(toHash);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a lightweight PreToolUse guard script that checks if the agent
|
||||
* is frozen and blocks tool execution if so.
|
||||
*
|
||||
* - Caches the agent status locally (60s TTL) to avoid hitting the API on every tool call
|
||||
* - Fails open: if the API is unreachable, tool execution is allowed
|
||||
* - If frozen: exits with code 1 to block the tool
|
||||
*/
|
||||
export function generateGuardScript(agentName: string, agentId: string): string {
|
||||
return `#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elydora agent guard (PreToolUse) for: ${agentName}
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = ${JSON.stringify(agentName)};
|
||||
const AGENT_ID = ${JSON.stringify(agentId)};
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||
|
||||
// Discard stdin immediately so the process can exit cleanly
|
||||
process.stdin.destroy();
|
||||
|
||||
async function main() {
|
||||
// Read agent config
|
||||
function readConfigAndKey() {
|
||||
const raw = readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8');
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return; // Can't read config — fail-open (allow)
|
||||
config = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error('Agent config is invalid JSON: ' + error.message);
|
||||
}
|
||||
|
||||
// Check cached status
|
||||
try {
|
||||
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
|
||||
const cache = JSON.parse(raw);
|
||||
const age = Date.now() - (cache.cached_at || 0);
|
||||
|
||||
if (age < CACHE_TTL_MS) {
|
||||
if (cache.status === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return; // Cache is fresh — use cached result
|
||||
}
|
||||
} catch {
|
||||
// No cache or invalid — need to check API
|
||||
if (!config || typeof config !== 'object') throw new Error('Agent config must be an object');
|
||||
if (config.agent_name !== AGENT_NAME || config.agent_id !== AGENT_ID) {
|
||||
throw new Error('Agent config identity does not match the generated audit hook');
|
||||
}
|
||||
|
||||
// Fetch agent status from API
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
|
||||
const token = config.token || '';
|
||||
const agentId = config.agent_id;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
for (const field of ['org_id', 'agent_id', 'kid']) {
|
||||
if (typeof config[field] !== 'string' || !config[field]) {
|
||||
throw new Error('Agent config is missing ' + field);
|
||||
}
|
||||
}
|
||||
if (config.token !== undefined && typeof config.token !== 'string') {
|
||||
throw new Error('Agent config token must be a string');
|
||||
}
|
||||
const baseUrl = config.base_url || 'https://api.elydora.com';
|
||||
if (typeof baseUrl !== 'string') throw new Error('Agent config base_url must be a string');
|
||||
const parsedBaseUrl = new URL(baseUrl);
|
||||
if (!['http:', 'https:'].includes(parsedBaseUrl.protocol)) {
|
||||
throw new Error('Agent config base_url must use HTTP or HTTPS');
|
||||
}
|
||||
return {
|
||||
config,
|
||||
baseUrl: baseUrl.replace(/\\\/+$/, ''),
|
||||
privateKey: parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key')),
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
|
||||
headers: headers,
|
||||
async function readHookInput() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (!raw) throw new Error('Hook input is empty');
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error('Hook input is invalid JSON: ' + error.message);
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Hook input must contain a JSON object');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function postOperation(operation, runtime, deadline) {
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) throw new Error('Audit API retry budget exhausted');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining));
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
|
||||
const response = await fetch(runtime.baseUrl + '/v1/operations', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(operation),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const agentStatus = (data.agent && data.agent.status) || 'active';
|
||||
|
||||
// Update cache
|
||||
try {
|
||||
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
|
||||
status: agentStatus,
|
||||
cached_at: Date.now(),
|
||||
}), 'utf-8');
|
||||
} catch {
|
||||
// Ignore cache write failures
|
||||
if (response.ok) return { accepted: true };
|
||||
const responseBody = await response.text();
|
||||
if (response.status === 400 && responseBody) {
|
||||
let failure;
|
||||
try { failure = JSON.parse(responseBody); } catch (error) {
|
||||
throw new Error('Audit API returned invalid error JSON: ' + error.message);
|
||||
}
|
||||
|
||||
if (agentStatus === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
|
||||
process.exitCode = 1;
|
||||
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
|
||||
const match = String(failure.error.message || '').match(
|
||||
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
|
||||
);
|
||||
if (match) return { accepted: false, expected: match[1] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API unreachable — fail-open (allow)
|
||||
throw new Error('Audit API returned HTTP ' + response.status);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
function buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash) {
|
||||
const operationId = uuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const chainHash = computeChainHash(previousHash, payloadHash, operationId, issuedAt);
|
||||
const unsigned = {
|
||||
op_version: '1.0',
|
||||
operation_id: operationId,
|
||||
org_id: runtime.config.org_id,
|
||||
agent_id: runtime.config.agent_id,
|
||||
issued_at: issuedAt,
|
||||
ttl_ms: 30000,
|
||||
nonce: base64urlEncode(crypto.randomBytes(16)),
|
||||
operation_type: 'ai.tool_use',
|
||||
subject: { session_id: sessionId },
|
||||
action: { tool: toolName },
|
||||
payload,
|
||||
payload_hash: payloadHash,
|
||||
prev_chain_hash: previousHash,
|
||||
agent_pubkey_kid: runtime.config.kid,
|
||||
};
|
||||
const signature = signEd25519(
|
||||
runtime.privateKey,
|
||||
Buffer.from(jcsCanonicalise(unsigned), 'utf-8'),
|
||||
);
|
||||
return { operation: { ...unsigned, chain_hash: chainHash, signature }, chainHash };
|
||||
}
|
||||
|
||||
async function submitOperation(hookData, runtime) {
|
||||
const toolEvent = hookData.tool_result && typeof hookData.tool_result === 'object'
|
||||
? hookData.tool_result
|
||||
: hookData.tool_call && typeof hookData.tool_call === 'object'
|
||||
? hookData.tool_call
|
||||
: {};
|
||||
const toolName = hookData.tool_name || hookData.toolName || hookData.name ||
|
||||
toolEvent.name || 'unknown';
|
||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
|
||||
hookData.parameters || toolEvent.input || {};
|
||||
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
||||
hookData.session || hookData.taskId || 'unknown';
|
||||
const payload = NATIVE_PAYLOAD ? hookData : {
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
};
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
const deadline = performance.now() + SUBMIT_BUDGET_MS;
|
||||
let previousHash = readChainState();
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
const built = buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash);
|
||||
const result = await postOperation(built.operation, runtime, deadline);
|
||||
if (result.accepted) {
|
||||
advanceChainState(previousHash, built.chainHash);
|
||||
return;
|
||||
}
|
||||
advanceChainState(previousHash, result.expected);
|
||||
logError(new Error('Chain hash resynced to server: ' + result.expected));
|
||||
if (attempt >= MAX_CHAIN_ATTEMPTS) {
|
||||
throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
|
||||
}
|
||||
previousHash = readChainState();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const hookData = await readHookInput();
|
||||
const runtime = readConfigAndKey();
|
||||
await submitOperation(hookData, runtime);
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
if (FAIL_CLOSED) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => {
|
||||
if (SUCCESS_OUTPUT) process.stdout.write(SUCCESS_OUTPUT);
|
||||
}).catch((error) => {
|
||||
process.stderr.write('[Elydora audit] ' + (error.stack || error.message || String(error)) + '\\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { applyEdits, modify, type FormattingOptions, type JSONPath } from 'jsonc-parser';
|
||||
|
||||
function formatting(raw: string): FormattingOptions {
|
||||
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
||||
const insertSpaces = !indentation?.includes('\t');
|
||||
return {
|
||||
eol: raw.includes('\r\n') ? '\r\n' : '\n',
|
||||
insertSpaces,
|
||||
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function changeJsonc(
|
||||
raw: string,
|
||||
jsonPath: JSONPath,
|
||||
value: unknown,
|
||||
isArrayInsertion = false,
|
||||
): string {
|
||||
return applyEdits(raw, modify(raw, jsonPath, value, {
|
||||
formattingOptions: formatting(raw),
|
||||
isArrayInsertion,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { managedScriptReference, type ManagedScriptReference } from './common.js';
|
||||
import {
|
||||
encodedWindowsCommand,
|
||||
isNodeExecutable,
|
||||
parseEncodedWindowsCommand,
|
||||
parseLegacyWindowsCommand,
|
||||
parsePosixCommand,
|
||||
posixSource,
|
||||
} from './shell-command.js';
|
||||
|
||||
export type KimiRuntimeReference = ManagedScriptReference;
|
||||
|
||||
export function buildKimiCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Kimi hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32' ? encodedWindowsCommand(scriptPath) : posixSource(scriptPath);
|
||||
}
|
||||
|
||||
export function kimiRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): KimiRuntimeReference | undefined {
|
||||
const parsed = parsePosixCommand(command)
|
||||
?? parseEncodedWindowsCommand(command)
|
||||
?? parseLegacyWindowsCommand(command);
|
||||
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||
return managedScriptReference(parsed[1], scriptName);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { sameAgentId } from './common.js';
|
||||
import {
|
||||
buildKimiCommand,
|
||||
kimiRuntimeReference,
|
||||
type KimiRuntimeReference,
|
||||
} from './kimi-command.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'kimi';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
|
||||
const SHARED_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'StopFailure',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
'Notification',
|
||||
] as const;
|
||||
|
||||
export const STABLE_EVENTS = new Set<string>([
|
||||
...SHARED_EVENTS,
|
||||
'PermissionRequest',
|
||||
'PermissionResult',
|
||||
'Interrupt',
|
||||
]);
|
||||
export const LEGACY_EVENTS = new Set<string>(SHARED_EVENTS);
|
||||
|
||||
export type TomlObject = JsonObject;
|
||||
export type ManagedKimiEvent = 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure';
|
||||
|
||||
export interface KimiHook extends TomlObject {
|
||||
readonly event: string;
|
||||
readonly command: string;
|
||||
readonly matcher?: string;
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface KimiContract {
|
||||
readonly generation: 'stable' | 'legacy';
|
||||
readonly runtimeName: 'Kimi Code' | 'kimi-cli';
|
||||
readonly label: string;
|
||||
readonly directoryLabel: string;
|
||||
readonly configPath: string;
|
||||
readonly events: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface KimiConfigDocument {
|
||||
readonly contract: KimiContract;
|
||||
readonly exists: boolean;
|
||||
readonly root: TomlObject;
|
||||
readonly hooks: readonly KimiHook[];
|
||||
readonly usesHookTables: boolean;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedKimiDocument {
|
||||
readonly document: KimiConfigDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface KimiRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
readonly configPath: string;
|
||||
}
|
||||
|
||||
function exactManagedKeys(hook: KimiHook): boolean {
|
||||
return Object.keys(hook).sort().join('|') === 'command|event|timeout';
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
hook: KimiHook,
|
||||
event: ManagedKimiEvent,
|
||||
scriptName: string,
|
||||
): KimiRuntimeReference | undefined {
|
||||
if (!exactManagedKeys(hook)
|
||||
|| hook.event !== event
|
||||
|| hook.timeout !== HOOK_TIMEOUT_SECONDS) return undefined;
|
||||
return kimiRuntimeReference(hook.command, scriptName);
|
||||
}
|
||||
|
||||
function managedAgentId(
|
||||
hook: KimiHook,
|
||||
event: ManagedKimiEvent,
|
||||
scriptName: string,
|
||||
): string | undefined {
|
||||
return managedReference(hook, event, scriptName)?.agentId;
|
||||
}
|
||||
|
||||
export function validateKimiHook(
|
||||
value: unknown,
|
||||
contract: KimiContract,
|
||||
index: number,
|
||||
): KimiHook {
|
||||
if (!isObject(value)) throw new Error(`${contract.label} hook ${index + 1} must be a table`);
|
||||
const supportedFields = new Set(['event', 'matcher', 'command', 'timeout']);
|
||||
const unsupportedField = Object.keys(value).find((key) => !supportedFields.has(key));
|
||||
if (unsupportedField) {
|
||||
throw new Error(`${contract.label} hook ${index + 1} has unsupported field "${unsupportedField}"`);
|
||||
}
|
||||
if (typeof value.event !== 'string' || !contract.events.has(value.event)) {
|
||||
throw new Error(
|
||||
`${contract.label} hook ${index + 1} has unsupported event "${String(value.event)}"`,
|
||||
);
|
||||
}
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${contract.label} hook ${index + 1} requires a non-empty command`);
|
||||
}
|
||||
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
|
||||
throw new Error(`${contract.label} hook ${index + 1} matcher must be a string`);
|
||||
}
|
||||
const timeout = value.timeout;
|
||||
if (timeout !== undefined
|
||||
&& (!Number.isInteger(timeout) || (timeout as number) < 1 || (timeout as number) > 600)) {
|
||||
throw new Error(`${contract.label} hook ${index + 1} timeout must be an integer from 1 to 600`);
|
||||
}
|
||||
return value as KimiHook;
|
||||
}
|
||||
|
||||
function readHooks(root: TomlObject, contract: KimiContract): readonly KimiHook[] {
|
||||
if (root.hooks === undefined) return [];
|
||||
if (!Array.isArray(root.hooks)) throw new Error(`${contract.label} field "hooks" must be an array`);
|
||||
return root.hooks.map((hook, index) => validateKimiHook(hook, contract, index));
|
||||
}
|
||||
|
||||
function documentUsesHookTables(nodes: readonly unknown[]): boolean {
|
||||
return nodes.some((node) => {
|
||||
if (!isObject(node) || node.type !== 'TableArray' || !isObject(node.key)) return false;
|
||||
if (!isObject(node.key.item) || !Array.isArray(node.key.item.value)) return false;
|
||||
return node.key.item.value.length === 1 && node.key.item.value[0] === 'hooks';
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseKimiDocument(
|
||||
contract: KimiContract,
|
||||
raw: string,
|
||||
): Promise<KimiConfigDocument> {
|
||||
let parsed: { readonly toJsObject: unknown; readonly cst: readonly unknown[] };
|
||||
try {
|
||||
const { TomlDocument } = await import('@decimalturn/toml-patch');
|
||||
parsed = new TomlDocument(raw);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to parse ${contract.label} at ${contract.configPath}: ${message}`, {
|
||||
cause: error instanceof Error ? error : new Error(message),
|
||||
});
|
||||
}
|
||||
if (!isObject(parsed.toJsObject)) {
|
||||
throw new Error(`${contract.label} at ${contract.configPath} must contain a TOML table`);
|
||||
}
|
||||
return {
|
||||
contract,
|
||||
exists: true,
|
||||
root: parsed.toJsObject,
|
||||
hooks: readHooks(parsed.toJsObject, contract),
|
||||
usesHookTables: documentUsesHookTables(parsed.cst),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createKimiDocument(contract: KimiContract): KimiConfigDocument {
|
||||
return { contract, exists: false, root: {}, hooks: [], usesHookTables: false };
|
||||
}
|
||||
|
||||
async function renderHookSource(
|
||||
document: KimiConfigDocument,
|
||||
hooks: readonly KimiHook[],
|
||||
): Promise<string> {
|
||||
const {
|
||||
patch: patchToml,
|
||||
stringify: stringifyToml,
|
||||
TomlFormat,
|
||||
} = await import('@decimalturn/toml-patch');
|
||||
const nextRoot: TomlObject = { ...document.root, hooks };
|
||||
if (document.usesHookTables) return patchToml(document.raw ?? '', nextRoot);
|
||||
|
||||
let base = document.raw ?? '';
|
||||
if (Object.hasOwn(document.root, 'hooks')) {
|
||||
const rootWithoutHooks = { ...document.root };
|
||||
delete rootWithoutHooks.hooks;
|
||||
base = patchToml(base, rootWithoutHooks);
|
||||
}
|
||||
const format = base.length > 0
|
||||
? TomlFormat.autoDetectFormat(base)
|
||||
: TomlFormat.default();
|
||||
const hookTables = hooks.length > 0 ? stringifyToml({ hooks }, format) : '';
|
||||
if (base.length === 0) return hookTables;
|
||||
if (hookTables.length === 0) return base;
|
||||
const newline = format.newLine;
|
||||
const separator = base.endsWith(newline + newline)
|
||||
? ''
|
||||
: base.endsWith(newline) ? newline : newline + newline;
|
||||
return base + separator + hookTables;
|
||||
}
|
||||
|
||||
export async function renderKimiDocument(
|
||||
document: KimiConfigDocument,
|
||||
hooks: readonly KimiHook[],
|
||||
): Promise<RenderedKimiDocument> {
|
||||
if (!document.exists && hooks.length === 0) return { document, changed: false };
|
||||
const next = await renderHookSource(document, hooks);
|
||||
if (hooks.length === 0 && next.trim().length === 0) {
|
||||
return { document, changed: document.exists };
|
||||
}
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
export function buildKimiHook(event: ManagedKimiEvent, scriptPath: string): KimiHook {
|
||||
return {
|
||||
event,
|
||||
command: buildKimiCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
function expectedManagedContract(event: string): readonly [ManagedKimiEvent, string] | undefined {
|
||||
if (event === 'PreToolUse') return ['PreToolUse', GUARD_SCRIPT];
|
||||
if (event === 'PostToolUse') return ['PostToolUse', AUDIT_SCRIPT];
|
||||
if (event === 'PostToolUseFailure') return ['PostToolUseFailure', AUDIT_SCRIPT];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function removeManagedKimiHooks(
|
||||
hooks: readonly KimiHook[],
|
||||
agentId?: string,
|
||||
): KimiHook[] {
|
||||
return hooks.filter((hook) => {
|
||||
const expected = expectedManagedContract(hook.event);
|
||||
if (!expected) return true;
|
||||
const managedId = managedAgentId(hook, expected[0], expected[1]);
|
||||
if (!managedId) return true;
|
||||
return agentId !== undefined && !sameAgentId(managedId, agentId);
|
||||
});
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
hooks: readonly KimiHook[],
|
||||
event: ManagedKimiEvent,
|
||||
scriptName: string,
|
||||
): Map<string, KimiRuntimeReference[]> {
|
||||
const result = new Map<string, KimiRuntimeReference[]>();
|
||||
for (const hook of hooks) {
|
||||
const reference = managedReference(hook, event, scriptName);
|
||||
if (!reference) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
const existing = result.get(key) ?? [];
|
||||
existing.push(reference);
|
||||
result.set(key, existing);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function kimiRuntimeContracts(
|
||||
documents: readonly KimiConfigDocument[],
|
||||
): KimiRuntimeContract[] {
|
||||
const contracts: KimiRuntimeContract[] = [];
|
||||
for (const document of documents) {
|
||||
const guards = referencesForEvent(document.hooks, 'PreToolUse', GUARD_SCRIPT);
|
||||
const successes = referencesForEvent(document.hooks, 'PostToolUse', AUDIT_SCRIPT);
|
||||
const failures = referencesForEvent(document.hooks, 'PostToolUseFailure', AUDIT_SCRIPT);
|
||||
for (const [key, guard] of guards) {
|
||||
const success = successes.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || success?.length !== 1 || failure?.length !== 1) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: success[0].scriptPath,
|
||||
configPath: document.contract.configPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type KimiConfigDocument,
|
||||
type RenderedKimiDocument,
|
||||
} from './kimi-contract.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Kimi';
|
||||
|
||||
function hookLocations(documents: readonly KimiConfigDocument[]) {
|
||||
return documents.map((document) => ({
|
||||
directoryLabel: document.contract.directoryLabel,
|
||||
filePath: document.contract.configPath,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function preflightKimiInstallation(
|
||||
config: InstallConfig,
|
||||
documents: readonly KimiConfigDocument[],
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocations(documents),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function renderedSource(rendered: RenderedKimiDocument): string {
|
||||
const source = rendered.next ?? rendered.document.raw;
|
||||
if (source === undefined) {
|
||||
throw new Error(`${rendered.document.contract.label} installation source is missing`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function prepareKimiInstallation(
|
||||
config: InstallConfig,
|
||||
rendered: readonly RenderedKimiDocument[],
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: rendered.map((item) => ({
|
||||
directoryLabel: item.document.contract.directoryLabel,
|
||||
label: item.document.contract.label,
|
||||
filePath: item.document.contract.configPath,
|
||||
expectedSource: item.document.raw,
|
||||
source: renderedSource(item),
|
||||
})),
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitKimiInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareKimiUninstall(
|
||||
rendered: readonly RenderedKimiDocument[],
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const changed = rendered.filter((item) => item.changed);
|
||||
const changes = await Promise.all(changed.map((item) => prepareManagedFileChange({
|
||||
filePath: item.document.contract.configPath,
|
||||
label: item.document.contract.label,
|
||||
next: item.next,
|
||||
mode: 0o600,
|
||||
expectedSource: item.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
})));
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: changed.map((item) => ({
|
||||
path: path.dirname(item.document.contract.configPath),
|
||||
label: item.document.contract.directoryLabel,
|
||||
})),
|
||||
changes: changes.filter((change) => change !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitKimiUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { asError, errorMessage, hasCode, samePath } from './common.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
LEGACY_EVENTS,
|
||||
STABLE_EVENTS,
|
||||
createKimiDocument,
|
||||
parseKimiDocument,
|
||||
type KimiConfigDocument,
|
||||
type KimiContract,
|
||||
type KimiRuntimeContract,
|
||||
} from './kimi-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
async function pathEntryExists(filePath: string, label: string): Promise<boolean> {
|
||||
try {
|
||||
await fsp.lstat(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
|
||||
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stableContract(configPath: string): KimiContract {
|
||||
return {
|
||||
generation: 'stable',
|
||||
runtimeName: 'Kimi Code',
|
||||
label: 'Kimi Code hooks config',
|
||||
directoryLabel: 'Kimi Code home directory',
|
||||
configPath,
|
||||
events: STABLE_EVENTS,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyContract(configPath: string): KimiContract {
|
||||
return {
|
||||
generation: 'legacy',
|
||||
runtimeName: 'kimi-cli',
|
||||
label: 'kimi-cli legacy hooks config',
|
||||
directoryLabel: 'kimi-cli legacy home directory',
|
||||
configPath,
|
||||
events: LEGACY_EVENTS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveKimiContracts(): Promise<KimiContract[]> {
|
||||
const home = os.homedir();
|
||||
const configuredHome = process.env.KIMI_CODE_HOME;
|
||||
const explicitHome = configuredHome === undefined || configuredHome === ''
|
||||
? undefined
|
||||
: path.resolve(configuredHome);
|
||||
const stableHome = explicitHome ?? path.join(home, '.kimi-code');
|
||||
const legacyHome = path.join(home, '.kimi');
|
||||
const stable = stableContract(path.join(stableHome, 'config.toml'));
|
||||
const legacy = legacyContract(path.join(legacyHome, 'config.toml'));
|
||||
if (samePath(stable.configPath, legacy.configPath)) return [stable];
|
||||
|
||||
const stableDetected = explicitHome !== undefined
|
||||
|| await pathEntryExists(stableHome, 'Kimi Code home');
|
||||
const legacyDetected = await pathEntryExists(legacyHome, 'kimi-cli legacy home');
|
||||
if (legacyDetected && !stableDetected) return [legacy];
|
||||
return legacyDetected ? [stable, legacy] : [stable];
|
||||
}
|
||||
|
||||
async function readKimiDocument(contract: KimiContract): Promise<KimiConfigDocument> {
|
||||
await inspectPhysicalDirectory(path.dirname(contract.configPath), contract.directoryLabel);
|
||||
const snapshot = await readPhysicalFile(contract.configPath, contract.label);
|
||||
return snapshot
|
||||
? parseKimiDocument(contract, snapshot.contents)
|
||||
: createKimiDocument(contract);
|
||||
}
|
||||
|
||||
export async function readKimiDocuments(): Promise<KimiConfigDocument[]> {
|
||||
const contracts = await resolveKimiContracts();
|
||||
const documents: KimiConfigDocument[] = [];
|
||||
for (const contract of contracts) documents.push(await readKimiDocument(contract));
|
||||
return documents;
|
||||
}
|
||||
|
||||
export async function kimiRuntimeFilesExist(
|
||||
contracts: readonly KimiRuntimeContract[],
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildKimiHook,
|
||||
kimiRuntimeContracts,
|
||||
removeManagedKimiHooks,
|
||||
renderKimiDocument,
|
||||
type KimiConfigDocument,
|
||||
type KimiHook,
|
||||
} from './kimi-contract.js';
|
||||
import {
|
||||
commitKimiInstallation,
|
||||
commitKimiUninstall,
|
||||
preflightKimiInstallation,
|
||||
prepareKimiInstallation,
|
||||
prepareKimiUninstall,
|
||||
} from './kimi-installation.js';
|
||||
import { kimiRuntimeFilesExist, readKimiDocuments } from './kimi-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function installedHooks(
|
||||
document: KimiConfigDocument,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): KimiHook[] {
|
||||
return [
|
||||
...removeManagedKimiHooks(document.hooks),
|
||||
buildKimiHook('PreToolUse', guardPath),
|
||||
buildKimiHook('PostToolUse', auditPath),
|
||||
buildKimiHook('PostToolUseFailure', auditPath),
|
||||
];
|
||||
}
|
||||
|
||||
export const kimiPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const documents = await readKimiDocuments();
|
||||
await preflightKimiInstallation(config, documents);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const documents = await readKimiDocuments();
|
||||
const paths = await preflightKimiInstallation(config, documents);
|
||||
const rendered = await Promise.all(documents.map((document) => renderKimiDocument(
|
||||
document,
|
||||
installedHooks(document, paths.guardPath, paths.auditPath),
|
||||
)));
|
||||
const prepared = await prepareKimiInstallation(config, rendered);
|
||||
await commitKimiInstallation(prepared);
|
||||
const runtimes = documents.map(({ contract }) => contract.runtimeName).join(' and ');
|
||||
console.log(
|
||||
` ${runtimes}: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.`,
|
||||
);
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const documents = await readKimiDocuments();
|
||||
const rendered = await Promise.all(documents.map((document) => renderKimiDocument(
|
||||
document,
|
||||
removeManagedKimiHooks(document.hooks, agentId),
|
||||
)));
|
||||
await commitKimiUninstall(await prepareKimiUninstall(rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const documents = await readKimiDocuments();
|
||||
const contracts = kimiRuntimeContracts(documents);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured && await kimiRuntimeFilesExist(contracts);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: contracts.at(-1)?.configPath ?? documents[0].contract.configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { managedScriptReference, sameAgentId, samePath } from './common.js';
|
||||
import {
|
||||
buildKiroIdeCommand,
|
||||
kiroIdeRuntimeReference,
|
||||
type KiroIdeRuntimeReference,
|
||||
} from './kiroide-command.js';
|
||||
import {
|
||||
buildKiroIdeHook,
|
||||
exactKeys,
|
||||
kiroIdeRuntimeContracts,
|
||||
renderKiroIdeDocument,
|
||||
type KiroIdeDocument,
|
||||
type KiroIdeHook,
|
||||
type RenderedKiroIdeDocument,
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { isNodeExecutable, parseLegacyWindowsCommand } from './shell-command.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'kirocli';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const V2_AGENT_NAME = 'elydora-audit';
|
||||
export const V3_CONFIG_FILE = 'elydora-audit.json';
|
||||
export const HOOK_TIMEOUT_MILLISECONDS = 10_000;
|
||||
|
||||
const V2_DESCRIPTION = 'Kiro CLI with Elydora audit and freeze enforcement';
|
||||
const V3_GUARD_NAME = 'elydora-guard';
|
||||
const V3_AUDIT_NAME = 'elydora-audit';
|
||||
const V2_EVENTS = new Set([
|
||||
'agentSpawn',
|
||||
'userPromptSubmit',
|
||||
'preToolUse',
|
||||
'postToolUse',
|
||||
'stop',
|
||||
]);
|
||||
|
||||
export interface KiroCliPaths {
|
||||
readonly homeDirectory: string;
|
||||
readonly kiroDirectory: string;
|
||||
readonly agentsDirectory: string;
|
||||
readonly hooksDirectory: string;
|
||||
readonly v2Path: string;
|
||||
readonly v3Path: string;
|
||||
}
|
||||
|
||||
export interface KiroCliV2Hook extends JsonObject {
|
||||
readonly command: string;
|
||||
readonly matcher?: string;
|
||||
readonly timeout_ms?: number;
|
||||
readonly cache_ttl_seconds?: number;
|
||||
}
|
||||
|
||||
export interface KiroCliV2Document {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: JsonObject;
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedKiroCliV2Document {
|
||||
readonly document: KiroCliV2Document;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export function resolveKiroCliPaths(homeDirectory = os.homedir()): KiroCliPaths {
|
||||
const resolvedHome = path.resolve(homeDirectory);
|
||||
const kiroDirectory = path.join(resolvedHome, '.kiro');
|
||||
const agentsDirectory = path.join(kiroDirectory, 'agents');
|
||||
const hooksDirectory = path.join(kiroDirectory, 'hooks');
|
||||
return {
|
||||
homeDirectory: resolvedHome,
|
||||
kiroDirectory,
|
||||
agentsDirectory,
|
||||
hooksDirectory,
|
||||
v2Path: path.join(agentsDirectory, `${V2_AGENT_NAME}.json`),
|
||||
v3Path: path.join(hooksDirectory, V3_CONFIG_FILE),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOptionalInteger(value: unknown, field: string, label: string): void {
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || (value as number) < 0)) {
|
||||
throw new Error(`${label} ${field} must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateV2Hook(value: unknown, event: string, index: number): KiroCliV2Hook {
|
||||
const label = `Kiro CLI v2 agent hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (typeof value.command !== 'string' || !value.command) {
|
||||
throw new Error(`${label} command must be a non-empty string`);
|
||||
}
|
||||
if (value.matcher !== undefined) {
|
||||
if (!['preToolUse', 'postToolUse'].includes(event)) {
|
||||
throw new Error(`${label} matcher is valid only for tool events`);
|
||||
}
|
||||
if (typeof value.matcher !== 'string' || !value.matcher) {
|
||||
throw new Error(`${label} matcher must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
validateOptionalInteger(value.timeout_ms, 'timeout_ms', label);
|
||||
validateOptionalInteger(value.cache_ttl_seconds, 'cache_ttl_seconds', label);
|
||||
return value as KiroCliV2Hook;
|
||||
}
|
||||
|
||||
function validateV2Hooks(value: unknown, filePath: string): JsonObject {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) {
|
||||
throw new Error(`Kiro CLI v2 agent field "hooks" must be an object: ${filePath}`);
|
||||
}
|
||||
const hooks: JsonObject = { ...value };
|
||||
for (const [event, entries] of Object.entries(value)) {
|
||||
if (!V2_EVENTS.has(event)) {
|
||||
throw new Error(`Kiro CLI v2 agent has unsupported hook event "${event}": ${filePath}`);
|
||||
}
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new Error(`Kiro CLI v2 agent hooks.${event} must be an array: ${filePath}`);
|
||||
}
|
||||
hooks[event] = entries.map((entry, index) => validateV2Hook(entry, event, index));
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
function validateOptionalV2Fields(root: JsonObject, filePath: string): void {
|
||||
for (const field of ['name', 'description', 'prompt', 'model', 'keyboardShortcut', 'welcomeMessage']) {
|
||||
if (root[field] !== undefined && typeof root[field] !== 'string') {
|
||||
throw new Error(`Kiro CLI v2 agent field "${field}" must be a string: ${filePath}`);
|
||||
}
|
||||
}
|
||||
for (const field of ['tools', 'allowedTools', 'resources']) {
|
||||
const value = root[field];
|
||||
if (value !== undefined
|
||||
&& (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string'))) {
|
||||
throw new Error(`Kiro CLI v2 agent field "${field}" must be an array of strings: ${filePath}`);
|
||||
}
|
||||
}
|
||||
if (root.includeMcpJson !== undefined && typeof root.includeMcpJson !== 'boolean') {
|
||||
throw new Error(`Kiro CLI v2 agent field "includeMcpJson" must be a boolean: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseKiroCliV2Document(filePath: string, raw: string): KiroCliV2Document {
|
||||
const root = parseStrictJsonObject(raw, `Kiro CLI v2 agent at ${filePath}`);
|
||||
validateOptionalV2Fields(root, filePath);
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
root,
|
||||
hooks: validateV2Hooks(root.hooks, filePath),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createKiroCliV2Document(filePath: string): KiroCliV2Document {
|
||||
return { exists: false, filePath, root: {}, hooks: {} };
|
||||
}
|
||||
|
||||
function eventHooks(document: KiroCliV2Document, event: string): readonly KiroCliV2Hook[] {
|
||||
return (document.hooks[event] as readonly KiroCliV2Hook[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
function legacyWindowsReference(
|
||||
command: unknown,
|
||||
scriptName: string,
|
||||
): KiroIdeRuntimeReference | undefined {
|
||||
if (typeof command !== 'string') return undefined;
|
||||
const parsed = parseLegacyWindowsCommand(command);
|
||||
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||
return managedScriptReference(parsed[1], scriptName);
|
||||
}
|
||||
|
||||
function ownedReference(command: unknown, scriptName: string): KiroIdeRuntimeReference | undefined {
|
||||
return typeof command === 'string'
|
||||
? kiroIdeRuntimeReference(command, scriptName) ?? legacyWindowsReference(command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function v2OwnedReference(hook: KiroCliV2Hook, event: string) {
|
||||
if (event === 'preToolUse') return ownedReference(hook.command, GUARD_SCRIPT);
|
||||
if (event === 'postToolUse') return ownedReference(hook.command, AUDIT_SCRIPT);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function v2ManagedReference(hook: KiroCliV2Hook, event: string) {
|
||||
const reference = v2OwnedReference(hook, event);
|
||||
if (!reference
|
||||
|| !exactKeys(hook, ['matcher', 'command', 'timeout_ms'])
|
||||
|| hook.matcher !== '*'
|
||||
|| hook.timeout_ms !== HOOK_TIMEOUT_MILLISECONDS) return undefined;
|
||||
return reference;
|
||||
}
|
||||
|
||||
function managedMetadata(root: JsonObject): boolean {
|
||||
return root.name === V2_AGENT_NAME
|
||||
&& root.description === V2_DESCRIPTION
|
||||
&& Array.isArray(root.tools)
|
||||
&& root.tools.length === 1
|
||||
&& root.tools[0] === '*'
|
||||
&& root.includeMcpJson === true;
|
||||
}
|
||||
|
||||
function hasOwnedV2Hooks(document: KiroCliV2Document, agentId?: string): boolean {
|
||||
return ['preToolUse', 'postToolUse'].some((event) => eventHooks(document, event).some((hook) => {
|
||||
const reference = v2OwnedReference(hook, event);
|
||||
return reference !== undefined
|
||||
&& (agentId === undefined || sameAgentId(reference.agentId, agentId));
|
||||
}));
|
||||
}
|
||||
|
||||
export function requireAvailableKiroCliV2Document(document: KiroCliV2Document): void {
|
||||
if (document.exists && !managedMetadata(document.root) && !hasOwnedV2Hooks(document)) {
|
||||
throw new Error(`Kiro CLI v2 agent path conflicts with the Elydora contract: ${document.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function withoutManagedKiroCliV2Hooks(
|
||||
document: KiroCliV2Document,
|
||||
agentId?: string,
|
||||
): JsonObject {
|
||||
const next = { ...document.hooks };
|
||||
for (const event of ['preToolUse', 'postToolUse']) {
|
||||
const remaining = eventHooks(document, event).filter((hook) => {
|
||||
const reference = v2OwnedReference(hook, event);
|
||||
return !reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId));
|
||||
});
|
||||
if (remaining.length > 0) next[event] = remaining;
|
||||
else delete next[event];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildV2Hook(scriptPath: string): KiroCliV2Hook {
|
||||
return {
|
||||
matcher: '*',
|
||||
command: buildKiroIdeCommand(scriptPath),
|
||||
timeout_ms: HOOK_TIMEOUT_MILLISECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKiroCliV2Hooks(
|
||||
document: KiroCliV2Document,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): JsonObject {
|
||||
const hooks = withoutManagedKiroCliV2Hooks(document);
|
||||
hooks.preToolUse = [...eventHooks({ ...document, hooks }, 'preToolUse'), buildV2Hook(guardPath)];
|
||||
hooks.postToolUse = [...eventHooks({ ...document, hooks }, 'postToolUse'), buildV2Hook(auditPath)];
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function renderKiroCliV2Installation(
|
||||
document: KiroCliV2Document,
|
||||
hooks: JsonObject,
|
||||
): RenderedKiroCliV2Document {
|
||||
const next = `${JSON.stringify({
|
||||
...document.root,
|
||||
name: V2_AGENT_NAME,
|
||||
description: V2_DESCRIPTION,
|
||||
tools: ['*'],
|
||||
includeMcpJson: true,
|
||||
hooks,
|
||||
}, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
function entirelyManagedV2Document(document: KiroCliV2Document, agentId?: string): boolean {
|
||||
if (!document.exists
|
||||
|| !managedMetadata(document.root)
|
||||
|| !exactKeys(document.root, ['name', 'description', 'tools', 'includeMcpJson', 'hooks'])
|
||||
|| !exactKeys(document.hooks, ['preToolUse', 'postToolUse'])) return false;
|
||||
for (const event of ['preToolUse', 'postToolUse']) {
|
||||
const hooks = eventHooks(document, event);
|
||||
if (hooks.length !== 1) return false;
|
||||
const reference = v2OwnedReference(hooks[0], event);
|
||||
if (!reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function renderKiroCliV2Uninstall(
|
||||
document: KiroCliV2Document,
|
||||
hooks: JsonObject,
|
||||
agentId?: string,
|
||||
): RenderedKiroCliV2Document {
|
||||
if (!hasOwnedV2Hooks(document, agentId)) return { document, changed: false };
|
||||
if (entirelyManagedV2Document(document, agentId) && Object.keys(hooks).length === 0) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
const next = `${JSON.stringify({ ...document.root, hooks }, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
export function kiroCliV2RuntimeContracts(
|
||||
document: KiroCliV2Document,
|
||||
): KiroIdeRuntimeContract[] {
|
||||
for (const event of ['preToolUse', 'postToolUse']) {
|
||||
if (eventHooks(document, event).some((hook) => (
|
||||
v2OwnedReference(hook, event) !== undefined
|
||||
&& v2ManagedReference(hook, event) === undefined
|
||||
))) return [];
|
||||
}
|
||||
const guards = eventHooks(document, 'preToolUse')
|
||||
.map((hook) => v2ManagedReference(hook, 'preToolUse'))
|
||||
.filter((value): value is KiroIdeRuntimeReference => value !== undefined);
|
||||
const audits = eventHooks(document, 'postToolUse')
|
||||
.map((hook) => v2ManagedReference(hook, 'postToolUse'))
|
||||
.filter((value): value is KiroIdeRuntimeReference => value !== undefined);
|
||||
if (guards.length !== 1 || audits.length !== 1
|
||||
|| !sameAgentId(guards[0].agentId, audits[0].agentId)) return [];
|
||||
return [{ agentId: guards[0].agentId, guardPath: guards[0].scriptPath, auditPath: audits[0].scriptPath }];
|
||||
}
|
||||
|
||||
function v3Specification(name: string) {
|
||||
if (name === V3_GUARD_NAME) return { scriptName: GUARD_SCRIPT };
|
||||
if (name === V3_AUDIT_NAME) return { scriptName: AUDIT_SCRIPT };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function v3OwnedReference(hook: KiroIdeHook) {
|
||||
const specification = v3Specification(hook.name);
|
||||
return specification && hook.action.type === 'command'
|
||||
? ownedReference(hook.action.command, specification.scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function requireAvailableKiroCliV3Hooks(hooks: readonly KiroIdeHook[]): void {
|
||||
for (const hook of hooks) {
|
||||
if (v3Specification(hook.name) && !v3OwnedReference(hook)) {
|
||||
throw new Error(`Kiro CLI v3 hook name "${hook.name}" conflicts with the Elydora contract`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function withoutManagedKiroCliV3Hooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
agentId?: string,
|
||||
): KiroIdeHook[] {
|
||||
return hooks.filter((hook) => {
|
||||
const reference = v3OwnedReference(hook);
|
||||
return !reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
function hasOwnedV3Hooks(hooks: readonly KiroIdeHook[], agentId?: string): boolean {
|
||||
return hooks.some((hook) => {
|
||||
const reference = v3OwnedReference(hook);
|
||||
return reference !== undefined
|
||||
&& (agentId === undefined || sameAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
function entirelyManagedV3Document(document: KiroIdeDocument, agentId?: string): boolean {
|
||||
return document.exists
|
||||
&& exactKeys(document.root, ['version', 'hooks'])
|
||||
&& document.hooks.length > 0
|
||||
&& document.hooks.every((hook) => {
|
||||
const reference = v3OwnedReference(hook);
|
||||
return reference !== undefined
|
||||
&& (agentId === undefined || sameAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
export function renderKiroCliV3Uninstall(
|
||||
document: KiroIdeDocument,
|
||||
hooks: readonly KiroIdeHook[],
|
||||
agentId?: string,
|
||||
): RenderedKiroIdeDocument {
|
||||
if (!hasOwnedV3Hooks(document.hooks, agentId)) return { document, changed: false };
|
||||
if (hooks.length === 0 && entirelyManagedV3Document(document, agentId)) {
|
||||
return { document, changed: true };
|
||||
}
|
||||
return renderKiroIdeDocument(document, hooks);
|
||||
}
|
||||
|
||||
export function buildKiroCliV3Hooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): KiroIdeHook[] {
|
||||
return [
|
||||
...withoutManagedKiroCliV3Hooks(hooks),
|
||||
buildKiroIdeHook(V3_GUARD_NAME, guardPath),
|
||||
buildKiroIdeHook(V3_AUDIT_NAME, auditPath),
|
||||
];
|
||||
}
|
||||
|
||||
export function commonKiroCliRuntimeContract(
|
||||
v2Document: KiroCliV2Document,
|
||||
v3Hooks: readonly KiroIdeHook[],
|
||||
): KiroIdeRuntimeContract | undefined {
|
||||
const v2 = kiroCliV2RuntimeContracts(v2Document);
|
||||
const v3 = kiroIdeRuntimeContracts(v3Hooks);
|
||||
if (v2.length !== 1 || v3.length !== 1
|
||||
|| !sameAgentId(v2[0].agentId, v3[0].agentId)
|
||||
|| !samePath(v2[0].guardPath, v3[0].guardPath)
|
||||
|| !samePath(v2[0].auditPath, v3[0].auditPath)) return undefined;
|
||||
return v2[0];
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type RenderedKiroCliV2Document,
|
||||
} from './kirocli-contract.js';
|
||||
import type { KiroCliSources } from './kirocli-io.js';
|
||||
import type { RenderedKiroIdeDocument } from './kiroide-contract.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Kiro CLI';
|
||||
const V2_DIRECTORY_LABEL = 'Kiro CLI agents directory';
|
||||
const V3_DIRECTORY_LABEL = 'Kiro CLI global hooks directory';
|
||||
|
||||
function hookLocations(sources: KiroCliSources) {
|
||||
return [
|
||||
{ directoryLabel: V2_DIRECTORY_LABEL, filePath: sources.paths.v2Path },
|
||||
{ directoryLabel: V3_DIRECTORY_LABEL, filePath: sources.paths.v3Path },
|
||||
];
|
||||
}
|
||||
|
||||
export async function preflightKiroCliInstallation(
|
||||
config: InstallConfig,
|
||||
sources: KiroCliSources,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocations(sources),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareKiroCliInstallation(
|
||||
config: InstallConfig,
|
||||
sources: KiroCliSources,
|
||||
v2: RenderedKiroCliV2Document,
|
||||
v3: RenderedKiroIdeDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
if (v2.next === undefined || v3.next === undefined) {
|
||||
throw new Error('Kiro CLI installation requires both v2 and v3 hook documents');
|
||||
}
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [
|
||||
{
|
||||
directoryLabel: V2_DIRECTORY_LABEL,
|
||||
label: 'Kiro CLI v2 agent config',
|
||||
filePath: sources.paths.v2Path,
|
||||
expectedSource: sources.v2.raw,
|
||||
source: v2.next,
|
||||
},
|
||||
{
|
||||
directoryLabel: V3_DIRECTORY_LABEL,
|
||||
label: 'Kiro CLI v3 global hooks',
|
||||
filePath: sources.paths.v3Path,
|
||||
expectedSource: sources.v3.raw,
|
||||
source: v3.next,
|
||||
},
|
||||
],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitKiroCliInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareKiroCliUninstall(
|
||||
sources: KiroCliSources,
|
||||
v2: RenderedKiroCliV2Document,
|
||||
v3: RenderedKiroIdeDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const changes: ManagedFileChange[] = [];
|
||||
const directories = [];
|
||||
for (const item of [
|
||||
{ rendered: v2, label: 'Kiro CLI v2 agent config', directoryLabel: V2_DIRECTORY_LABEL },
|
||||
{ rendered: v3, label: 'Kiro CLI v3 global hooks', directoryLabel: V3_DIRECTORY_LABEL },
|
||||
]) {
|
||||
if (!item.rendered.changed) continue;
|
||||
const change = await prepareManagedFileChange({
|
||||
filePath: item.rendered.document.filePath,
|
||||
label: item.label,
|
||||
next: item.rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: item.rendered.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (!change) continue;
|
||||
changes.push(change);
|
||||
directories.push({
|
||||
path: path.dirname(item.rendered.document.filePath),
|
||||
label: item.directoryLabel,
|
||||
});
|
||||
}
|
||||
return { displayName: DISPLAY_NAME, operation: 'uninstall', directories, changes };
|
||||
}
|
||||
|
||||
export async function commitKiroCliUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
AGENT_KEY,
|
||||
createKiroCliV2Document,
|
||||
parseKiroCliV2Document,
|
||||
resolveKiroCliPaths,
|
||||
type KiroCliPaths,
|
||||
type KiroCliV2Document,
|
||||
} from './kirocli-contract.js';
|
||||
import {
|
||||
createKiroIdeDocument,
|
||||
parseKiroIdeDocument,
|
||||
type KiroIdeDocument,
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { MAX_CONFIG_BYTES } from './common.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
export interface KiroCliSources {
|
||||
readonly paths: KiroCliPaths;
|
||||
readonly v2: KiroCliV2Document;
|
||||
readonly v3: KiroIdeDocument;
|
||||
}
|
||||
|
||||
async function inspectKiroCliDirectories(paths: KiroCliPaths): Promise<void> {
|
||||
await inspectPhysicalDirectory(paths.homeDirectory, 'Kiro CLI home directory');
|
||||
await inspectPhysicalDirectory(paths.kiroDirectory, 'Kiro CLI configuration directory');
|
||||
await Promise.all([
|
||||
inspectPhysicalDirectory(paths.agentsDirectory, 'Kiro CLI agents directory'),
|
||||
inspectPhysicalDirectory(paths.hooksDirectory, 'Kiro CLI hooks directory'),
|
||||
]);
|
||||
}
|
||||
|
||||
async function readV2Document(paths: KiroCliPaths): Promise<KiroCliV2Document> {
|
||||
const snapshot = await readPhysicalFile(
|
||||
paths.v2Path,
|
||||
'Kiro CLI v2 agent config',
|
||||
MAX_CONFIG_BYTES,
|
||||
);
|
||||
return snapshot
|
||||
? parseKiroCliV2Document(paths.v2Path, snapshot.contents)
|
||||
: createKiroCliV2Document(paths.v2Path);
|
||||
}
|
||||
|
||||
async function readV3Document(paths: KiroCliPaths): Promise<KiroIdeDocument> {
|
||||
const snapshot = await readPhysicalFile(
|
||||
paths.v3Path,
|
||||
'Kiro CLI v3 global hooks',
|
||||
MAX_CONFIG_BYTES,
|
||||
);
|
||||
return snapshot
|
||||
? parseKiroIdeDocument(paths.v3Path, snapshot.contents, 'Kiro CLI v3 global hooks')
|
||||
: createKiroIdeDocument(paths.v3Path);
|
||||
}
|
||||
|
||||
export async function readKiroCliSources(): Promise<KiroCliSources> {
|
||||
const paths = resolveKiroCliPaths();
|
||||
await inspectKiroCliDirectories(paths);
|
||||
const [v2, v3] = await Promise.all([readV2Document(paths), readV3Document(paths)]);
|
||||
return { paths, v2, v3 };
|
||||
}
|
||||
|
||||
export async function kiroCliRuntimeFilesExist(
|
||||
contract: KiroIdeRuntimeContract,
|
||||
): Promise<boolean> {
|
||||
return managedRuntimeFilesExist(contract, AGENT_KEY);
|
||||
}
|
||||
+73
-149
@@ -1,173 +1,97 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildKiroCliV2Hooks,
|
||||
buildKiroCliV3Hooks,
|
||||
commonKiroCliRuntimeContract,
|
||||
renderKiroCliV2Installation,
|
||||
renderKiroCliV2Uninstall,
|
||||
renderKiroCliV3Uninstall,
|
||||
requireAvailableKiroCliV2Document,
|
||||
requireAvailableKiroCliV3Hooks,
|
||||
withoutManagedKiroCliV2Hooks,
|
||||
withoutManagedKiroCliV3Hooks,
|
||||
} from './kirocli-contract.js';
|
||||
import {
|
||||
commitKiroCliInstallation,
|
||||
commitKiroCliUninstall,
|
||||
preflightKiroCliInstallation,
|
||||
prepareKiroCliInstallation,
|
||||
prepareKiroCliUninstall,
|
||||
} from './kirocli-installation.js';
|
||||
import { kiroCliRuntimeFilesExist, readKiroCliSources } from './kirocli-io.js';
|
||||
import { renderKiroIdeDocument } from './kiroide-contract.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'kirocli';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
function validateSources(
|
||||
sources: Awaited<ReturnType<typeof readKiroCliSources>>,
|
||||
): void {
|
||||
requireAvailableKiroCliV2Document(sources.v2);
|
||||
requireAvailableKiroCliV3Hooks(sources.v3.hooks);
|
||||
}
|
||||
|
||||
export const kirocliPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readKiroCliSources();
|
||||
validateSources(sources);
|
||||
await preflightKiroCliInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath), timeout_ms: 5000 }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath), timeout_ms: 5000 }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readKiroCliSources();
|
||||
validateSources(sources);
|
||||
const paths = await preflightKiroCliInstallation(config, sources);
|
||||
const v2 = renderKiroCliV2Installation(
|
||||
sources.v2,
|
||||
buildKiroCliV2Hooks(sources.v2, paths.guardPath, paths.auditPath),
|
||||
);
|
||||
const v3 = renderKiroIdeDocument(
|
||||
sources.v3,
|
||||
buildKiroCliV3Hooks(sources.v3.hooks, paths.guardPath, paths.auditPath),
|
||||
);
|
||||
const prepared = await prepareKiroCliInstallation(config, sources, v2, v3);
|
||||
await commitKiroCliInstallation(prepared);
|
||||
console.log(` Kiro CLI v2 agent: ${sources.paths.v2Path}`);
|
||||
console.log(' Kiro CLI v2 verification: run "kiro-cli agent validate --path <agent-file>".');
|
||||
console.log(' Kiro CLI v2 activation: start with "kiro-cli --agent elydora-audit".');
|
||||
console.log(` Kiro CLI v3 global hooks (2.13.0+): ${sources.paths.v3Path}`);
|
||||
console.log(' Kiro CLI v3 activation: start the TUI with "kiro-cli --v3".');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readKiroCliSources();
|
||||
const v2 = renderKiroCliV2Uninstall(
|
||||
sources.v2,
|
||||
withoutManagedKiroCliV2Hooks(sources.v2, agentId),
|
||||
agentId,
|
||||
);
|
||||
const v3 = renderKiroCliV3Uninstall(
|
||||
sources.v3,
|
||||
withoutManagedKiroCliV3Hooks(sources.v3.hooks, agentId),
|
||||
agentId,
|
||||
);
|
||||
const prepared = await prepareKiroCliUninstall(sources, v2, v3);
|
||||
await commitKiroCliUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readKiroCliSources();
|
||||
validateSources(sources);
|
||||
const contract = commonKiroCliRuntimeContract(sources.v2, sources.v3.hooks);
|
||||
const hookConfigured = contract !== undefined;
|
||||
const hookScriptExists = contract !== undefined
|
||||
&& await kiroCliRuntimeFilesExist(contract);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.paths.v3Path,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from 'node:path';
|
||||
import { managedScriptReference, type ManagedScriptReference } from './common.js';
|
||||
import {
|
||||
encodedWindowsCommand,
|
||||
isNodeExecutable,
|
||||
parseEncodedWindowsCommand,
|
||||
parsePosixCommand,
|
||||
posixSource,
|
||||
} from './shell-command.js';
|
||||
|
||||
export type KiroIdeRuntimeReference = ManagedScriptReference;
|
||||
|
||||
export function buildKiroIdeCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Kiro IDE hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32' ? encodedWindowsCommand(scriptPath) : posixSource(scriptPath);
|
||||
}
|
||||
|
||||
export function kiroIdeRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): KiroIdeRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parseEncodedWindowsCommand(command)
|
||||
: parsePosixCommand(command);
|
||||
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||
return managedScriptReference(parsed[1], scriptName);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { managedScriptReference, sameAgentId } from './common.js';
|
||||
import {
|
||||
buildKiroIdeCommand,
|
||||
kiroIdeRuntimeReference,
|
||||
type KiroIdeRuntimeReference,
|
||||
} from './kiroide-command.js';
|
||||
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'kiroide';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const CONFIG_FILE = 'elydora-audit.json';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const LEGACY_CONFIG_FILE = 'elydora-audit.kiro.hook';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
|
||||
const GUARD_NAME = 'elydora-guard';
|
||||
const AUDIT_NAME = 'elydora-audit';
|
||||
const GUARD_DESCRIPTION = 'Block tool use when the Elydora agent is frozen';
|
||||
const AUDIT_DESCRIPTION = 'Record tool use in the Elydora audit trail';
|
||||
const KIRO_TRIGGERS = new Set([
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PreTaskExec',
|
||||
'PostTaskExec',
|
||||
'UserPromptSubmit',
|
||||
'PostFileCreate',
|
||||
'PostFileSave',
|
||||
'PostFileDelete',
|
||||
]);
|
||||
|
||||
export interface KiroIdeAction extends JsonObject {
|
||||
readonly type: 'command' | 'agent';
|
||||
readonly command?: string;
|
||||
readonly prompt?: string;
|
||||
}
|
||||
|
||||
export interface KiroIdeHook extends JsonObject {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly trigger: string;
|
||||
readonly matcher?: string;
|
||||
readonly action: KiroIdeAction;
|
||||
readonly timeout?: number;
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface KiroIdeDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: readonly KiroIdeHook[];
|
||||
readonly raw?: string;
|
||||
}
|
||||
|
||||
export interface RenderedKiroIdeDocument {
|
||||
readonly document: KiroIdeDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
export interface KiroIdeRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface KiroIdePaths {
|
||||
readonly workspaceRoot: string;
|
||||
readonly kiroDirectory: string;
|
||||
readonly hooksDirectory: string;
|
||||
readonly configPath: string;
|
||||
readonly legacyConfigPath: string;
|
||||
}
|
||||
|
||||
export function resolveKiroIdePaths(
|
||||
workspaceRoot = process.cwd(),
|
||||
homeDirectory = os.homedir(),
|
||||
): KiroIdePaths {
|
||||
const resolvedWorkspace = path.resolve(workspaceRoot);
|
||||
const kiroDirectory = path.join(resolvedWorkspace, '.kiro');
|
||||
const hooksDirectory = path.join(kiroDirectory, 'hooks');
|
||||
return {
|
||||
workspaceRoot: resolvedWorkspace,
|
||||
kiroDirectory,
|
||||
hooksDirectory,
|
||||
configPath: path.join(hooksDirectory, CONFIG_FILE),
|
||||
legacyConfigPath: path.join(homeDirectory, '.kiro', 'hooks', LEGACY_CONFIG_FILE),
|
||||
};
|
||||
}
|
||||
|
||||
function validateAction(value: unknown, label: string): KiroIdeAction {
|
||||
if (!isObject(value)) throw new Error(`${label} action must be an object`);
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || !value.command) {
|
||||
throw new Error(`${label} command action requires a non-empty command`);
|
||||
}
|
||||
return value as KiroIdeAction;
|
||||
}
|
||||
if (value.type === 'agent') {
|
||||
if (typeof value.prompt !== 'string' || !value.prompt) {
|
||||
throw new Error(`${label} agent action requires a non-empty prompt`);
|
||||
}
|
||||
return value as KiroIdeAction;
|
||||
}
|
||||
throw new Error(`${label} action has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
|
||||
function validateHook(value: unknown, index: number, documentLabel: string): KiroIdeHook {
|
||||
const label = `${documentLabel} hooks[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (typeof value.name !== 'string' || !value.name) throw new Error(`${label} requires a name`);
|
||||
if (typeof value.trigger !== 'string' || !KIRO_TRIGGERS.has(value.trigger)) {
|
||||
throw new Error(`${label} has unsupported trigger "${String(value.trigger)}"`);
|
||||
}
|
||||
if (value.description !== undefined && typeof value.description !== 'string') {
|
||||
throw new Error(`${label} description must be a string`);
|
||||
}
|
||||
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
if (value.timeout !== undefined
|
||||
&& (!Number.isSafeInteger(value.timeout) || (value.timeout as number) < 0)) {
|
||||
throw new Error(`${label} timeout must be a non-negative integer`);
|
||||
}
|
||||
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
||||
throw new Error(`${label} enabled must be a boolean`);
|
||||
}
|
||||
return { ...value, action: validateAction(value.action, label) } as KiroIdeHook;
|
||||
}
|
||||
|
||||
export function parseKiroIdeDocument(
|
||||
filePath: string,
|
||||
raw: string,
|
||||
documentLabel = 'Kiro IDE hooks',
|
||||
): KiroIdeDocument {
|
||||
const root = parseStrictJsonObject(raw, `${documentLabel} at ${filePath}`);
|
||||
if (root.version !== 'v1') throw new Error(`${documentLabel} version must be "v1": ${filePath}`);
|
||||
if (!Array.isArray(root.hooks)) throw new Error(`${documentLabel} field "hooks" must be an array: ${filePath}`);
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
root,
|
||||
hooks: root.hooks.map((hook, index) => validateHook(hook, index, documentLabel)),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function createKiroIdeDocument(filePath: string): KiroIdeDocument {
|
||||
return { exists: false, filePath, root: {}, hooks: [] };
|
||||
}
|
||||
|
||||
export function exactKeys(value: JsonObject, keys: readonly string[]): boolean {
|
||||
return Object.keys(value).sort().join('|') === [...keys].sort().join('|');
|
||||
}
|
||||
|
||||
function managedSpecification(name: string): {
|
||||
readonly description: string;
|
||||
readonly trigger: 'PreToolUse' | 'PostToolUse';
|
||||
readonly scriptName: string;
|
||||
} | undefined {
|
||||
if (name === GUARD_NAME) {
|
||||
return { description: GUARD_DESCRIPTION, trigger: 'PreToolUse', scriptName: GUARD_SCRIPT };
|
||||
}
|
||||
if (name === AUDIT_NAME) {
|
||||
return { description: AUDIT_DESCRIPTION, trigger: 'PostToolUse', scriptName: AUDIT_SCRIPT };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function managedReference(hook: KiroIdeHook) {
|
||||
const specification = managedSpecification(hook.name);
|
||||
const owned = ownedReference(hook);
|
||||
if (!specification
|
||||
|| !owned
|
||||
|| !exactKeys(hook, ['name', 'description', 'trigger', 'matcher', 'action', 'timeout', 'enabled'])
|
||||
|| hook.description !== specification.description
|
||||
|| hook.trigger !== specification.trigger
|
||||
|| hook.matcher !== '.*'
|
||||
|| hook.timeout !== HOOK_TIMEOUT_SECONDS
|
||||
|| hook.enabled !== true
|
||||
|| !exactKeys(hook.action, ['type', 'command'])) return undefined;
|
||||
return owned;
|
||||
}
|
||||
|
||||
function ownedReference(hook: KiroIdeHook) {
|
||||
const specification = managedSpecification(hook.name);
|
||||
if (!specification
|
||||
|| hook.action.type !== 'command'
|
||||
|| typeof hook.action.command !== 'string') return undefined;
|
||||
return kiroIdeRuntimeReference(hook.action.command, specification.scriptName);
|
||||
}
|
||||
|
||||
export function requireAvailableKiroIdeHooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
productLabel = 'Kiro IDE',
|
||||
): void {
|
||||
for (const hook of hooks) {
|
||||
if (managedSpecification(hook.name) && !ownedReference(hook)) {
|
||||
throw new Error(`${productLabel} hook name "${hook.name}" conflicts with the Elydora contract`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKiroIdeHook(
|
||||
name: typeof GUARD_NAME | typeof AUDIT_NAME,
|
||||
scriptPath: string,
|
||||
): KiroIdeHook {
|
||||
const specification = managedSpecification(name)!;
|
||||
return {
|
||||
name,
|
||||
description: specification.description,
|
||||
trigger: specification.trigger,
|
||||
matcher: '.*',
|
||||
action: { type: 'command', command: buildKiroIdeCommand(scriptPath) },
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function withoutManagedKiroIdeHooks(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
agentId?: string,
|
||||
): KiroIdeHook[] {
|
||||
return hooks.filter((hook) => {
|
||||
const reference = ownedReference(hook);
|
||||
return !reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
function entirelyManaged(document: KiroIdeDocument): boolean {
|
||||
return document.exists
|
||||
&& exactKeys(document.root, ['version', 'hooks'])
|
||||
&& document.hooks.length > 0
|
||||
&& document.hooks.every((hook) => managedReference(hook) !== undefined);
|
||||
}
|
||||
|
||||
export function renderKiroIdeDocument(
|
||||
document: KiroIdeDocument,
|
||||
hooks: readonly KiroIdeHook[],
|
||||
): RenderedKiroIdeDocument {
|
||||
if (!document.exists && hooks.length === 0) return { document, changed: false };
|
||||
if (document.exists
|
||||
&& hooks.length === document.hooks.length
|
||||
&& hooks.every((hook, index) => hook === document.hooks[index])) {
|
||||
return { document, changed: false };
|
||||
}
|
||||
if (hooks.length === 0 && entirelyManaged(document)) return { document, changed: true };
|
||||
const next = `${JSON.stringify({ ...document.root, version: 'v1', hooks }, null, 2)}\n`;
|
||||
return { document, changed: next !== document.raw, next };
|
||||
}
|
||||
|
||||
export function kiroIdeRuntimeContracts(
|
||||
hooks: readonly KiroIdeHook[],
|
||||
): KiroIdeRuntimeContract[] {
|
||||
const guards = new Map<string, KiroIdeRuntimeReference[]>();
|
||||
const audits = new Map<string, KiroIdeRuntimeReference[]>();
|
||||
for (const hook of hooks) {
|
||||
if (!managedSpecification(hook.name)) continue;
|
||||
const reference = managedReference(hook);
|
||||
if (!reference) return [];
|
||||
const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId;
|
||||
const target = hook.name === GUARD_NAME ? guards : audits;
|
||||
const entries = target.get(key) ?? [];
|
||||
entries.push(reference);
|
||||
target.set(key, entries);
|
||||
}
|
||||
const contracts: KiroIdeRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const audit = audits.get(key);
|
||||
if (guard.length !== 1 || audit?.length !== 1 || !guard[0] || !audit[0]) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: audit[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
|
||||
function legacyReference(command: unknown, scriptName: string) {
|
||||
if (typeof command !== 'string') return undefined;
|
||||
const match = /^node "([^"\r\n]+)"$/.exec(command);
|
||||
return match ? managedScriptReference(match[1], scriptName) : undefined;
|
||||
}
|
||||
|
||||
export function legacyKiroIdeRuntimeContract(raw: string, filePath: string): KiroIdeRuntimeContract | undefined {
|
||||
const root = parseStrictJsonObject(raw, `legacy Kiro IDE hook at ${filePath}`);
|
||||
if (!exactKeys(root, ['name', 'description', 'version', 'hooks'])
|
||||
|| root.name !== 'Elydora Audit'
|
||||
|| root.description !== 'Sends tool-use events to the Elydora tamper-evident audit platform'
|
||||
|| root.version !== '1.0.0'
|
||||
|| !isObject(root.hooks)
|
||||
|| !exactKeys(root.hooks, ['pre_tool_use', 'post_tool_use'])) return undefined;
|
||||
const guardHook = root.hooks.pre_tool_use;
|
||||
const auditHook = root.hooks.post_tool_use;
|
||||
if (!isObject(guardHook)
|
||||
|| !isObject(auditHook)
|
||||
|| !exactKeys(guardHook, ['command', 'timeout_ms'])
|
||||
|| !exactKeys(auditHook, ['command', 'timeout_ms'])
|
||||
|| guardHook.timeout_ms !== 5000
|
||||
|| auditHook.timeout_ms !== 5000) return undefined;
|
||||
const guard = legacyReference(guardHook.command, GUARD_SCRIPT);
|
||||
const audit = legacyReference(auditHook.command, AUDIT_SCRIPT);
|
||||
if (!guard || !audit || !sameAgentId(guard.agentId, audit.agentId)) return undefined;
|
||||
return { agentId: guard.agentId, guardPath: guard.scriptPath, auditPath: audit.scriptPath };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import { sameAgentId } from './common.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
type KiroIdePaths,
|
||||
type RenderedKiroIdeDocument,
|
||||
} from './kiroide-contract.js';
|
||||
import {
|
||||
type KiroIdeSources,
|
||||
type LegacyKiroIdeDocument,
|
||||
requirePhysicalLegacyDirectory,
|
||||
} from './kiroide-io.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'Kiro IDE';
|
||||
const DIRECTORY_LABEL = 'Kiro IDE hooks directory';
|
||||
|
||||
function hookLocation(paths: KiroIdePaths) {
|
||||
return [{ directoryLabel: DIRECTORY_LABEL, filePath: paths.configPath }];
|
||||
}
|
||||
|
||||
function removesLegacy(legacy: LegacyKiroIdeDocument, agentId?: string): boolean {
|
||||
return legacy.contract !== undefined
|
||||
&& (agentId === undefined || sameAgentId(legacy.contract.agentId, agentId));
|
||||
}
|
||||
|
||||
async function prepareLegacyRemoval(
|
||||
legacy: LegacyKiroIdeDocument,
|
||||
agentId?: string,
|
||||
): Promise<ManagedFileChange | undefined> {
|
||||
if (!removesLegacy(legacy, agentId)) return undefined;
|
||||
if (legacy.raw === undefined) throw new Error(`Legacy Kiro IDE hook source is missing: ${legacy.filePath}`);
|
||||
await requirePhysicalLegacyDirectory(legacy);
|
||||
return prepareManagedFileChange({
|
||||
filePath: legacy.filePath,
|
||||
label: 'legacy Kiro IDE hook',
|
||||
next: undefined,
|
||||
mode: 0o600,
|
||||
expectedSource: legacy.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function preflightKiroIdeInstallation(
|
||||
config: InstallConfig,
|
||||
paths: KiroIdePaths,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: hookLocation(paths),
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareKiroIdeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
if (rendered.next === undefined) throw new Error('Kiro IDE installation requires a hook document');
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'Kiro IDE hooks',
|
||||
filePath: sources.paths.configPath,
|
||||
expectedSource: sources.document.raw,
|
||||
source: rendered.next,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
const legacyChange = await prepareLegacyRemoval(sources.legacy, config.agentId);
|
||||
if (!legacyChange) return prepared;
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
directories: [
|
||||
...prepared.transaction.directories,
|
||||
{ path: path.dirname(sources.legacy.filePath), label: 'legacy Kiro IDE hooks directory' },
|
||||
],
|
||||
changes: [...prepared.transaction.changes, legacyChange],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitKiroIdeInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareKiroIdeUninstall(
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
agentId?: string,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const changes: ManagedFileChange[] = [];
|
||||
const directories = [];
|
||||
if (rendered.changed) {
|
||||
const configChange = await prepareManagedFileChange({
|
||||
filePath: sources.document.filePath,
|
||||
label: 'Kiro IDE hooks',
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
expectedSource: sources.document.raw,
|
||||
verifyExpectedSource: true,
|
||||
});
|
||||
if (configChange) {
|
||||
changes.push(configChange);
|
||||
directories.push({ path: sources.paths.hooksDirectory, label: DIRECTORY_LABEL });
|
||||
}
|
||||
}
|
||||
const legacyChange = await prepareLegacyRemoval(sources.legacy, agentId);
|
||||
if (legacyChange) {
|
||||
changes.push(legacyChange);
|
||||
directories.push({
|
||||
path: path.dirname(sources.legacy.filePath),
|
||||
label: 'legacy Kiro IDE hooks directory',
|
||||
});
|
||||
}
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitKiroIdeUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createKiroIdeDocument,
|
||||
legacyKiroIdeRuntimeContract,
|
||||
parseKiroIdeDocument,
|
||||
resolveKiroIdePaths,
|
||||
type KiroIdeDocument,
|
||||
type KiroIdePaths,
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
|
||||
export interface LegacyKiroIdeDocument {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw?: string;
|
||||
readonly contract?: KiroIdeRuntimeContract;
|
||||
}
|
||||
|
||||
export interface KiroIdeSources {
|
||||
readonly paths: KiroIdePaths;
|
||||
readonly document: KiroIdeDocument;
|
||||
readonly legacy: LegacyKiroIdeDocument;
|
||||
}
|
||||
|
||||
async function inspectWorkspace(paths: KiroIdePaths): Promise<void> {
|
||||
if (!await inspectPhysicalDirectory(paths.workspaceRoot, 'Kiro IDE workspace')) {
|
||||
throw new Error(`Kiro IDE workspace is missing: ${paths.workspaceRoot}`);
|
||||
}
|
||||
await inspectPhysicalDirectory(paths.kiroDirectory, 'Kiro IDE configuration directory');
|
||||
await inspectPhysicalDirectory(paths.hooksDirectory, 'Kiro IDE hooks directory');
|
||||
}
|
||||
|
||||
async function readDocument(paths: KiroIdePaths): Promise<KiroIdeDocument> {
|
||||
await inspectWorkspace(paths);
|
||||
const snapshot = await readPhysicalFile(paths.configPath, 'Kiro IDE hooks');
|
||||
return snapshot
|
||||
? parseKiroIdeDocument(paths.configPath, snapshot.contents)
|
||||
: createKiroIdeDocument(paths.configPath);
|
||||
}
|
||||
|
||||
async function readLegacyDocument(paths: KiroIdePaths): Promise<LegacyKiroIdeDocument> {
|
||||
const snapshot = await readPhysicalFile(paths.legacyConfigPath, 'legacy Kiro IDE hook');
|
||||
if (!snapshot) return { exists: false, filePath: paths.legacyConfigPath };
|
||||
return {
|
||||
exists: true,
|
||||
filePath: paths.legacyConfigPath,
|
||||
raw: snapshot.contents,
|
||||
contract: legacyKiroIdeRuntimeContract(snapshot.contents, paths.legacyConfigPath),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readKiroIdeSources(): Promise<KiroIdeSources> {
|
||||
const paths = resolveKiroIdePaths();
|
||||
const [document, legacy] = await Promise.all([
|
||||
readDocument(paths),
|
||||
readLegacyDocument(paths),
|
||||
]);
|
||||
return { paths, document, legacy };
|
||||
}
|
||||
|
||||
export async function requirePhysicalLegacyDirectory(
|
||||
legacy: LegacyKiroIdeDocument,
|
||||
): Promise<void> {
|
||||
if (!legacy.exists) return;
|
||||
const hooksDirectory = path.dirname(legacy.filePath);
|
||||
const kiroDirectory = path.dirname(hooksDirectory);
|
||||
if (!await inspectPhysicalDirectory(kiroDirectory, 'legacy Kiro IDE configuration directory')) {
|
||||
throw new Error(`Legacy Kiro IDE configuration directory is missing: ${kiroDirectory}`);
|
||||
}
|
||||
if (!await inspectPhysicalDirectory(hooksDirectory, 'legacy Kiro IDE hooks directory')) {
|
||||
throw new Error(`Legacy Kiro IDE hooks directory is missing: ${hooksDirectory}`);
|
||||
}
|
||||
}
|
||||
+55
-73
@@ -1,94 +1,76 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
buildKiroIdeHook,
|
||||
kiroIdeRuntimeContracts,
|
||||
renderKiroIdeDocument,
|
||||
requireAvailableKiroIdeHooks,
|
||||
withoutManagedKiroIdeHooks,
|
||||
} from './kiroide-contract.js';
|
||||
import {
|
||||
commitKiroIdeInstallation,
|
||||
commitKiroIdeUninstall,
|
||||
preflightKiroIdeInstallation,
|
||||
prepareKiroIdeInstallation,
|
||||
prepareKiroIdeUninstall,
|
||||
} from './kiroide-installation.js';
|
||||
import { readKiroIdeSources } from './kiroide-io.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'kiroide';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
// .kiro/hooks is project-relative, but for global install use home dir
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
export const kiroidePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
managesRuntime: true,
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
const hookConfig = {
|
||||
name: 'Elydora Audit',
|
||||
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
||||
version: '1.0.0',
|
||||
hooks: {
|
||||
pre_tool_use: {
|
||||
command: `node "${config.guardScriptPath}"`,
|
||||
timeout_ms: 5000,
|
||||
},
|
||||
post_tool_use: {
|
||||
command: `node "${config.hookScriptPath}"`,
|
||||
timeout_ms: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8');
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readKiroIdeSources();
|
||||
requireAvailableKiroIdeHooks(sources.document.hooks);
|
||||
await preflightKiroIdeInstallation(config, sources.paths);
|
||||
},
|
||||
|
||||
async uninstall(_agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const sources = await readKiroIdeSources();
|
||||
requireAvailableKiroIdeHooks(sources.document.hooks);
|
||||
const paths = await preflightKiroIdeInstallation(config, sources.paths);
|
||||
const hooks = withoutManagedKiroIdeHooks(sources.document.hooks);
|
||||
const rendered = renderKiroIdeDocument(sources.document, [
|
||||
...hooks,
|
||||
buildKiroIdeHook('elydora-guard', paths.guardPath),
|
||||
buildKiroIdeHook('elydora-audit', paths.auditPath),
|
||||
]);
|
||||
const prepared = await prepareKiroIdeInstallation(config, sources, rendered);
|
||||
await commitKiroIdeInstallation(prepared);
|
||||
console.log(` Kiro IDE workspace hooks: ${sources.paths.configPath}`);
|
||||
console.log(' Kiro IDE verification: confirm Elydora hooks in the Agent Hooks panel.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readKiroIdeSources();
|
||||
const rendered = renderKiroIdeDocument(
|
||||
sources.document,
|
||||
withoutManagedKiroIdeHooks(sources.document.hooks, agentId),
|
||||
);
|
||||
const prepared = await prepareKiroIdeUninstall(sources, rendered, agentId);
|
||||
await commitKiroIdeUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use);
|
||||
|
||||
// Extract hook script path from post_tool_use command
|
||||
if (hookConfigured && config.hooks.post_tool_use) {
|
||||
const cmd = config.hooks.post_tool_use.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readKiroIdeSources();
|
||||
requireAvailableKiroIdeHooks(sources.document.hooks);
|
||||
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
&& (await Promise.all(contracts.map((contract) => (
|
||||
managedRuntimeFilesExist(contract, AGENT_KEY)
|
||||
)))).every(Boolean);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.paths.configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import path from 'node:path';
|
||||
import { managedScriptReference } from './common.js';
|
||||
import {
|
||||
isNodeExecutable,
|
||||
parsePosixCommand,
|
||||
parsePowerShellSource,
|
||||
posixSource,
|
||||
powerShellSource,
|
||||
} from './shell-command.js';
|
||||
|
||||
export interface LettaRuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly executablePath?: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
export function buildLettaCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Letta Code hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32' ? powerShellSource(scriptPath) : posixSource(scriptPath);
|
||||
}
|
||||
|
||||
function legacyScriptPath(command: string): string | undefined {
|
||||
if (!command.startsWith('node "') || !command.endsWith('"')) return undefined;
|
||||
const scriptPath = command.slice(6, -1);
|
||||
return scriptPath.includes('"') ? undefined : scriptPath;
|
||||
}
|
||||
|
||||
export function lettaRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellSource(command)
|
||||
: parsePosixCommand(command);
|
||||
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||
const reference = managedScriptReference(parsed[1], scriptName);
|
||||
return reference ? { ...reference, executablePath: parsed[0] } : undefined;
|
||||
}
|
||||
|
||||
export function lettaLegacyRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const legacy = legacyScriptPath(command);
|
||||
return legacy ? managedScriptReference(legacy, scriptName) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { JSONPath } from 'jsonc-parser';
|
||||
import { changeJsonc } from './jsonc-edit.js';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import {
|
||||
MANAGED_EVENTS,
|
||||
managedLettaRemovals,
|
||||
readLettaHooks,
|
||||
type LettaGroup,
|
||||
type LettaHooks,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export type LettaDocumentKind = 'global' | 'project' | 'project-local';
|
||||
|
||||
export interface LettaDocument {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: LettaHooks;
|
||||
readonly hasHooksContainer: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedLettaDocument {
|
||||
readonly document: LettaDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export function lettaSourceLabel(kind: LettaDocumentKind): string {
|
||||
switch (kind) {
|
||||
case 'project': return 'Letta Code project settings';
|
||||
case 'project-local': return 'Letta Code project-local settings';
|
||||
default: return 'Letta Code global settings';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLettaDocument(options: DocumentOptions): LettaDocument {
|
||||
const label = `${lettaSourceLabel(options.kind)} at ${options.filePath}`;
|
||||
const root = parseStrictJsonObject(options.raw, label);
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readLettaHooks(root.hooks),
|
||||
hasHooksContainer: Object.hasOwn(root, 'hooks'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLettaDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind,
|
||||
exists: false,
|
||||
filePath,
|
||||
raw: '{}\n',
|
||||
});
|
||||
}
|
||||
|
||||
function currentDocument(document: LettaDocument, raw: string): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind: document.kind,
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
raw,
|
||||
snapshot: document.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function groupsForEvent(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const groups = hooks[event];
|
||||
return Array.isArray(groups) ? groups : [];
|
||||
}
|
||||
|
||||
function removeManagedEntries(
|
||||
document: LettaDocument,
|
||||
initial: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
let raw = initial;
|
||||
const removals = managedLettaRemovals(document.hooks, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = changeJsonc(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||
(left, right) => right - left,
|
||||
)) {
|
||||
raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if (groupsForEvent(current.hooks, event).length === 0) {
|
||||
raw = changeJsonc(raw, ['hooks', event], undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||
raw = changeJsonc(raw, ['hooks'], undefined);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: LettaDocument,
|
||||
raw: string,
|
||||
event: ManagedLettaEvent,
|
||||
group: LettaGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
const groups = groupsForEvent(current.hooks, event);
|
||||
if (groups.length > 0) {
|
||||
return changeJsonc(raw, ['hooks', event, groups.length], group, true);
|
||||
}
|
||||
return changeJsonc(raw, ['hooks', event], [group]);
|
||||
}
|
||||
|
||||
export function renderLettaDocument(
|
||||
document: LettaDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ManagedLettaEvent, LettaGroup>,
|
||||
): RenderedLettaDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (additions.size === 0 && Object.keys(current.root).length === 0) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
export function lettaDocumentLabel(document: LettaDocument): string {
|
||||
return lettaSourceLabel(document.kind);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import {
|
||||
buildLettaCommand,
|
||||
lettaLegacyRuntimeReference,
|
||||
lettaRuntimeReference,
|
||||
type LettaRuntimeReference,
|
||||
} from './letta-command.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'letta';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_MS = 10_000;
|
||||
export const LETTA_AUDIT_OPTIONS = {
|
||||
nativePayload: true,
|
||||
failClosed: true,
|
||||
} as const;
|
||||
export const MANAGED_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
] as const;
|
||||
|
||||
const TOOL_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest',
|
||||
]);
|
||||
|
||||
const SIMPLE_EVENTS = new Set([
|
||||
'UserPromptSubmit',
|
||||
'Notification',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
]);
|
||||
|
||||
const KNOWN_EVENTS = new Set([...TOOL_EVENTS, ...SIMPLE_EVENTS]);
|
||||
|
||||
export type ManagedLettaEvent = (typeof MANAGED_EVENTS)[number];
|
||||
|
||||
export interface LettaHandler extends JsonObject {
|
||||
readonly type: 'command' | 'prompt';
|
||||
readonly command?: string;
|
||||
readonly prompt?: string;
|
||||
readonly model?: string;
|
||||
readonly timeout?: number;
|
||||
readonly quiet?: boolean;
|
||||
}
|
||||
|
||||
export interface LettaGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: LettaHandler[];
|
||||
}
|
||||
|
||||
export interface LettaHooks extends JsonObject {
|
||||
readonly disabled?: boolean;
|
||||
readonly [event: string]: LettaGroup[] | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface LettaRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedLettaRemoval {
|
||||
readonly event: ManagedLettaEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: readonly number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, label: string): LettaHandler {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.type !== 'command' && value.type !== 'prompt') {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
validateTimeout(value.timeout, label);
|
||||
if (value.quiet !== undefined && typeof value.quiet !== 'boolean') {
|
||||
throw new Error(`${label} quiet must be a boolean`);
|
||||
}
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
} else {
|
||||
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty prompt`);
|
||||
}
|
||||
if (value.model !== undefined && typeof value.model !== 'string') {
|
||||
throw new Error(`${label} model must be a string`);
|
||||
}
|
||||
}
|
||||
return value as LettaHandler;
|
||||
}
|
||||
|
||||
function validateGroup(
|
||||
value: unknown,
|
||||
event: string,
|
||||
index: number,
|
||||
): LettaGroup {
|
||||
const label = `Letta Code settings group hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (TOOL_EVENTS.has(event)) {
|
||||
if (typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
} else if (value.matcher !== undefined) {
|
||||
throw new Error(`${label} matcher is unsupported for ${event}`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
|
||||
)),
|
||||
} as LettaGroup;
|
||||
}
|
||||
|
||||
export function readLettaHooks(value: unknown): LettaHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Letta Code settings field "hooks" must be an object');
|
||||
if (value.disabled !== undefined && typeof value.disabled !== 'boolean') {
|
||||
throw new Error('Letta Code settings field "hooks.disabled" must be a boolean');
|
||||
}
|
||||
const hooks: JsonObject = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (event === 'disabled') {
|
||||
hooks.disabled = groups;
|
||||
continue;
|
||||
}
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
hooks[event] = groups;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Letta Code settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks as LettaHooks;
|
||||
}
|
||||
|
||||
export function buildLettaGroup(scriptPath: string): LettaGroup {
|
||||
return {
|
||||
matcher: '*',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: buildLettaCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactGroup(group: LettaGroup): boolean {
|
||||
return Object.keys(group).sort().join('|') === 'hooks|matcher'
|
||||
&& group.matcher === '*';
|
||||
}
|
||||
|
||||
function exactCurrentReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function exactLegacyReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|type'
|
||||
&& handler.type === 'command'
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaLegacyRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return exactCurrentReference(handler, scriptName)
|
||||
?? exactLegacyReference(handler, scriptName);
|
||||
}
|
||||
|
||||
const EVENT_CONTRACTS = [
|
||||
['PreToolUse', GUARD_SCRIPT],
|
||||
['PostToolUse', AUDIT_SCRIPT],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT],
|
||||
] as const;
|
||||
|
||||
function eventGroups(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const value = hooks[event];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function managedLettaRemovals(
|
||||
hooks: LettaHooks,
|
||||
agentId?: string,
|
||||
): ManagedLettaRemoval[] {
|
||||
const removals: ManagedLettaRemoval[] = [];
|
||||
for (const [event, scriptName] of EVENT_CONTRACTS) {
|
||||
eventGroups(hooks, event).forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const reference = managedReference(handler, scriptName);
|
||||
return reference && (agentId === undefined || sameAgentId(reference.agentId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactGroup(group) && handlerIndexes.length === group.hooks.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly LettaGroup[],
|
||||
scriptName: string,
|
||||
): Map<string, LettaRuntimeReference[]> {
|
||||
const references = new Map<string, LettaRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = exactCurrentReference(handler, scriptName);
|
||||
if (!reference
|
||||
|| !reference.executablePath
|
||||
|| !samePath(reference.executablePath, process.execPath)) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function lettaRuntimeContracts(hooks: LettaHooks): LettaRuntimeContract[] {
|
||||
const guards = referencesForEvent(eventGroups(hooks, 'PreToolUse'), GUARD_SCRIPT);
|
||||
const posts = referencesForEvent(eventGroups(hooks, 'PostToolUse'), AUDIT_SCRIPT);
|
||||
const failures = referencesForEvent(
|
||||
eventGroups(hooks, 'PostToolUseFailure'),
|
||||
AUDIT_SCRIPT,
|
||||
);
|
||||
const contracts: LettaRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const post = posts.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
||||
if (!samePath(post[0].scriptPath, failure[0].scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: post[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
} from './letta-contract.js';
|
||||
import { MAX_SOURCE_BYTES, samePath } from './common.js';
|
||||
import {
|
||||
lettaDocumentLabel,
|
||||
type RenderedLettaDocument,
|
||||
} from './letta-config.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
import {
|
||||
requireLettaHooksEnabled,
|
||||
type LettaSources,
|
||||
} from './letta-sources.js';
|
||||
|
||||
const DISPLAY_NAME = 'Letta Code';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Letta Code global configuration directory';
|
||||
|
||||
export async function preflightLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
requireLettaHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
filePath: sources.global.filePath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function readOnlyPreconditions(sources: LettaSources, changedPath?: string) {
|
||||
return sources.preconditions.filter((condition) => (
|
||||
changedPath === undefined || !samePath(condition.filePath, changedPath)
|
||||
));
|
||||
}
|
||||
|
||||
export async function prepareLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
await preflightLettaInstallation(config, sources);
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareLettaUninstall(
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const change = rendered.changed
|
||||
? await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: SETTINGS_DIRECTORY_LABEL,
|
||||
}],
|
||||
changes: change ? [change] : [],
|
||||
preconditions: readOnlyPreconditions(
|
||||
sources,
|
||||
rendered.changed ? rendered.document.filePath : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createLettaDocument,
|
||||
lettaDocumentLabel,
|
||||
lettaSourceLabel,
|
||||
parseLettaDocument,
|
||||
type LettaDocument,
|
||||
type LettaDocumentKind,
|
||||
} from './letta-config.js';
|
||||
import { hasCode, MAX_SOURCE_BYTES, pathKey, samePath } from './common.js';
|
||||
import {
|
||||
inspectPhysicalDirectory,
|
||||
readPhysicalFile,
|
||||
type FileSnapshot,
|
||||
} from './managed-files.js';
|
||||
|
||||
export interface LettaSourcePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface LettaDisableControl {
|
||||
readonly disabled: boolean;
|
||||
readonly source?: LettaDocument;
|
||||
}
|
||||
|
||||
export interface LettaSources {
|
||||
readonly homeDirectory: string;
|
||||
readonly global: LettaDocument;
|
||||
readonly project: LettaDocument;
|
||||
readonly projectLocal: LettaDocument;
|
||||
readonly projectActive: boolean;
|
||||
readonly disableControl: LettaDisableControl;
|
||||
readonly preconditions: readonly LettaSourcePrecondition[];
|
||||
}
|
||||
|
||||
async function canonicalPath(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return path.resolve(filePath);
|
||||
throw new Error(`Resolve Letta Code path at ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHomeDirectory(): string {
|
||||
return path.resolve(process.env.HOME || os.homedir());
|
||||
}
|
||||
|
||||
async function readDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): Promise<LettaDocument> {
|
||||
const snapshot = await readPhysicalFile(filePath, lettaSourceLabel(kind), MAX_SOURCE_BYTES);
|
||||
return snapshot
|
||||
? parseLettaDocument({
|
||||
kind,
|
||||
exists: true,
|
||||
filePath,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
})
|
||||
: createLettaDocument(kind, filePath);
|
||||
}
|
||||
|
||||
function sourcePrecondition(document: LettaDocument): LettaSourcePrecondition {
|
||||
return {
|
||||
filePath: document.filePath,
|
||||
label: lettaDocumentLabel(document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: document.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function deduplicatePreconditions(
|
||||
values: readonly LettaSourcePrecondition[],
|
||||
): LettaSourcePrecondition[] {
|
||||
const result = new Map<string, LettaSourcePrecondition>();
|
||||
for (const value of values) {
|
||||
const key = pathKey(value.filePath);
|
||||
if (!result.has(key)) result.set(key, value);
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function effectiveDisable(
|
||||
global: LettaDocument,
|
||||
project: LettaDocument,
|
||||
projectLocal: LettaDocument,
|
||||
projectActive: boolean,
|
||||
): LettaDisableControl {
|
||||
if (global.hooks.disabled === false) return { disabled: false, source: global };
|
||||
if (global.hooks.disabled === true) return { disabled: true, source: global };
|
||||
if (projectActive && project.hooks.disabled === true) {
|
||||
return { disabled: true, source: project };
|
||||
}
|
||||
if (projectLocal.hooks.disabled === true) {
|
||||
return { disabled: true, source: projectLocal };
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
export async function readLettaSources(): Promise<LettaSources> {
|
||||
const homeDirectory = resolveHomeDirectory();
|
||||
const workspace = process.cwd();
|
||||
const globalDirectory = path.join(homeDirectory, '.letta');
|
||||
const projectDirectory = path.join(workspace, '.letta');
|
||||
await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory');
|
||||
await inspectPhysicalDirectory(globalDirectory, 'Letta Code global configuration directory');
|
||||
if (!samePath(projectDirectory, globalDirectory)) {
|
||||
await inspectPhysicalDirectory(projectDirectory, 'Letta Code project configuration directory');
|
||||
}
|
||||
const globalPath = path.join(globalDirectory, 'settings.json');
|
||||
const projectPath = path.join(projectDirectory, 'settings.json');
|
||||
const projectLocalPath = path.join(projectDirectory, 'settings.local.json');
|
||||
const [canonicalWorkspace, canonicalHome] = await Promise.all([
|
||||
canonicalPath(workspace),
|
||||
canonicalPath(homeDirectory),
|
||||
]);
|
||||
const projectActive = !samePath(canonicalWorkspace, canonicalHome);
|
||||
const global = await readDocument('global', globalPath);
|
||||
const project = projectActive
|
||||
? await readDocument('project', projectPath)
|
||||
: createLettaDocument('project', projectPath);
|
||||
const projectLocal = await readDocument('project-local', projectLocalPath);
|
||||
const preconditions = deduplicatePreconditions([
|
||||
sourcePrecondition(global),
|
||||
...(projectActive ? [sourcePrecondition(project)] : []),
|
||||
sourcePrecondition(projectLocal),
|
||||
]);
|
||||
return {
|
||||
homeDirectory,
|
||||
global,
|
||||
project,
|
||||
projectLocal,
|
||||
projectActive,
|
||||
disableControl: effectiveDisable(global, project, projectLocal, projectActive),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireLettaHooksEnabled(sources: LettaSources): void {
|
||||
if (!sources.disableControl.disabled) return;
|
||||
const source = sources.disableControl.source;
|
||||
const location = source
|
||||
? `${lettaDocumentLabel(source)} at ${source.filePath}`
|
||||
: 'effective settings';
|
||||
throw new Error(`Letta Code hooks are disabled by hooks.disabled in ${location}`);
|
||||
}
|
||||
+63
-146
@@ -1,173 +1,90 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
buildLettaGroup,
|
||||
lettaRuntimeContracts,
|
||||
type LettaGroup,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { renderLettaDocument } from './letta-config.js';
|
||||
import {
|
||||
commitLettaInstallation,
|
||||
commitLettaUninstall,
|
||||
preflightLettaInstallation,
|
||||
prepareLettaInstallation,
|
||||
prepareLettaUninstall,
|
||||
} from './letta-installation.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import { readLettaSources } from './letta-sources.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'letta';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||
ManagedLettaEvent,
|
||||
LettaGroup
|
||||
> {
|
||||
return new Map([
|
||||
['PreToolUse', buildLettaGroup(guardPath)],
|
||||
['PostToolUse', buildLettaGroup(auditPath)],
|
||||
['PostToolUseFailure', buildLettaGroup(auditPath)],
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
async function runtimeExists(
|
||||
contracts: ReturnType<typeof lettaRuntimeContracts>,
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
})) return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export const lettaPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readLettaSources();
|
||||
await preflightLettaInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const paths = await preflightLettaInstallation(config, sources);
|
||||
const rendered = renderLettaDocument(
|
||||
sources.global,
|
||||
undefined,
|
||||
installedGroups(paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitLettaInstallation(
|
||||
await prepareLettaInstallation(config, sources, rendered),
|
||||
);
|
||||
console.log(` Letta Code hooks: ${sources.global.filePath}`);
|
||||
console.log(' Letta Code verification: run /hooks and restart active sessions.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const rendered = renderLettaDocument(sources.global, agentId, new Map());
|
||||
if (!rendered.changed) return;
|
||||
await commitLettaUninstall(await prepareLettaUninstall(sources, rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readLettaSources();
|
||||
const contracts = lettaRuntimeContracts(sources.global.hooks);
|
||||
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured ? await runtimeExists(contracts) : false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.global.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { constants } from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import { asError, errorMessage, hasCode, MAX_SOURCE_BYTES } from './common.js';
|
||||
|
||||
export interface FileSnapshot {
|
||||
readonly contents: string;
|
||||
readonly device: bigint | number;
|
||||
readonly inode: bigint | number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
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,306 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import { MAX_CONFIG_BYTES, MAX_SECRET_BYTES, sameAgentId, samePath } from './common.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 {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedDirectory,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
type RenameFile,
|
||||
} from './managed-transaction.js';
|
||||
import { parseStrictJsonObject } from './strict-json.js';
|
||||
|
||||
export interface ManagedRuntimePaths {
|
||||
readonly runtimeRoot: string;
|
||||
readonly agentDirectory: string;
|
||||
readonly configPath: string;
|
||||
readonly keyPath: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedHookLocation {
|
||||
readonly directoryLabel: string;
|
||||
readonly filePath: string;
|
||||
}
|
||||
|
||||
export interface ManagedHookSource extends ManagedHookLocation {
|
||||
readonly label: string;
|
||||
readonly expectedSource?: string;
|
||||
readonly expectedSnapshot?: FileSnapshot;
|
||||
readonly source?: string;
|
||||
}
|
||||
|
||||
export interface ManagedRuntimeFile {
|
||||
readonly fileName: string;
|
||||
readonly label: string;
|
||||
readonly source: string;
|
||||
readonly mode: number;
|
||||
readonly maximumBytes?: number;
|
||||
}
|
||||
|
||||
export interface ManagedInstallationSpec {
|
||||
readonly agentKey: string;
|
||||
readonly displayName: string;
|
||||
readonly hookSources: readonly ManagedHookSource[];
|
||||
readonly runtimeFiles?: readonly ManagedRuntimeFile[];
|
||||
readonly config: InstallConfig;
|
||||
readonly guardOptions?: GuardScriptOptions;
|
||||
readonly auditOptions?: HookScriptOptions;
|
||||
}
|
||||
|
||||
export interface ManagedPreflightSpec {
|
||||
readonly agentKey: string;
|
||||
readonly hookLocations: readonly ManagedHookLocation[];
|
||||
readonly config: InstallConfig;
|
||||
}
|
||||
|
||||
export interface PreparedManagedInstallation {
|
||||
readonly paths: ManagedRuntimePaths;
|
||||
readonly transaction: PreparedManagedTransaction;
|
||||
}
|
||||
|
||||
interface RuntimeArtifactLocation {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export type { RenameFile };
|
||||
|
||||
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) || !baseUrl.hostname) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateRuntimeIdentity(
|
||||
paths: ManagedRuntimePaths,
|
||||
agentKey: string,
|
||||
additionalArtifacts: readonly RuntimeArtifactLocation[] = [],
|
||||
): 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'),
|
||||
...additionalArtifacts.map((artifact) => readPhysicalFile(
|
||||
artifact.filePath,
|
||||
artifact.label,
|
||||
)),
|
||||
]);
|
||||
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);
|
||||
for (const location of spec.hookLocations) {
|
||||
await inspectPhysicalDirectory(path.dirname(location.filePath), location.directoryLabel);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function hookDirectories(sources: readonly ManagedHookSource[]): ManagedDirectory[] {
|
||||
return sources.map((source) => ({
|
||||
path: path.dirname(source.filePath),
|
||||
label: source.directoryLabel,
|
||||
}));
|
||||
}
|
||||
|
||||
function runtimeFilePath(paths: ManagedRuntimePaths, fileName: string): string {
|
||||
if (!fileName
|
||||
|| fileName === '.'
|
||||
|| fileName === '..'
|
||||
|| fileName.includes('/')
|
||||
|| fileName.includes('\\')) {
|
||||
throw new Error(`Managed runtime fileName must be a basename: ${fileName}`);
|
||||
}
|
||||
return path.join(paths.agentDirectory, fileName);
|
||||
}
|
||||
|
||||
export async function prepareManagedInstallation(
|
||||
spec: ManagedInstallationSpec,
|
||||
guardScript: string,
|
||||
auditScript: string,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
if (spec.hookSources.length === 0) throw new Error('At least one hook source is required');
|
||||
const paths = await preflightManagedInstallation({
|
||||
agentKey: spec.agentKey,
|
||||
hookLocations: spec.hookSources,
|
||||
config: spec.config,
|
||||
}, guardScript, auditScript);
|
||||
const runtimeFiles = (spec.runtimeFiles ?? []).map((runtimeFile) => ({
|
||||
runtimeFile,
|
||||
filePath: runtimeFilePath(paths, runtimeFile.fileName),
|
||||
}));
|
||||
const runtimeArtifacts = runtimeFiles.map(({ runtimeFile, filePath }) => ({
|
||||
filePath,
|
||||
label: runtimeFile.label,
|
||||
}));
|
||||
await validateRuntimeIdentity(paths, spec.agentKey, runtimeArtifacts);
|
||||
const changes = await Promise.all([
|
||||
prepareManagedFileChange({
|
||||
filePath: paths.guardPath,
|
||||
label: 'Elydora guard runtime',
|
||||
next: generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions),
|
||||
mode: 0o700,
|
||||
}),
|
||||
prepareManagedFileChange({
|
||||
filePath: paths.configPath,
|
||||
label: 'Elydora runtime config',
|
||||
next: runtimeConfig(spec.config, spec.agentKey),
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_CONFIG_BYTES,
|
||||
}),
|
||||
prepareManagedFileChange({
|
||||
filePath: paths.keyPath,
|
||||
label: 'Elydora private key',
|
||||
next: spec.config.privateKey,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_SECRET_BYTES,
|
||||
}),
|
||||
prepareManagedFileChange({
|
||||
filePath: paths.auditPath,
|
||||
label: 'Elydora audit runtime',
|
||||
next: generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions),
|
||||
mode: 0o700,
|
||||
}),
|
||||
...runtimeFiles.map(({ runtimeFile, filePath }) => prepareManagedFileChange({
|
||||
filePath,
|
||||
label: runtimeFile.label,
|
||||
next: runtimeFile.source,
|
||||
mode: runtimeFile.mode,
|
||||
maximumBytes: runtimeFile.maximumBytes,
|
||||
})),
|
||||
...spec.hookSources.map((source) => prepareManagedFileChange({
|
||||
filePath: source.filePath,
|
||||
label: source.label,
|
||||
next: source.source,
|
||||
mode: 0o600,
|
||||
expectedSource: source.expectedSource,
|
||||
expectedSnapshot: source.expectedSnapshot,
|
||||
verifyExpectedSource: true,
|
||||
})),
|
||||
]);
|
||||
await validateRuntimeIdentity(paths, spec.agentKey, runtimeArtifacts);
|
||||
return {
|
||||
paths,
|
||||
transaction: {
|
||||
displayName: spec.displayName,
|
||||
directories: hookDirectories(spec.hookSources),
|
||||
changes: changes.filter((change): change is ManagedFileChange => change !== undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitManagedInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await ensurePrivateDirectory(prepared.paths.runtimeRoot);
|
||||
await ensurePrivateDirectory(prepared.paths.agentDirectory);
|
||||
await commitManagedTransaction(prepared.transaction, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
|
||||
import { generateHookScript, type HookScriptOptions } from './hook-template.js';
|
||||
import { MAX_CONFIG_BYTES, MAX_SECRET_BYTES, sameAgentId, samePath } from './common.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
const GUARD_SCRIPT = 'guard.js';
|
||||
const AUDIT_SCRIPT = 'hook.js';
|
||||
|
||||
export interface ManagedRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRuntimeStatusOptions {
|
||||
readonly guardOptions?: GuardScriptOptions;
|
||||
readonly auditOptions?: HookScriptOptions;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string, configPath: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(
|
||||
config: JsonObject,
|
||||
contract: ManagedRuntimeContract,
|
||||
configPath: string,
|
||||
agentKey: string,
|
||||
): void {
|
||||
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
||||
const extra = Object.keys(config).find((key) => !supported.has(key));
|
||||
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
|
||||
requireString(config.org_id, 'org_id', configPath);
|
||||
requireString(config.kid, 'kid', configPath);
|
||||
const agentId = requireString(config.agent_id, 'agent_id', configPath);
|
||||
if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
|
||||
throw new Error(`Elydora runtime identity does not match ${agentKey} hooks: ${configPath}`);
|
||||
}
|
||||
if (config.token !== undefined) requireString(config.token, 'token', configPath);
|
||||
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(rawBaseUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (!['http:', 'https:'].includes(baseUrl.protocol)
|
||||
|| !baseUrl.hostname
|
||||
|| baseUrl.username
|
||||
|| baseUrl.password
|
||||
|| baseUrl.search
|
||||
|| baseUrl.hash) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePrivateKey(contents: string, keyPath: string): void {
|
||||
const bytes = Buffer.from(contents, 'base64url');
|
||||
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
|
||||
throw new Error(`Elydora private key is invalid: ${keyPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validContractPaths(contract: ManagedRuntimeContract): boolean {
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
return samePath(path.dirname(agentDirectory), runtimeRoot)
|
||||
&& sameAgentId(path.basename(agentDirectory), contract.agentId)
|
||||
&& samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|
||||
&& samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
|
||||
}
|
||||
|
||||
export async function managedRuntimeFilesExist(
|
||||
contract: ManagedRuntimeContract,
|
||||
agentKey: string,
|
||||
options: ManagedRuntimeStatusOptions = {},
|
||||
): Promise<boolean> {
|
||||
if (!validContractPaths(contract)) return false;
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
|
||||
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false;
|
||||
const configPath = path.join(agentDirectory, 'config.json');
|
||||
const keyPath = path.join(agentDirectory, 'private.key');
|
||||
const [config, key, guard, audit] = await Promise.all([
|
||||
readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
|
||||
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
|
||||
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
|
||||
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
|
||||
]);
|
||||
if (!config || !key || !guard || !audit) return false;
|
||||
validateRuntimeConfig(
|
||||
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
|
||||
contract,
|
||||
configPath,
|
||||
agentKey,
|
||||
);
|
||||
validatePrivateKey(key.contents, keyPath);
|
||||
return guard.contents === generateGuardScript(agentKey, contract.agentId, options.guardOptions)
|
||||
&& audit.contents === generateHookScript(
|
||||
agentKey,
|
||||
contract.agentId,
|
||||
options.auditOptions ?? { nativePayload: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Presence-only check: identity mismatch reports false; parse failures still throw.
|
||||
export async function managedRuntimePresent(
|
||||
contract: ManagedRuntimeContract,
|
||||
agentKey: string,
|
||||
): Promise<boolean> {
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
const configPath = path.join(agentDirectory, 'config.json');
|
||||
const snapshot = await readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES);
|
||||
if (!snapshot) return false;
|
||||
const config = parseStrictJsonObject(snapshot.contents, `Elydora runtime config at ${configPath}`);
|
||||
if (config.agent_name !== agentKey || !sameAgentId(config.agent_id, contract.agentId)) {
|
||||
return false;
|
||||
}
|
||||
const files = await Promise.all([
|
||||
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
|
||||
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
|
||||
readPhysicalFile(path.join(agentDirectory, 'private.key'), 'Elydora private key', MAX_SECRET_BYTES),
|
||||
]);
|
||||
return files.every(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { asError, errorMessage, hasCode, MAX_SOURCE_BYTES } from './common.js';
|
||||
import {
|
||||
inspectPhysicalDirectory,
|
||||
readPhysicalFile,
|
||||
type FileSnapshot,
|
||||
} from './managed-files.js';
|
||||
|
||||
export interface ManagedChangeSpec {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly next?: string;
|
||||
readonly mode: number;
|
||||
readonly maximumBytes?: number;
|
||||
readonly expectedSource?: string;
|
||||
readonly expectedSnapshot?: FileSnapshot;
|
||||
readonly verifyExpectedSource?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFileChange {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly next?: string;
|
||||
readonly mode: number;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface ManagedDirectory {
|
||||
readonly path: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface ManagedFilePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface PreparedManagedTransaction {
|
||||
readonly displayName: string;
|
||||
readonly operation?: 'install' | 'uninstall';
|
||||
readonly directories: readonly ManagedDirectory[];
|
||||
readonly changes: readonly ManagedFileChange[];
|
||||
readonly preconditions?: readonly ManagedFilePrecondition[];
|
||||
}
|
||||
|
||||
interface StagedChange {
|
||||
readonly change: ManagedFileChange;
|
||||
temporaryPath?: string;
|
||||
rollbackPath?: string;
|
||||
committedSnapshot?: FileSnapshot;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
export type RenameFile = (source: string, destination: string) => Promise<void>;
|
||||
|
||||
function targetKey(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
async function unlinkOptional(filePath?: string): Promise<void> {
|
||||
if (!filePath) return;
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareManagedFileChange(
|
||||
spec: ManagedChangeSpec,
|
||||
): Promise<ManagedFileChange | undefined> {
|
||||
const maximumBytes = spec.maximumBytes ?? MAX_SOURCE_BYTES;
|
||||
if (spec.next !== undefined && Buffer.byteLength(spec.next) > maximumBytes) {
|
||||
throw new Error(`${spec.label} exceeds ${maximumBytes} bytes: ${spec.filePath}`);
|
||||
}
|
||||
const original = await readPhysicalFile(spec.filePath, spec.label, maximumBytes);
|
||||
if (spec.verifyExpectedSource && original?.contents !== spec.expectedSource) {
|
||||
throw new Error(`${spec.label} changed before installation: ${spec.filePath}`);
|
||||
}
|
||||
if (spec.expectedSnapshot && (!original
|
||||
|| original.device !== spec.expectedSnapshot.device
|
||||
|| original.inode !== spec.expectedSnapshot.inode)) {
|
||||
throw new Error(`${spec.label} changed before installation: ${spec.filePath}`);
|
||||
}
|
||||
if (!original && spec.next === undefined) return undefined;
|
||||
return {
|
||||
filePath: spec.filePath,
|
||||
label: spec.label,
|
||||
next: spec.next,
|
||||
mode: spec.mode,
|
||||
maximumBytes,
|
||||
original,
|
||||
};
|
||||
}
|
||||
|
||||
function sameSnapshot(current: FileSnapshot | undefined, original: FileSnapshot | undefined): boolean {
|
||||
if (!current || !original) return !current && !original;
|
||||
return current.contents === original.contents
|
||||
&& current.device === original.device
|
||||
&& current.inode === original.inode;
|
||||
}
|
||||
|
||||
async function assertUnchanged(
|
||||
target: ManagedFilePrecondition,
|
||||
operationName: string,
|
||||
): Promise<void> {
|
||||
const current = await readPhysicalFile(target.filePath, target.label, target.maximumBytes);
|
||||
if (!sameSnapshot(current, target.original)) {
|
||||
throw new Error(`${target.label} changed during ${operationName}: ${target.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPreconditions(
|
||||
transaction: PreparedManagedTransaction,
|
||||
operationName: string,
|
||||
): Promise<void> {
|
||||
for (const condition of transaction.preconditions ?? []) {
|
||||
await assertUnchanged(condition, operationName);
|
||||
}
|
||||
}
|
||||
|
||||
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 reservePath(filePath: string, label: string): Promise<void> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await fsp.open(filePath, 'wx', 0o600);
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await fsp.unlink(filePath);
|
||||
} 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, `Reserve ${label}`);
|
||||
throw new Error(`Reserve ${label}: ${errorMessage(error)}`, { cause: failures[0] });
|
||||
}
|
||||
}
|
||||
|
||||
async function stage(change: ManagedFileChange, operationName: string): Promise<StagedChange> {
|
||||
await assertUnchanged(change, operationName);
|
||||
const token = randomUUID();
|
||||
const directory = path.dirname(change.filePath);
|
||||
const temporaryPath = change.next === undefined
|
||||
? undefined
|
||||
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
|
||||
const rollbackPath = change.original
|
||||
? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`)
|
||||
: undefined;
|
||||
const staged: StagedChange = {
|
||||
change,
|
||||
temporaryPath,
|
||||
rollbackPath,
|
||||
committed: false,
|
||||
};
|
||||
try {
|
||||
if (temporaryPath && change.next !== undefined) {
|
||||
await writeExclusive(temporaryPath, change.next, change.mode, change.label);
|
||||
}
|
||||
if (rollbackPath && change.original) {
|
||||
if (change.next === undefined) {
|
||||
await reservePath(rollbackPath, `${change.label} rollback`);
|
||||
} else {
|
||||
await writeExclusive(
|
||||
rollbackPath,
|
||||
change.original.contents,
|
||||
change.original.mode,
|
||||
`${change.label} rollback`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return staged;
|
||||
} catch (error) {
|
||||
const failures = [asError(error)];
|
||||
for (const item of [temporaryPath, rollbackPath]) {
|
||||
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,
|
||||
operationName: string,
|
||||
renameFile: RenameFile,
|
||||
): Promise<void> {
|
||||
await assertUnchanged(staged.change, operationName);
|
||||
if (staged.change.next === undefined) {
|
||||
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
|
||||
await renameFile(staged.change.filePath, staged.rollbackPath);
|
||||
} else {
|
||||
if (!staged.temporaryPath) throw new Error(`Missing staged file for ${staged.change.label}`);
|
||||
await renameFile(staged.temporaryPath, staged.change.filePath);
|
||||
}
|
||||
staged.committed = true;
|
||||
const current = await readPhysicalFile(
|
||||
staged.change.filePath,
|
||||
staged.change.label,
|
||||
staged.change.maximumBytes,
|
||||
);
|
||||
if (staged.change.next === undefined) {
|
||||
if (current) throw new Error(`${staged.change.label} remained after removal`);
|
||||
return;
|
||||
}
|
||||
if (!current || current.contents !== staged.change.next) {
|
||||
throw new Error(
|
||||
`${staged.change.label} changed immediately after commit: ${staged.change.filePath}`,
|
||||
);
|
||||
}
|
||||
staged.committedSnapshot = current;
|
||||
}
|
||||
|
||||
async function assertCommittedUnchanged(staged: StagedChange): Promise<void> {
|
||||
const current = await readPhysicalFile(
|
||||
staged.change.filePath,
|
||||
staged.change.label,
|
||||
staged.change.maximumBytes,
|
||||
);
|
||||
if (staged.change.next === undefined) {
|
||||
if (current) {
|
||||
throw new Error(
|
||||
`${staged.change.label} changed during transaction recovery: ${staged.change.filePath}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!current || !sameSnapshot(current, staged.committedSnapshot)) {
|
||||
throw new Error(
|
||||
`${staged.change.label} changed during transaction recovery: ${staged.change.filePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function preserveRollback(staged: StagedChange, cause: unknown): Error {
|
||||
if (!staged.rollbackPath) return asError(cause);
|
||||
const rollbackPath = staged.rollbackPath;
|
||||
staged.rollbackPath = undefined;
|
||||
return new Error(
|
||||
`${errorMessage(cause)}; original content preserved at ${rollbackPath}`,
|
||||
{ cause: asError(cause) },
|
||||
);
|
||||
}
|
||||
|
||||
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
|
||||
if (!staged.committed) return;
|
||||
try {
|
||||
await assertCommittedUnchanged(staged);
|
||||
} catch (error) {
|
||||
throw preserveRollback(staged, error);
|
||||
}
|
||||
try {
|
||||
if (staged.change.next === undefined || staged.change.original) {
|
||||
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
|
||||
await renameFile(staged.rollbackPath, staged.change.filePath);
|
||||
} else {
|
||||
await unlinkOptional(staged.change.filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
throw preserveRollback(staged, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(staged: StagedChange): Promise<void> {
|
||||
await Promise.all([
|
||||
unlinkOptional(staged.temporaryPath),
|
||||
unlinkOptional(staged.rollbackPath),
|
||||
]);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function transactionDirectories(transaction: PreparedManagedTransaction): ManagedDirectory[] {
|
||||
const directories = new Map<string, ManagedDirectory>();
|
||||
for (const item of transaction.directories) directories.set(targetKey(item.path), item);
|
||||
for (const change of transaction.changes) {
|
||||
const directory = path.dirname(change.filePath);
|
||||
const key = targetKey(directory);
|
||||
if (!directories.has(key)) {
|
||||
directories.set(key, { path: directory, label: `Directory for ${change.label}` });
|
||||
}
|
||||
}
|
||||
return [...directories.values()];
|
||||
}
|
||||
|
||||
export async function commitManagedTransaction(
|
||||
transaction: PreparedManagedTransaction,
|
||||
renameFile: RenameFile = fsp.rename,
|
||||
): Promise<void> {
|
||||
const operationName = transaction.operation === 'uninstall'
|
||||
? `${transaction.displayName} uninstall`
|
||||
: `${transaction.displayName} installation`;
|
||||
const failureLabel = transaction.operation === 'uninstall'
|
||||
? `Uninstall ${transaction.displayName} hooks`
|
||||
: `Install ${transaction.displayName} hooks`;
|
||||
const targets = new Set<string>();
|
||||
for (const change of transaction.changes) {
|
||||
const key = targetKey(change.filePath);
|
||||
if (targets.has(key)) {
|
||||
throw new Error(
|
||||
`${operationName} contains duplicate file target ${change.filePath}`,
|
||||
);
|
||||
}
|
||||
targets.add(key);
|
||||
}
|
||||
await assertPreconditions(transaction, operationName);
|
||||
for (const directory of transactionDirectories(transaction)) {
|
||||
await ensureManagedDirectory(directory.path, directory.label);
|
||||
}
|
||||
await assertPreconditions(transaction, operationName);
|
||||
const staged: StagedChange[] = [];
|
||||
try {
|
||||
for (const change of transaction.changes) staged.push(await stage(change, operationName));
|
||||
await assertPreconditions(transaction, operationName);
|
||||
for (const item of staged) {
|
||||
await assertPreconditions(transaction, operationName);
|
||||
await commit(item, operationName, renameFile);
|
||||
}
|
||||
await assertPreconditions(transaction, operationName);
|
||||
} 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,
|
||||
`${failureLabel}: ${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 ${operationName} transaction files`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { HookScriptOptions } from './hook-template.js';
|
||||
import { errorMessage, samePath } from './common.js';
|
||||
import { isNodeExecutable } from './shell-command.js';
|
||||
import { parseStrictJsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'opencode';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const PLUGIN_FILE = 'elydora-audit.js';
|
||||
export const LEGACY_PLUGIN_FILE = 'elydora-audit.mjs';
|
||||
export const OPENCODE_AUDIT_OPTIONS = Object.freeze({
|
||||
failClosed: true,
|
||||
nativePayload: true,
|
||||
}) satisfies HookScriptOptions;
|
||||
|
||||
const METADATA_MARKER = '// @elydora-opencode-plugin ';
|
||||
const METADATA_VERSION = 1;
|
||||
const MAX_RUNTIME_STDERR_BYTES = 64 * 1024;
|
||||
const GUARD_TIMEOUT_MILLISECONDS = 5_000;
|
||||
const AUDIT_TIMEOUT_MILLISECONDS = 7_000;
|
||||
|
||||
export interface OpenCodePaths {
|
||||
readonly homeDirectory: string;
|
||||
readonly configRoot: string;
|
||||
readonly configDirectory: string;
|
||||
readonly pluginsDirectory: string;
|
||||
readonly pluginPath: string;
|
||||
readonly legacyPluginPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly executablePath: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeLegacyContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
interface OpenCodePluginMetadata {
|
||||
readonly version: 1;
|
||||
readonly agentName: 'opencode';
|
||||
readonly agentId: string;
|
||||
readonly executablePath: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export function resolveOpenCodePaths(
|
||||
homeDirectory = os.homedir(),
|
||||
xdgConfigHome = process.env.XDG_CONFIG_HOME,
|
||||
): OpenCodePaths {
|
||||
const resolvedHome = path.resolve(homeDirectory);
|
||||
const configRoot = xdgConfigHome || path.join(resolvedHome, '.config');
|
||||
if (!path.isAbsolute(configRoot)) {
|
||||
throw new Error('XDG_CONFIG_HOME must be an absolute path for OpenCode integration');
|
||||
}
|
||||
const configDirectory = path.join(configRoot, 'opencode');
|
||||
const pluginsDirectory = path.join(configDirectory, 'plugins');
|
||||
return {
|
||||
homeDirectory: resolvedHome,
|
||||
configRoot: path.normalize(configRoot),
|
||||
configDirectory,
|
||||
pluginsDirectory,
|
||||
pluginPath: path.join(pluginsDirectory, PLUGIN_FILE),
|
||||
legacyPluginPath: path.join(pluginsDirectory, LEGACY_PLUGIN_FILE),
|
||||
};
|
||||
}
|
||||
|
||||
function validAgentSegment(agentId: string): boolean {
|
||||
return Boolean(agentId)
|
||||
&& agentId !== '.'
|
||||
&& agentId !== '..'
|
||||
&& path.basename(agentId) === agentId;
|
||||
}
|
||||
|
||||
function validateRuntimePaths(metadata: OpenCodePluginMetadata): void {
|
||||
if (!validAgentSegment(metadata.agentId)) {
|
||||
throw new Error('OpenCode plugin metadata contains an invalid agentId');
|
||||
}
|
||||
if (!path.isAbsolute(metadata.executablePath) || !isNodeExecutable(metadata.executablePath)) {
|
||||
throw new Error('OpenCode plugin metadata executablePath must reference Node.js');
|
||||
}
|
||||
const agentDirectory = path.join(os.homedir(), '.elydora', metadata.agentId);
|
||||
if (!samePath(metadata.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|
||||
|| !samePath(metadata.auditPath, path.join(agentDirectory, AUDIT_SCRIPT))) {
|
||||
throw new Error('OpenCode plugin metadata references an unexpected runtime path');
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadata(value: unknown): OpenCodePluginMetadata {
|
||||
const object = value as Record<string, unknown>;
|
||||
if (!object || typeof object !== 'object' || Array.isArray(object)) {
|
||||
throw new Error('OpenCode plugin metadata must be an object');
|
||||
}
|
||||
if (Object.keys(object).sort().join(',') !== 'agentId,agentName,auditPath,executablePath,guardPath,version') {
|
||||
throw new Error('OpenCode plugin metadata contains an unexpected field set');
|
||||
}
|
||||
if (object.version !== METADATA_VERSION) throw new Error('OpenCode plugin metadata version must be 1');
|
||||
if (object.agentName !== AGENT_KEY) throw new Error('OpenCode plugin metadata agentName must be opencode');
|
||||
for (const field of ['agentId', 'executablePath', 'guardPath', 'auditPath']) {
|
||||
if (typeof object[field] !== 'string' || object[field].length === 0) {
|
||||
throw new Error(`OpenCode plugin metadata ${field} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
const metadata = object as unknown as OpenCodePluginMetadata;
|
||||
validateRuntimePaths(metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function encodeMetadata(metadata: OpenCodePluginMetadata): string {
|
||||
return Buffer.from(JSON.stringify(metadata), 'utf-8').toString('base64url');
|
||||
}
|
||||
|
||||
export function buildOpenCodeMetadata(
|
||||
agentId: string,
|
||||
executablePath: string,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): OpenCodeRuntimeContract {
|
||||
return validateMetadata({
|
||||
version: METADATA_VERSION,
|
||||
agentName: AGENT_KEY,
|
||||
agentId,
|
||||
executablePath,
|
||||
guardPath,
|
||||
auditPath,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGeneratedSource(source: string): string {
|
||||
return source.replaceAll('\r\n', '\n');
|
||||
}
|
||||
|
||||
export function buildOpenCodePlugin(contract: OpenCodeRuntimeContract): string {
|
||||
const metadata = validateMetadata({
|
||||
version: METADATA_VERSION,
|
||||
agentName: AGENT_KEY,
|
||||
...contract,
|
||||
});
|
||||
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
|
||||
${METADATA_MARKER}${encodeMetadata(metadata)}
|
||||
// Generated by @elydora/sdk. DO NOT EDIT.
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const RUNTIME_EXECUTABLE = ${JSON.stringify(metadata.executablePath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(metadata.guardPath)};
|
||||
const AUDIT_SCRIPT_PATH = ${JSON.stringify(metadata.auditPath)};
|
||||
const MAX_STDERR_BYTES = ${MAX_RUNTIME_STDERR_BYTES};
|
||||
const GUARD_TIMEOUT_MS = ${GUARD_TIMEOUT_MILLISECONDS};
|
||||
const AUDIT_TIMEOUT_MS = ${AUDIT_TIMEOUT_MILLISECONDS};
|
||||
|
||||
function eventPayload(eventName, input, output) {
|
||||
return JSON.stringify({
|
||||
hook_event_name: eventName,
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: eventName === 'tool.execute.before' ? output?.args : input?.args,
|
||||
session_id: input?.sessionID || 'unknown',
|
||||
call_id: input?.callID || 'unknown',
|
||||
input,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
function runGuard(input, output) {
|
||||
let payload;
|
||||
try {
|
||||
payload = eventPayload('tool.execute.before', input, output);
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard input serialization failed: ' + error.message);
|
||||
}
|
||||
const result = spawnSync(RUNTIME_EXECUTABLE, [GUARD_SCRIPT_PATH], {
|
||||
input: payload,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: MAX_STDERR_BYTES,
|
||||
timeout: GUARD_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
const stderr = String(result.stderr || '').trim();
|
||||
if (result.status !== 0) {
|
||||
const detail = stderr || (result.signal
|
||||
? 'Guard terminated by signal ' + result.signal
|
||||
: 'Guard exited with status ' + result.status);
|
||||
throw new Error((result.status === 2 ? '' : 'Elydora guard failed: ') + detail);
|
||||
}
|
||||
if (stderr) console.error(stderr);
|
||||
}
|
||||
|
||||
function runAudit(input, output) {
|
||||
let payload;
|
||||
try {
|
||||
payload = eventPayload('tool.execute.after', input, output);
|
||||
} catch (error) {
|
||||
console.error('[elydora] OpenCode audit input serialization failed:', error.message);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(RUNTIME_EXECUTABLE, [AUDIT_SCRIPT_PATH], {
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const stderr = [];
|
||||
let stderrBytes = 0;
|
||||
let truncated = false;
|
||||
let settled = false;
|
||||
let timer;
|
||||
|
||||
const report = (message) => {
|
||||
console.error('[elydora] OpenCode audit runtime failed:', message);
|
||||
};
|
||||
const complete = (failure) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (failure) report(failure);
|
||||
resolve();
|
||||
};
|
||||
const stderrText = () => {
|
||||
const detail = Buffer.concat(stderr).toString('utf-8').trim();
|
||||
return truncated ? detail + '\\n[stderr truncated]' : detail;
|
||||
};
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const value = Buffer.from(chunk);
|
||||
const remaining = MAX_STDERR_BYTES - stderrBytes;
|
||||
if (remaining <= 0) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
stderr.push(value.subarray(0, remaining));
|
||||
stderrBytes += Math.min(value.length, remaining);
|
||||
if (value.length > remaining) truncated = true;
|
||||
});
|
||||
child.once('error', (error) => complete(error.message));
|
||||
child.stdin.once('error', (error) => {
|
||||
child.kill();
|
||||
complete('failed to write hook input: ' + error.message);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
const detail = stderrText();
|
||||
if (code !== 0 || signal) {
|
||||
complete(detail || (signal
|
||||
? 'runtime terminated by signal ' + signal
|
||||
: 'runtime exited with code ' + code));
|
||||
return;
|
||||
}
|
||||
if (detail) report(detail);
|
||||
complete();
|
||||
});
|
||||
timer = setTimeout(() => {
|
||||
child.kill();
|
||||
complete('runtime timed out after ' + AUDIT_TIMEOUT_MS + 'ms');
|
||||
}, AUDIT_TIMEOUT_MS);
|
||||
try {
|
||||
child.stdin.end(payload);
|
||||
} catch (error) {
|
||||
child.kill();
|
||||
complete('failed to write hook input: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const ElydoraAuditPlugin = async () => ({
|
||||
'tool.execute.before': async (input, output) => {
|
||||
runGuard(input, output);
|
||||
},
|
||||
'tool.execute.after': async (input, output) => {
|
||||
await runAudit(input, output);
|
||||
},
|
||||
});
|
||||
`);
|
||||
}
|
||||
|
||||
export function parseOpenCodePlugin(
|
||||
filePath: string,
|
||||
source: string,
|
||||
): OpenCodeRuntimeContract | undefined {
|
||||
const markerLine = source.split(/\r?\n/, 3)[1];
|
||||
if (!markerLine?.startsWith(METADATA_MARKER)) return undefined;
|
||||
let metadata: OpenCodePluginMetadata;
|
||||
try {
|
||||
const encoded = markerLine.slice(METADATA_MARKER.length);
|
||||
const decoded = Buffer.from(encoded, 'base64url').toString('utf-8');
|
||||
if (Buffer.from(decoded, 'utf-8').toString('base64url') !== encoded) {
|
||||
throw new Error('metadata encoding is not canonical base64url');
|
||||
}
|
||||
metadata = validateMetadata(parseStrictJsonObject(decoded, 'OpenCode plugin metadata'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse Elydora OpenCode plugin metadata at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (source !== buildOpenCodePlugin(metadata)) {
|
||||
throw new Error(`Elydora OpenCode plugin at ${filePath} does not match the managed template`);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function buildLegacyOpenCodePlugin(auditPath: string, guardPath: string): string {
|
||||
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const HOOK_SCRIPT_PATH = ${JSON.stringify(auditPath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardPath)};
|
||||
|
||||
export const ElydoraAuditPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async () => {
|
||||
// Guard — blocks tool if agent is frozen
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard failed: ' + error.message);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
|
||||
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
|
||||
throw new Error(prefix + message);
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
const data = JSON.stringify({
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: input?.args || output?.args || {},
|
||||
session_id: input?.sessionID || ctx?.project?.name || 'unknown',
|
||||
});
|
||||
const child = spawn('node', [HOOK_SCRIPT_PATH], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
console.error('[elydora] audit hook failed:', error.message);
|
||||
});
|
||||
child.stdin.on('error', (error) => {
|
||||
console.error('[elydora] audit input failed:', error.message);
|
||||
});
|
||||
child.stdin.end(data);
|
||||
child.unref();
|
||||
},
|
||||
};
|
||||
};
|
||||
`);
|
||||
}
|
||||
|
||||
function legacyRuntimeContract(
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): OpenCodeLegacyContract | undefined {
|
||||
if (!path.isAbsolute(guardPath) || !path.isAbsolute(auditPath)) return undefined;
|
||||
const agentDirectory = path.dirname(guardPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
|
||||
|| !samePath(auditPath, path.join(agentDirectory, AUDIT_SCRIPT))
|
||||
|| !samePath(guardPath, path.join(agentDirectory, GUARD_SCRIPT))) return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return validAgentSegment(agentId) ? { agentId, guardPath, auditPath } : undefined;
|
||||
}
|
||||
|
||||
export function parseLegacyOpenCodePlugin(source: string): OpenCodeLegacyContract | undefined {
|
||||
const normalized = normalizeGeneratedSource(source);
|
||||
const auditMatch = normalized.match(/^const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
|
||||
const guardMatch = normalized.match(/^const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
|
||||
if (!auditMatch || !guardMatch) return undefined;
|
||||
let auditPath: unknown;
|
||||
let guardPath: unknown;
|
||||
try {
|
||||
auditPath = JSON.parse(auditMatch[1]);
|
||||
guardPath = JSON.parse(guardMatch[1]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof auditPath !== 'string' || typeof guardPath !== 'string'
|
||||
|| normalized !== buildLegacyOpenCodePlugin(auditPath, guardPath)) return undefined;
|
||||
return legacyRuntimeContract(guardPath, auditPath);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
OPENCODE_AUDIT_OPTIONS,
|
||||
buildOpenCodeMetadata,
|
||||
buildOpenCodePlugin,
|
||||
} from './opencode-contract.js';
|
||||
import { sameAgentId } from './common.js';
|
||||
import type { OpenCodePluginFile, OpenCodeSources } from './opencode-io.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedHookSource,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'OpenCode';
|
||||
const DIRECTORY_LABEL = 'OpenCode plugins directory';
|
||||
|
||||
export async function preflightOpenCodeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: OpenCodeSources,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
filePath: sources.paths.pluginPath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareOpenCodeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: OpenCodeSources,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
const paths = await preflightOpenCodeInstallation(config, sources);
|
||||
const metadata = buildOpenCodeMetadata(
|
||||
config.agentId,
|
||||
process.execPath,
|
||||
paths.guardPath,
|
||||
paths.auditPath,
|
||||
);
|
||||
const hookSources: ManagedHookSource[] = [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'OpenCode plugin',
|
||||
filePath: sources.current.filePath,
|
||||
expectedSource: sources.current.source,
|
||||
expectedSnapshot: sources.current.snapshot,
|
||||
source: buildOpenCodePlugin(metadata),
|
||||
}];
|
||||
if (sources.legacy.contract) {
|
||||
hookSources.push({
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'legacy OpenCode plugin',
|
||||
filePath: sources.legacy.filePath,
|
||||
expectedSource: sources.legacy.source,
|
||||
expectedSnapshot: sources.legacy.snapshot,
|
||||
});
|
||||
}
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources,
|
||||
config,
|
||||
auditOptions: OPENCODE_AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitOpenCodeInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
function ownedByAgent<TContract extends { readonly agentId: string }>(
|
||||
file: OpenCodePluginFile<TContract>,
|
||||
agentId?: string,
|
||||
): boolean {
|
||||
return file.contract !== undefined
|
||||
&& (agentId === undefined || sameAgentId(file.contract.agentId, agentId));
|
||||
}
|
||||
|
||||
export async function prepareOpenCodeUninstall(
|
||||
sources: OpenCodeSources,
|
||||
agentId?: string,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const owned = [sources.current, sources.legacy].filter((file) => ownedByAgent(file, agentId));
|
||||
const changes = await Promise.all(owned.map((file) => prepareManagedFileChange({
|
||||
filePath: file.filePath,
|
||||
label: file.filePath === sources.paths.pluginPath ? 'OpenCode plugin' : 'legacy OpenCode plugin',
|
||||
next: undefined,
|
||||
mode: 0o600,
|
||||
expectedSource: file.source,
|
||||
expectedSnapshot: file.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})));
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: owned.map((file) => ({
|
||||
path: path.dirname(file.filePath),
|
||||
label: DIRECTORY_LABEL,
|
||||
})),
|
||||
changes: changes.filter((change): change is ManagedFileChange => change !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitOpenCodeUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
OPENCODE_AUDIT_OPTIONS,
|
||||
parseLegacyOpenCodePlugin,
|
||||
parseOpenCodePlugin,
|
||||
resolveOpenCodePaths,
|
||||
type OpenCodeLegacyContract,
|
||||
type OpenCodePaths,
|
||||
type OpenCodeRuntimeContract,
|
||||
} from './opencode-contract.js';
|
||||
import { errorMessage, hasCode, MAX_CONFIG_BYTES } from './common.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
export interface OpenCodePluginFile<TContract> {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly source?: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly contract?: TContract;
|
||||
}
|
||||
|
||||
export interface OpenCodeSources {
|
||||
readonly paths: OpenCodePaths;
|
||||
readonly current: OpenCodePluginFile<OpenCodeRuntimeContract>;
|
||||
readonly legacy: OpenCodePluginFile<OpenCodeLegacyContract>;
|
||||
}
|
||||
|
||||
async function inspectOpenCodeDirectories(paths: OpenCodePaths): Promise<void> {
|
||||
await inspectPhysicalDirectory(paths.homeDirectory, 'OpenCode home directory');
|
||||
await inspectPhysicalDirectory(paths.configRoot, 'OpenCode XDG config directory');
|
||||
await inspectPhysicalDirectory(paths.configDirectory, 'OpenCode config directory');
|
||||
await inspectPhysicalDirectory(paths.pluginsDirectory, 'OpenCode plugins directory');
|
||||
}
|
||||
|
||||
async function readPluginFile<TContract>(
|
||||
filePath: string,
|
||||
label: string,
|
||||
parse: (filePath: string, source: string) => TContract | undefined,
|
||||
): Promise<OpenCodePluginFile<TContract>> {
|
||||
const snapshot = await readPhysicalFile(filePath, label, MAX_CONFIG_BYTES);
|
||||
if (!snapshot) return { exists: false, filePath };
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
source: snapshot.contents,
|
||||
snapshot,
|
||||
contract: parse(filePath, snapshot.contents),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readOpenCodeSources(): Promise<OpenCodeSources> {
|
||||
const paths = resolveOpenCodePaths();
|
||||
await inspectOpenCodeDirectories(paths);
|
||||
const [current, legacy] = await Promise.all([
|
||||
readPluginFile(paths.pluginPath, 'OpenCode plugin', parseOpenCodePlugin),
|
||||
readPluginFile(
|
||||
paths.legacyPluginPath,
|
||||
'legacy OpenCode plugin',
|
||||
(_filePath, source) => parseLegacyOpenCodePlugin(source),
|
||||
),
|
||||
]);
|
||||
return { paths, current, legacy };
|
||||
}
|
||||
|
||||
export function requireAvailableOpenCodePlugin(
|
||||
file: OpenCodePluginFile<OpenCodeRuntimeContract>,
|
||||
): void {
|
||||
if (file.exists && !file.contract) {
|
||||
throw new Error(`OpenCode plugin path is owned by another integration: ${file.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function executableExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fsp.stat(filePath)).isFile();
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
|
||||
throw new Error(`Inspect OpenCode Node.js runtime at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function openCodeRuntimeFilesExist(
|
||||
contract: OpenCodeRuntimeContract,
|
||||
): Promise<boolean> {
|
||||
return await executableExists(contract.executablePath)
|
||||
&& await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
auditOptions: OPENCODE_AUDIT_OPTIONS,
|
||||
});
|
||||
}
|
||||
+40
-111
@@ -1,134 +1,63 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { AGENT_KEY } from './opencode-contract.js';
|
||||
import {
|
||||
commitOpenCodeInstallation,
|
||||
commitOpenCodeUninstall,
|
||||
preflightOpenCodeInstallation,
|
||||
prepareOpenCodeInstallation,
|
||||
prepareOpenCodeUninstall,
|
||||
} from './opencode-installation.js';
|
||||
import {
|
||||
openCodeRuntimeFilesExist,
|
||||
readOpenCodeSources,
|
||||
requireAvailableOpenCodePlugin,
|
||||
} from './opencode-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'opencode';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
|
||||
return `// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
export const ElydoraAuditPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// Guard — blocks tool if agent is frozen
|
||||
try {
|
||||
const result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
if (result.status === 2) {
|
||||
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
|
||||
// Signal denial by setting a blocked flag
|
||||
if (output && typeof output === 'object') {
|
||||
output.blocked = true;
|
||||
output.reason = msg;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fail-open — allow if guard can't run
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
try {
|
||||
const toolName = input?.tool || 'unknown';
|
||||
const toolInput = output?.args || input?.args || {};
|
||||
const sessionId = ctx?.project?.name || 'unknown';
|
||||
|
||||
const data = JSON.stringify({
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
});
|
||||
|
||||
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.stdin.write(data);
|
||||
child.stdin.end();
|
||||
child.unref();
|
||||
} catch {
|
||||
// Fire-and-forget — never block the developer
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
`;
|
||||
function validateSources(sources: Awaited<ReturnType<typeof readOpenCodeSources>>): void {
|
||||
requireAvailableOpenCodePlugin(sources.current);
|
||||
}
|
||||
|
||||
export const opencodePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
managesRuntime: true,
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath)
|
||||
.replace('__ELYDORA_HOOK_SCRIPT_PATH__', config.hookScriptPath.replace(/\\/g, '\\\\'))
|
||||
.replace('__ELYDORA_GUARD_SCRIPT_PATH__', config.guardScriptPath.replace(/\\/g, '\\\\'));
|
||||
|
||||
await fsp.writeFile(configPath, script, 'utf-8');
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
await preflightOpenCodeInstallation(config, sources);
|
||||
},
|
||||
|
||||
async uninstall(_agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
const prepared = await prepareOpenCodeInstallation(config, sources);
|
||||
await commitOpenCodeInstallation(prepared);
|
||||
console.log(` OpenCode global plugin: ${sources.paths.pluginPath}`);
|
||||
console.log(' OpenCode activation: restart active OpenCode sessions.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
const prepared = await prepareOpenCodeUninstall(sources, agentId);
|
||||
await commitOpenCodeUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
hookConfigured = true;
|
||||
|
||||
// Extract hook script path from plugin script content
|
||||
// The plugin script contains a spawn call like: spawn('node', ['<path>'])
|
||||
const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/);
|
||||
if (match) {
|
||||
hookScriptPath = match[1];
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
const contract = sources.legacy.contract ? undefined : sources.current.contract;
|
||||
const hookConfigured = contract !== undefined;
|
||||
const hookScriptExists = contract !== undefined
|
||||
&& await openCodeRuntimeFilesExist(contract);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.paths.pluginPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from 'node:path';
|
||||
import { managedScriptReference, type ManagedScriptReference } from './common.js';
|
||||
import {
|
||||
isNodeExecutable,
|
||||
parsePosixCommand,
|
||||
parsePowerShellSource,
|
||||
posixSource,
|
||||
powerShellSource,
|
||||
} from './shell-command.js';
|
||||
|
||||
export type QwenRuntimeReference = ManagedScriptReference;
|
||||
|
||||
export function buildQwenCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Qwen Code hook commands require absolute executable and script paths');
|
||||
}
|
||||
return process.platform === 'win32' ? powerShellSource(scriptPath) : posixSource(scriptPath);
|
||||
}
|
||||
|
||||
export function qwenRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): QwenRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellSource(command)
|
||||
: parsePosixCommand(command);
|
||||
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||
return managedScriptReference(parsed[1], scriptName);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { JSONPath } from 'jsonc-parser';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import { changeJsonc } from './jsonc-edit.js';
|
||||
import {
|
||||
MANAGED_EVENTS,
|
||||
managedQwenRemovals,
|
||||
readQwenHooks,
|
||||
type ManagedQwenEvent,
|
||||
type QwenGroup,
|
||||
type QwenHooks,
|
||||
} from './qwen-contract.js';
|
||||
import { isObject, parseCommentedJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const QWEN_OWNED_FILE_MARKER = '// Managed by Elydora';
|
||||
|
||||
export type QwenDocumentKind = 'system-defaults' | 'user' | 'workspace' | 'system';
|
||||
|
||||
export interface QwenDocument {
|
||||
readonly kind: QwenDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: QwenHooks;
|
||||
readonly hasHooksContainer: boolean;
|
||||
readonly disableAllHooks?: boolean;
|
||||
readonly folderTrustEnabled?: boolean;
|
||||
readonly ownedFile: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedQwenDocument {
|
||||
readonly document: QwenDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly kind: QwenDocumentKind;
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export function qwenSourceLabel(kind: QwenDocumentKind): string {
|
||||
switch (kind) {
|
||||
case 'system-defaults': return 'Qwen Code system defaults';
|
||||
case 'workspace': return 'Qwen Code workspace settings';
|
||||
case 'system': return 'Qwen Code system override settings';
|
||||
default: return 'Qwen Code user settings';
|
||||
}
|
||||
}
|
||||
|
||||
function readFolderTrustEnabled(root: JsonObject, label: string): boolean | undefined {
|
||||
if (root.security === undefined) return undefined;
|
||||
if (!isObject(root.security)) throw new Error(`${label} field "security" must be an object`);
|
||||
if (root.security.folderTrust === undefined) return undefined;
|
||||
if (!isObject(root.security.folderTrust)) {
|
||||
throw new Error(`${label} field "security.folderTrust" must be an object`);
|
||||
}
|
||||
const enabled = root.security.folderTrust.enabled;
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
throw new Error(`${label} field "security.folderTrust.enabled" must be a boolean`);
|
||||
}
|
||||
return enabled as boolean | undefined;
|
||||
}
|
||||
|
||||
export function parseQwenDocument(options: DocumentOptions): QwenDocument {
|
||||
const label = `${qwenSourceLabel(options.kind)} at ${options.filePath}`;
|
||||
const root = parseCommentedJsonObject(options.raw, label);
|
||||
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
|
||||
throw new Error(`${label} field "disableAllHooks" must be a boolean`);
|
||||
}
|
||||
const hasHooksContainer = Object.hasOwn(root, 'hooks');
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readQwenHooks(root.hooks),
|
||||
hasHooksContainer,
|
||||
disableAllHooks: root.disableAllHooks as boolean | undefined,
|
||||
folderTrustEnabled: readFolderTrustEnabled(root, label),
|
||||
ownedFile: options.kind === 'user' && options.raw.startsWith(QWEN_OWNED_FILE_MARKER),
|
||||
};
|
||||
}
|
||||
|
||||
export function createQwenDocument(
|
||||
kind: QwenDocumentKind,
|
||||
filePath: string,
|
||||
): QwenDocument {
|
||||
return parseQwenDocument({
|
||||
kind,
|
||||
exists: false,
|
||||
filePath,
|
||||
raw: kind === 'user' ? `${QWEN_OWNED_FILE_MARKER}\n{}\n` : '{}\n',
|
||||
});
|
||||
}
|
||||
|
||||
function currentDocument(document: QwenDocument, raw: string): QwenDocument {
|
||||
return parseQwenDocument({
|
||||
kind: document.kind,
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
raw,
|
||||
snapshot: document.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function removeManagedEntries(
|
||||
document: QwenDocument,
|
||||
raw: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
const removals = managedQwenRemovals(document.hooks, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = changeJsonc(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||
(left, right) => right - left,
|
||||
)) {
|
||||
raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if ((current.hooks[event] ?? []).length === 0) {
|
||||
raw = changeJsonc(raw, ['hooks', event], undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||
raw = changeJsonc(raw, ['hooks'], undefined);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: QwenDocument,
|
||||
raw: string,
|
||||
event: ManagedQwenEvent,
|
||||
group: QwenGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hooks[event]) {
|
||||
return changeJsonc(raw, ['hooks', event, current.hooks[event].length], group, true);
|
||||
}
|
||||
return changeJsonc(raw, ['hooks', event], [group]);
|
||||
}
|
||||
|
||||
export function renderQwenDocument(
|
||||
document: QwenDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ManagedQwenEvent, QwenGroup>,
|
||||
): RenderedQwenDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (additions.size === 0 && document.ownedFile && Object.keys(current.root).length === 0) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
export function qwenDocumentLabel(document: QwenDocument): string {
|
||||
return qwenSourceLabel(document.kind);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { sameAgentId, samePath } from './common.js';
|
||||
import {
|
||||
buildQwenCommand,
|
||||
qwenRuntimeReference,
|
||||
type QwenRuntimeReference,
|
||||
} from './qwen-command.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'qwen';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_HOOK_NAME = 'elydora-guard';
|
||||
export const AUDIT_HOOK_NAME = 'elydora-audit';
|
||||
export const HOOK_TIMEOUT_MS = 10_000;
|
||||
export const MANAGED_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
] as const;
|
||||
|
||||
const KNOWN_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PostToolBatch',
|
||||
'Notification',
|
||||
'UserPromptSubmit',
|
||||
'UserPromptExpansion',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'MessageDisplay',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
'SessionEnd',
|
||||
'PermissionRequest',
|
||||
'PermissionDenied',
|
||||
'StopFailure',
|
||||
'TodoCreated',
|
||||
'TodoCompleted',
|
||||
'InstructionsLoaded',
|
||||
]);
|
||||
|
||||
const REGEX_MATCHER_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest',
|
||||
'PermissionDenied',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'StopFailure',
|
||||
'Notification',
|
||||
'InstructionsLoaded',
|
||||
'UserPromptExpansion',
|
||||
]);
|
||||
|
||||
export type ManagedQwenEvent = (typeof MANAGED_EVENTS)[number];
|
||||
|
||||
export interface QwenHandler extends JsonObject {
|
||||
readonly type: 'command' | 'http' | 'prompt';
|
||||
readonly command?: string;
|
||||
readonly name?: string;
|
||||
readonly shell?: 'bash' | 'powershell';
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface QwenGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly sequential?: boolean;
|
||||
readonly hooks: QwenHandler[];
|
||||
}
|
||||
|
||||
export type QwenHooks = Record<string, QwenGroup[]>;
|
||||
|
||||
export interface QwenRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRemoval {
|
||||
readonly event: ManagedQwenEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: readonly number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
function optionalString(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined && typeof value[field] !== 'string') {
|
||||
throw new Error(`${label} field "${field}" must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function optionalBoolean(value: JsonObject, field: string, label: string): void {
|
||||
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
|
||||
throw new Error(`${label} field "${field}" must be a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
function optionalStringMap(value: JsonObject, field: string, label: string): void {
|
||||
const item = value[field];
|
||||
if (item === undefined) return;
|
||||
if (!isObject(item) || Object.values(item).some((entry) => typeof entry !== 'string')) {
|
||||
throw new Error(`${label} field "${field}" must map names to strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, label: string): QwenHandler {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (!['command', 'http', 'prompt'].includes(String(value.type))) {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
validateTimeout(value.timeout, label);
|
||||
for (const field of ['name', 'description', 'statusMessage', 'source']) {
|
||||
optionalString(value, field, label);
|
||||
}
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
optionalStringMap(value, 'env', label);
|
||||
optionalBoolean(value, 'async', label);
|
||||
if (value.shell !== undefined && !['bash', 'powershell'].includes(String(value.shell))) {
|
||||
throw new Error(`${label} shell must be "bash" or "powershell"`);
|
||||
}
|
||||
}
|
||||
if (value.type === 'http') {
|
||||
if (typeof value.url !== 'string' || value.url.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty url`);
|
||||
}
|
||||
optionalStringMap(value, 'headers', label);
|
||||
optionalBoolean(value, 'once', label);
|
||||
optionalString(value, 'if', label);
|
||||
if (value.allowedEnvVars !== undefined && (
|
||||
!Array.isArray(value.allowedEnvVars)
|
||||
|| value.allowedEnvVars.some((item) => typeof item !== 'string')
|
||||
)) {
|
||||
throw new Error(`${label} allowedEnvVars must be an array of strings`);
|
||||
}
|
||||
}
|
||||
if (value.type === 'prompt') {
|
||||
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty prompt`);
|
||||
}
|
||||
optionalString(value, 'model', label);
|
||||
}
|
||||
return value as QwenHandler;
|
||||
}
|
||||
|
||||
function validateMatcher(matcher: string, event: string, label: string): void {
|
||||
if (!REGEX_MATCHER_EVENTS.has(event) || matcher.trim() === '' || matcher.trim() === '*') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new RegExp(matcher);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} matcher must be a valid regular expression`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, event: string, index: number): QwenGroup {
|
||||
const label = `Qwen Code settings group hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.matcher !== undefined) {
|
||||
if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`);
|
||||
validateMatcher(value.matcher, event, label);
|
||||
}
|
||||
if (value.sequential !== undefined && typeof value.sequential !== 'boolean') {
|
||||
throw new Error(`${label} sequential must be a boolean`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
|
||||
)),
|
||||
} as QwenGroup;
|
||||
}
|
||||
|
||||
export function readQwenHooks(value: unknown): QwenHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Qwen Code settings field "hooks" must be an object');
|
||||
const hooks: QwenHooks = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
hooks[event] = groups as QwenGroup[];
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Qwen Code settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export function buildQwenGroup(scriptPath: string, name: string): QwenGroup {
|
||||
return {
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
name,
|
||||
command: buildQwenCommand(scriptPath),
|
||||
shell: process.platform === 'win32' ? 'powershell' : 'bash',
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactCurrentGroup(group: QwenGroup): boolean {
|
||||
return Object.keys(group).length === 1 && Object.hasOwn(group, 'hooks');
|
||||
}
|
||||
|
||||
function exactLegacyGroup(group: QwenGroup): boolean {
|
||||
return Object.keys(group).sort().join('|') === 'hooks|matcher' && group.matcher === '*';
|
||||
}
|
||||
|
||||
function currentReference(
|
||||
handler: QwenHandler,
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
): QwenRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|name|shell|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.name === hookName
|
||||
&& handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash')
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? qwenRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function legacyReference(
|
||||
handler: QwenHandler,
|
||||
scriptName: string,
|
||||
): QwenRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|shell|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.shell === (process.platform === 'win32' ? 'powershell' : 'bash')
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? qwenRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: QwenHandler,
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
includeLegacy: boolean,
|
||||
): QwenRuntimeReference | undefined {
|
||||
return currentReference(handler, scriptName, hookName)
|
||||
?? (includeLegacy ? legacyReference(handler, scriptName) : undefined);
|
||||
}
|
||||
|
||||
const EVENT_CONTRACTS = [
|
||||
['PreToolUse', GUARD_SCRIPT, GUARD_HOOK_NAME],
|
||||
['PostToolUse', AUDIT_SCRIPT, AUDIT_HOOK_NAME],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT, AUDIT_HOOK_NAME],
|
||||
] as const;
|
||||
|
||||
export function managedQwenRemovals(
|
||||
hooks: QwenHooks,
|
||||
agentId?: string,
|
||||
): ManagedRemoval[] {
|
||||
const removals: ManagedRemoval[] = [];
|
||||
for (const [event, scriptName, hookName] of EVENT_CONTRACTS) {
|
||||
(hooks[event] ?? []).forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const reference = managedReference(handler, scriptName, hookName, true);
|
||||
return reference && (agentId === undefined || sameAgentId(reference.agentId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: (exactCurrentGroup(group) || exactLegacyGroup(group))
|
||||
&& handlerIndexes.length === group.hooks.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly QwenGroup[],
|
||||
scriptName: string,
|
||||
hookName: string,
|
||||
): Map<string, QwenRuntimeReference[]> {
|
||||
const references = new Map<string, QwenRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactCurrentGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = currentReference(handler, scriptName, hookName);
|
||||
if (!reference) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] {
|
||||
const guards = referencesForEvent(hooks.PreToolUse ?? [], GUARD_SCRIPT, GUARD_HOOK_NAME);
|
||||
const posts = referencesForEvent(hooks.PostToolUse ?? [], AUDIT_SCRIPT, AUDIT_HOOK_NAME);
|
||||
const failures = referencesForEvent(
|
||||
hooks.PostToolUseFailure ?? [],
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_HOOK_NAME,
|
||||
);
|
||||
const contracts: QwenRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const post = posts.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
||||
if (!samePath(post[0].scriptPath, failure[0].scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: post[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
} from './qwen-contract.js';
|
||||
import { MAX_SOURCE_BYTES, samePath } from './common.js';
|
||||
import {
|
||||
qwenDocumentLabel,
|
||||
type RenderedQwenDocument,
|
||||
} from './qwen-config.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
import {
|
||||
requireQwenHooksEnabled,
|
||||
type QwenSources,
|
||||
} from './qwen-sources.js';
|
||||
|
||||
const DISPLAY_NAME = 'Qwen Code';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Qwen Code user configuration directory';
|
||||
|
||||
export async function preflightQwenInstallation(
|
||||
config: InstallConfig,
|
||||
sources: QwenSources,
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
requireQwenHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
filePath: sources.user.filePath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function readOnlyPreconditions(sources: QwenSources, changedPath?: string) {
|
||||
return sources.preconditions.filter((condition) => (
|
||||
changedPath === undefined || !samePath(condition.filePath, changedPath)
|
||||
));
|
||||
}
|
||||
|
||||
export async function prepareQwenInstallation(
|
||||
config: InstallConfig,
|
||||
sources: QwenSources,
|
||||
rendered: RenderedQwenDocument,
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
await preflightQwenInstallation(config, sources);
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: qwenDocumentLabel(rendered.document),
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: { nativePayload: true },
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitQwenInstallation(
|
||||
prepared: PreparedManagedInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareQwenUninstall(
|
||||
sources: QwenSources,
|
||||
rendered: RenderedQwenDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const change = rendered.changed
|
||||
? await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: qwenDocumentLabel(rendered.document),
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: SETTINGS_DIRECTORY_LABEL,
|
||||
}],
|
||||
changes: change ? [change] : [],
|
||||
preconditions: readOnlyPreconditions(
|
||||
sources,
|
||||
rendered.changed ? rendered.document.filePath : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitQwenUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AGENT_KEY, type QwenRuntimeContract } from './qwen-contract.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
export async function qwenRuntimeFilesExist(
|
||||
contracts: readonly QwenRuntimeContract[],
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import { MAX_SOURCE_BYTES, pathKey, samePath } from './common.js';
|
||||
import {
|
||||
createQwenDocument,
|
||||
parseQwenDocument,
|
||||
qwenDocumentLabel,
|
||||
qwenSourceLabel,
|
||||
type QwenDocument,
|
||||
type QwenDocumentKind,
|
||||
} from './qwen-config.js';
|
||||
import { readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||
import { parseCommentedJsonObject } from './strict-json.js';
|
||||
const HOME_ENV_KEYS = ['QWEN_HOME', 'QWEN_RUNTIME_DIR'] as const;
|
||||
|
||||
export interface QwenSourcePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface QwenDisableControl {
|
||||
readonly disabled: boolean;
|
||||
readonly source?: QwenDocument;
|
||||
}
|
||||
|
||||
export interface QwenSources {
|
||||
readonly qwenHome: string;
|
||||
readonly systemDefaults: QwenDocument;
|
||||
readonly user: QwenDocument;
|
||||
readonly workspace: QwenDocument;
|
||||
readonly system: QwenDocument;
|
||||
readonly workspaceActive: boolean;
|
||||
readonly workspaceTrusted: boolean;
|
||||
readonly disableControl: QwenDisableControl;
|
||||
readonly preconditions: readonly QwenSourcePrecondition[];
|
||||
}
|
||||
|
||||
interface RoutingResult {
|
||||
readonly qwenHome: string;
|
||||
readonly preconditions: readonly QwenSourcePrecondition[];
|
||||
}
|
||||
|
||||
function defaultQwenHome(): string {
|
||||
const home = os.homedir();
|
||||
return home ? path.join(home, '.qwen') : path.join(os.tmpdir(), '.qwen');
|
||||
}
|
||||
|
||||
function resolveConfigPath(value: string): string {
|
||||
let resolved = value;
|
||||
if (resolved === '~' || resolved.startsWith('~/') || resolved.startsWith('~\\')) {
|
||||
const segments = resolved === '~'
|
||||
? []
|
||||
: resolved.slice(2).split(/[/\\]+/).filter(Boolean);
|
||||
resolved = path.join(os.homedir(), ...segments);
|
||||
}
|
||||
return path.isAbsolute(resolved) ? resolved : path.resolve(resolved);
|
||||
}
|
||||
|
||||
function environmentValue(key: string): { own: boolean; value?: string } {
|
||||
return {
|
||||
own: Object.hasOwn(process.env, key),
|
||||
value: process.env[key],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveQwenRouting(): Promise<RoutingResult> {
|
||||
const values = new Map<string, string | undefined>();
|
||||
const owned = new Set<string>();
|
||||
for (const key of HOME_ENV_KEYS) {
|
||||
const current = environmentValue(key);
|
||||
values.set(key, current.value);
|
||||
if (current.own) owned.add(key);
|
||||
}
|
||||
if (HOME_ENV_KEYS.every((key) => values.get(key))) {
|
||||
return {
|
||||
qwenHome: resolveConfigPath(values.get('QWEN_HOME')!),
|
||||
preconditions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const initialQwenHome = values.get('QWEN_HOME');
|
||||
const initialDirectory = initialQwenHome
|
||||
? resolveConfigPath(initialQwenHome)
|
||||
: defaultQwenHome();
|
||||
const candidates = [path.join(initialDirectory, '.env')];
|
||||
if (!initialQwenHome) candidates.push(path.join(path.dirname(initialDirectory), '.env'));
|
||||
const preconditions: QwenSourcePrecondition[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const readCandidate = async (filePath: string): Promise<void> => {
|
||||
const resolved = path.resolve(filePath);
|
||||
const key = pathKey(resolved);
|
||||
if (visited.has(key)) return;
|
||||
visited.add(key);
|
||||
const snapshot = await readPhysicalFile(resolved, 'Qwen Code home environment');
|
||||
preconditions.push({
|
||||
filePath: resolved,
|
||||
label: 'Qwen Code home environment',
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: snapshot,
|
||||
});
|
||||
if (!snapshot) return;
|
||||
const parsed = parseDotenv(snapshot.contents);
|
||||
for (const envKey of HOME_ENV_KEYS) {
|
||||
const value = parsed[envKey];
|
||||
if (value && !owned.has(envKey)) {
|
||||
values.set(envKey, value);
|
||||
owned.add(envKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const candidate of candidates) await readCandidate(candidate);
|
||||
const discoveredHome = values.get('QWEN_HOME');
|
||||
if (discoveredHome && discoveredHome !== initialQwenHome) {
|
||||
const discoveredDirectory = resolveConfigPath(discoveredHome);
|
||||
if (discoveredDirectory !== initialDirectory) {
|
||||
await readCandidate(path.join(discoveredDirectory, '.env'));
|
||||
}
|
||||
}
|
||||
return {
|
||||
qwenHome: values.get('QWEN_HOME')
|
||||
? resolveConfigPath(values.get('QWEN_HOME')!)
|
||||
: defaultQwenHome(),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
function systemSettingsPath(): string {
|
||||
const configured = process.env.QWEN_CODE_SYSTEM_SETTINGS_PATH;
|
||||
if (configured) return path.resolve(configured);
|
||||
if (process.platform === 'darwin') {
|
||||
return '/Library/Application Support/QwenCode/settings.json';
|
||||
}
|
||||
if (process.platform === 'win32') return 'C:\\ProgramData\\qwen-code\\settings.json';
|
||||
return '/etc/qwen-code/settings.json';
|
||||
}
|
||||
|
||||
function systemDefaultsPath(systemPath: string): string {
|
||||
const configured = process.env.QWEN_CODE_SYSTEM_DEFAULTS_PATH;
|
||||
return configured
|
||||
? path.resolve(configured)
|
||||
: path.join(path.dirname(systemPath), 'system-defaults.json');
|
||||
}
|
||||
|
||||
async function readDocument(
|
||||
kind: QwenDocumentKind,
|
||||
filePath: string,
|
||||
): Promise<QwenDocument> {
|
||||
const snapshot = await readPhysicalFile(filePath, qwenSourceLabel(kind));
|
||||
return snapshot
|
||||
? parseQwenDocument({
|
||||
kind,
|
||||
exists: true,
|
||||
filePath,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
})
|
||||
: createQwenDocument(kind, filePath);
|
||||
}
|
||||
|
||||
async function canonicalPath(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function isWithin(child: string, parent: string): boolean {
|
||||
const relative = path.relative(pathKey(parent), pathKey(child));
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function workspaceTrust(
|
||||
system: QwenDocument,
|
||||
user: QwenDocument,
|
||||
qwenHome: string,
|
||||
workspacePath: string,
|
||||
): Promise<{ trusted: boolean; precondition?: QwenSourcePrecondition }> {
|
||||
const enabled = user.folderTrustEnabled ?? system.folderTrustEnabled ?? false;
|
||||
if (!enabled) return { trusted: true };
|
||||
const configured = process.env.QWEN_CODE_TRUSTED_FOLDERS_PATH;
|
||||
const filePath = configured
|
||||
? path.resolve(configured)
|
||||
: path.join(qwenHome, 'trustedFolders.json');
|
||||
const snapshot = await readPhysicalFile(filePath, 'Qwen Code trusted folders');
|
||||
const precondition = {
|
||||
filePath,
|
||||
label: 'Qwen Code trusted folders',
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: snapshot,
|
||||
};
|
||||
if (!snapshot) return { trusted: true, precondition };
|
||||
const rules = parseCommentedJsonObject(snapshot.contents, `Qwen Code trusted folders at ${filePath}`);
|
||||
for (const [rulePath, level] of Object.entries(rules)) {
|
||||
if (!['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(String(level))) {
|
||||
throw new Error(`Qwen Code trusted folders has invalid trust level for "${rulePath}"`);
|
||||
}
|
||||
}
|
||||
const workspace = await canonicalPath(workspacePath);
|
||||
for (const [rulePath, level] of Object.entries(rules)) {
|
||||
const canonicalRule = await canonicalPath(String(rulePath));
|
||||
const trustRoot = level === 'TRUST_PARENT' ? path.dirname(canonicalRule) : canonicalRule;
|
||||
if ((level === 'TRUST_FOLDER' || level === 'TRUST_PARENT')
|
||||
&& isWithin(workspace, trustRoot)) return { trusted: true, precondition };
|
||||
}
|
||||
for (const [rulePath, level] of Object.entries(rules)) {
|
||||
if (level === 'DO_NOT_TRUST'
|
||||
&& samePath(workspace, await canonicalPath(rulePath))) {
|
||||
return { trusted: false, precondition };
|
||||
}
|
||||
}
|
||||
return { trusted: true, precondition };
|
||||
}
|
||||
|
||||
function effectiveDisable(
|
||||
systemDefaults: QwenDocument,
|
||||
user: QwenDocument,
|
||||
workspace: QwenDocument,
|
||||
system: QwenDocument,
|
||||
useWorkspace: boolean,
|
||||
): QwenDisableControl {
|
||||
let disabled = false;
|
||||
let source: QwenDocument | undefined;
|
||||
const documents = [systemDefaults, user, ...(useWorkspace ? [workspace] : []), system];
|
||||
for (const document of documents) {
|
||||
if (document.disableAllHooks === undefined) continue;
|
||||
disabled = document.disableAllHooks;
|
||||
source = document;
|
||||
}
|
||||
return { disabled, source };
|
||||
}
|
||||
|
||||
function sourcePrecondition(document: QwenDocument): QwenSourcePrecondition {
|
||||
return {
|
||||
filePath: document.filePath,
|
||||
label: qwenDocumentLabel(document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: document.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function deduplicatePreconditions(
|
||||
values: readonly QwenSourcePrecondition[],
|
||||
): QwenSourcePrecondition[] {
|
||||
const result = new Map<string, QwenSourcePrecondition>();
|
||||
for (const value of values) {
|
||||
const key = pathKey(value.filePath);
|
||||
if (!result.has(key)) result.set(key, value);
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
export async function readQwenSources(): Promise<QwenSources> {
|
||||
const routing = await resolveQwenRouting();
|
||||
const systemPath = systemSettingsPath();
|
||||
const workspacePath = path.join(process.cwd(), '.qwen', 'settings.json');
|
||||
const system = await readDocument('system', systemPath);
|
||||
const systemDefaults = await readDocument(
|
||||
'system-defaults',
|
||||
systemDefaultsPath(systemPath),
|
||||
);
|
||||
const user = await readDocument('user', path.join(routing.qwenHome, 'settings.json'));
|
||||
const [canonicalWorkspace, canonicalHome] = await Promise.all([
|
||||
canonicalPath(process.cwd()),
|
||||
canonicalPath(os.homedir()),
|
||||
]);
|
||||
const workspaceActive = !samePath(canonicalWorkspace, canonicalHome);
|
||||
const workspace = workspaceActive
|
||||
? await readDocument('workspace', workspacePath)
|
||||
: createQwenDocument('workspace', workspacePath);
|
||||
const trust = workspaceActive
|
||||
? await workspaceTrust(system, user, routing.qwenHome, canonicalWorkspace)
|
||||
: { trusted: false as const };
|
||||
const useWorkspace = workspaceActive && trust.trusted;
|
||||
const preconditions = deduplicatePreconditions([
|
||||
...routing.preconditions,
|
||||
sourcePrecondition(systemDefaults),
|
||||
sourcePrecondition(user),
|
||||
...(workspaceActive ? [sourcePrecondition(workspace)] : []),
|
||||
sourcePrecondition(system),
|
||||
...(trust.precondition ? [trust.precondition] : []),
|
||||
]);
|
||||
return {
|
||||
qwenHome: routing.qwenHome,
|
||||
systemDefaults,
|
||||
user,
|
||||
workspace,
|
||||
system,
|
||||
workspaceActive,
|
||||
workspaceTrusted: trust.trusted,
|
||||
disableControl: effectiveDisable(
|
||||
systemDefaults,
|
||||
user,
|
||||
workspace,
|
||||
system,
|
||||
useWorkspace,
|
||||
),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireQwenHooksEnabled(sources: QwenSources): void {
|
||||
if (!sources.disableControl.disabled) return;
|
||||
const source = sources.disableControl.source;
|
||||
const location = source ? `${qwenDocumentLabel(source)} at ${source.filePath}` : 'effective settings';
|
||||
throw new Error(`Qwen Code hooks are disabled by disableAllHooks in ${location}`);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_HOOK_NAME,
|
||||
GUARD_HOOK_NAME,
|
||||
buildQwenGroup,
|
||||
qwenRuntimeContracts,
|
||||
type ManagedQwenEvent,
|
||||
type QwenGroup,
|
||||
} from './qwen-contract.js';
|
||||
import { renderQwenDocument } from './qwen-config.js';
|
||||
import {
|
||||
commitQwenInstallation,
|
||||
commitQwenUninstall,
|
||||
preflightQwenInstallation,
|
||||
prepareQwenInstallation,
|
||||
prepareQwenUninstall,
|
||||
} from './qwen-installation.js';
|
||||
import { qwenRuntimeFilesExist } from './qwen-io.js';
|
||||
import { readQwenSources } from './qwen-sources.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||
ManagedQwenEvent,
|
||||
QwenGroup
|
||||
> {
|
||||
return new Map([
|
||||
['PreToolUse', buildQwenGroup(guardPath, GUARD_HOOK_NAME)],
|
||||
['PostToolUse', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)],
|
||||
['PostToolUseFailure', buildQwenGroup(auditPath, AUDIT_HOOK_NAME)],
|
||||
]);
|
||||
}
|
||||
|
||||
export const qwenPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readQwenSources();
|
||||
await preflightQwenInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const sources = await readQwenSources();
|
||||
const paths = await preflightQwenInstallation(config, sources);
|
||||
const rendered = renderQwenDocument(
|
||||
sources.user,
|
||||
undefined,
|
||||
installedGroups(paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitQwenInstallation(
|
||||
await prepareQwenInstallation(config, sources, rendered),
|
||||
);
|
||||
console.log(` Qwen Code hooks: ${sources.user.filePath}`);
|
||||
console.log(' Qwen Code verification: run /hooks.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readQwenSources();
|
||||
const rendered = renderQwenDocument(sources.user, agentId, new Map());
|
||||
if (!rendered.changed) return;
|
||||
await commitQwenUninstall(await prepareQwenUninstall(sources, rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const sources = await readQwenSources();
|
||||
const contracts = qwenRuntimeContracts(sources.user.hooks);
|
||||
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
? await qwenRuntimeFilesExist(contracts)
|
||||
: false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: sources.user.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
+12
-5
@@ -5,12 +5,19 @@ export interface AgentRegistryEntry {
|
||||
}
|
||||
|
||||
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
|
||||
['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }],
|
||||
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
|
||||
['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }],
|
||||
['cursor', { name: 'Cursor CLI', configDir: '~/.cursor', configFile: 'hooks.json' }],
|
||||
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro', configFile: 'settings.json' }],
|
||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
|
||||
['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }],
|
||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.js' }],
|
||||
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
|
||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||
['codex', { name: 'Codex CLI', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||
['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }],
|
||||
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
|
||||
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
|
||||
['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }],
|
||||
['qwen', { name: 'Qwen Code', configDir: '~/.qwen', configFile: 'settings.json' }],
|
||||
]);
|
||||
@@ -0,0 +1,144 @@
|
||||
export const PROTECTED_RUNTIME_READER: string = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024;
|
||||
const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024;
|
||||
const MAX_PROTECTED_LOG_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function hasRuntimeErrorCode(error, code) {
|
||||
return error && typeof error === 'object' && error.code === code;
|
||||
}
|
||||
|
||||
function validateProtectedMetadata(metadata, filePath, label, maxBytes) {
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new Error(label + ' path is not a physical file: ' + filePath);
|
||||
}
|
||||
if (metadata.size > maxBytes) {
|
||||
throw new Error(label + ' exceeds the size limit: ' + filePath);
|
||||
}
|
||||
if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
const raw = fs.readFileSync(descriptor);
|
||||
if (raw.length > maxBytes) {
|
||||
throw new Error(label + ' exceeds the size limit: ' + filePath);
|
||||
}
|
||||
return raw;
|
||||
} finally {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,128 @@
|
||||
import path from 'node:path';
|
||||
import { samePath } from './common.js';
|
||||
|
||||
export interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export type CommandParts = readonly [executable: string, script: string];
|
||||
|
||||
const POSIX_APOSTROPHE = `'"'"'`;
|
||||
const POWERSHELL_EXIT_SUFFIX = '; exit $LASTEXITCODE';
|
||||
const ENCODED_COMMAND_FLAGS = '-NoLogo -NoProfile -NonInteractive -EncodedCommand';
|
||||
|
||||
export function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", POSIX_APOSTROPHE)}'`;
|
||||
}
|
||||
|
||||
export function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function windowsPowerShellPath(): string {
|
||||
const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined;
|
||||
const systemRoot = configuredRoot
|
||||
&& path.win32.isAbsolute(configuredRoot)
|
||||
&& !/["%\r\n]/.test(configuredRoot)
|
||||
? configuredRoot
|
||||
: 'C:\\Windows';
|
||||
return path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
}
|
||||
|
||||
export function posixSource(scriptPath: string): string {
|
||||
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
}
|
||||
|
||||
export function powerShellSource(scriptPath: string): string {
|
||||
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}${POWERSHELL_EXIT_SUFFIX}`;
|
||||
}
|
||||
|
||||
export function encodePowerShellSource(scriptPath: string): string {
|
||||
return Buffer.from(powerShellSource(scriptPath), 'utf16le').toString('base64');
|
||||
}
|
||||
|
||||
export function encodedWindowsCommand(scriptPath: string): string {
|
||||
return `"${windowsPowerShellPath()}" ${ENCODED_COMMAND_FLAGS} ${encodePowerShellSource(scriptPath)}`;
|
||||
}
|
||||
|
||||
// The running executable, or a binary named node (node.exe case-insensitive on Windows).
|
||||
export function isNodeExecutable(filePath: string): boolean {
|
||||
if (samePath(filePath, process.execPath)) return true;
|
||||
const basename = path.basename(filePath);
|
||||
return basename === 'node' || basename.toLowerCase() === 'node.exe';
|
||||
}
|
||||
|
||||
export function isPowerShellExecutable(filePath: string): boolean {
|
||||
return path.win32.isAbsolute(filePath)
|
||||
&& path.win32.basename(filePath).toLowerCase() === 'powershell.exe';
|
||||
}
|
||||
|
||||
export function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(POSIX_APOSTROPHE, index)) {
|
||||
value += "'";
|
||||
index += POSIX_APOSTROPHE.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parsePosixCommand(command: string): CommandParts | undefined {
|
||||
const executable = readPosixArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPosixArgument(command, executable.next + 1);
|
||||
if (!script || script.next !== command.length) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] !== "'") {
|
||||
value += command[index];
|
||||
continue;
|
||||
}
|
||||
if (command[index + 1] === "'") {
|
||||
value += "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return { value, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parsePowerShellSource(source: string): CommandParts | undefined {
|
||||
if (!source.startsWith('& ')) return undefined;
|
||||
const executable = readPowerShellArgument(source, 2);
|
||||
if (!executable || source[executable.next] !== ' ') return undefined;
|
||||
const script = readPowerShellArgument(source, executable.next + 1);
|
||||
if (!script || source.slice(script.next) !== POWERSHELL_EXIT_SUFFIX) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
export function decodePowerShellSource(encoded: string): CommandParts | undefined {
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return undefined;
|
||||
const buffer = Buffer.from(encoded, 'base64');
|
||||
if (buffer.toString('base64') !== encoded || buffer.length % 2 !== 0) return undefined;
|
||||
return parsePowerShellSource(buffer.toString('utf16le'));
|
||||
}
|
||||
|
||||
export function parseEncodedWindowsCommand(command: string): CommandParts | undefined {
|
||||
const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command);
|
||||
if (!match || !isPowerShellExecutable(match[1])) return undefined;
|
||||
return decodePowerShellSource(match[2]);
|
||||
}
|
||||
|
||||
export function parseLegacyWindowsCommand(command: string): CommandParts | undefined {
|
||||
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
|
||||
return match ? [match[1], match[2]] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
parse,
|
||||
parseTree,
|
||||
printParseErrorCode,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
interface JsonObjectParseOptions {
|
||||
readonly allowTrailingComma: boolean;
|
||||
readonly disallowComments: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(
|
||||
raw: string,
|
||||
label: string,
|
||||
options: JsonObjectParseOptions,
|
||||
): JsonObject {
|
||||
const errors: ParseError[] = [];
|
||||
const value: unknown = parse(raw, errors, options);
|
||||
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, [], options), label);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseStrictJsonObject(raw: string, label: string): JsonObject {
|
||||
return parseJsonObject(raw, label, {
|
||||
allowTrailingComma: false,
|
||||
disallowComments: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCommentedJsonObject(raw: string, label: string): JsonObject {
|
||||
return parseJsonObject(raw, label, {
|
||||
allowTrailingComma: false,
|
||||
disallowComments: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseStrictJsoncObject(raw: string, label: string): JsonObject {
|
||||
return parseJsonObject(raw, label, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export function isMissingPath(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
export async function ensurePrivateDirectory(directory: string): Promise<void> {
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const metadata = await fsp.lstat(directory);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
throw new Error(`Private directory path is not a physical directory: ${directory}`);
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
await fsp.chmod(directory, 0o700);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requirePhysicalDirectory(directory: string): Promise<boolean> {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await fsp.lstat(directory);
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) return false;
|
||||
throw new Error(`Could not inspect agent runtime directory: ${directory}`, { cause: error });
|
||||
}
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
throw new Error(`Agent runtime path is not a physical directory: ${directory}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requirePhysicalFile(filePath: string): Promise<boolean> {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await fsp.lstat(filePath);
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) return false;
|
||||
throw new Error(`Could not inspect agent runtime file: ${filePath}`, { cause: error });
|
||||
}
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new Error(`Agent runtime config is not a physical file: ${filePath}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolvePrivateChildDirectory(root: string, childName: string): string {
|
||||
const isWindowsDeviceName = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(childName);
|
||||
if (
|
||||
childName.length === 0
|
||||
|| /[<>:"|?*\u0000-\u001f]/.test(childName)
|
||||
|| childName.includes('/')
|
||||
|| childName.includes('\\')
|
||||
|| childName === '.'
|
||||
|| childName === '..'
|
||||
|| childName.startsWith(' ')
|
||||
|| childName.endsWith('.')
|
||||
|| childName.endsWith(' ')
|
||||
|| isWindowsDeviceName
|
||||
) {
|
||||
throw new Error(`Invalid agent ID for local storage: ${JSON.stringify(childName)}`);
|
||||
}
|
||||
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const candidate = path.resolve(resolvedRoot, childName);
|
||||
const relative = path.relative(resolvedRoot, candidate);
|
||||
if (
|
||||
relative.length === 0
|
||||
|| path.isAbsolute(relative)
|
||||
|| relative === '..'
|
||||
|| relative.startsWith(`..${path.sep}`)
|
||||
|| relative.includes(path.sep)
|
||||
) {
|
||||
throw new Error(`Agent ID escapes the local storage directory: ${JSON.stringify(childName)}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ensurePrivateDirectory, isMissingPath } from './runtime-paths.js';
|
||||
|
||||
export async function writePrivateFile(filePath: string, contents: string): Promise<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
await ensurePrivateDirectory(directory);
|
||||
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`,
|
||||
);
|
||||
let committed = false;
|
||||
try {
|
||||
const handle = await fsp.open(temporaryPath, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(contents, 'utf-8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
await fsp.rename(temporaryPath, filePath);
|
||||
committed = true;
|
||||
if (process.platform !== 'win32') {
|
||||
await fsp.chmod(filePath, 0o600);
|
||||
}
|
||||
} finally {
|
||||
if (!committed) {
|
||||
try {
|
||||
await fsp.unlink(temporaryPath);
|
||||
} catch (error) {
|
||||
if (!isMissingPath(error)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-35
@@ -1,7 +1,3 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums / status types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AgentStatus = 'active' | 'frozen' | 'revoked';
|
||||
export type KeyStatus = 'active' | 'retired' | 'revoked';
|
||||
export type ExportStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||
@@ -11,21 +7,42 @@ export type RbacRole =
|
||||
| 'compliance_auditor'
|
||||
| 'readonly_investigator'
|
||||
| 'integration_engineer';
|
||||
export type IntegrationType =
|
||||
| 'claudecode' | 'cursor' | 'gemini' | 'kirocli' | 'kiroide'
|
||||
| 'opencode' | 'copilot' | 'letta' | 'codex' | 'kimi'
|
||||
| 'enterprise' | 'gui' | 'sdk' | 'other';
|
||||
export const INTEGRATION_TYPES = [
|
||||
'augment',
|
||||
'claudecode',
|
||||
'cline',
|
||||
'codex',
|
||||
'copilot',
|
||||
'cursor',
|
||||
'droid',
|
||||
'gemini',
|
||||
'grok',
|
||||
'kimi',
|
||||
'kirocli',
|
||||
'kiroide',
|
||||
'letta',
|
||||
'opencode',
|
||||
'qwen',
|
||||
'enterprise',
|
||||
'gui',
|
||||
'sdk',
|
||||
'other',
|
||||
] as const;
|
||||
export type IntegrationType = (typeof INTEGRATION_TYPES)[number];
|
||||
export type AdminAction =
|
||||
| 'agent.register' | 'agent.update' | 'agent.freeze' | 'agent.unfreeze'
|
||||
| 'agent.revoke' | 'agent.delete' | 'key.revoke' | 'export.create'
|
||||
| 'org.create' | 'org.update'
|
||||
| 'member.invite' | 'member.remove' | 'member.role_change'
|
||||
| 'agent.assign' | 'agent.unassign';
|
||||
| 'member.invite' | 'invitation.cancel' | 'member.remove' | 'member.role_change'
|
||||
| 'agent.assign' | 'agent.unassign'
|
||||
| 'webhook.register' | 'webhook.delete';
|
||||
export type ErrorCode =
|
||||
| 'INVALID_SIGNATURE'
|
||||
| 'UNKNOWN_AGENT'
|
||||
| 'KEY_REVOKED'
|
||||
| 'KEY_RETIRED'
|
||||
| 'AGENT_FROZEN'
|
||||
| 'AGENT_REVOKED'
|
||||
| 'TTL_EXPIRED'
|
||||
| 'REPLAY_DETECTED'
|
||||
| 'PREV_HASH_MISMATCH'
|
||||
@@ -37,10 +54,6 @@ export type ErrorCode =
|
||||
| 'NOT_FOUND'
|
||||
| 'VALIDATION_ERROR';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Agent {
|
||||
readonly agent_id: string;
|
||||
readonly org_id: string;
|
||||
@@ -150,10 +163,6 @@ export interface Export {
|
||||
readonly completed_at: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EOR {
|
||||
readonly op_version: '1.0';
|
||||
readonly operation_id: string;
|
||||
@@ -187,12 +196,9 @@ export interface EAR {
|
||||
readonly elydora_signature: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API request/response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegisterAgentRequest {
|
||||
readonly agent_id: string;
|
||||
readonly integration_type: IntegrationType;
|
||||
readonly display_name?: string;
|
||||
readonly responsible_entity?: string;
|
||||
readonly keys: ReadonlyArray<{
|
||||
@@ -219,6 +225,7 @@ export interface SubmitOperationResponse {
|
||||
export interface GetOperationResponse {
|
||||
readonly operation: Operation;
|
||||
readonly receipt?: Receipt;
|
||||
readonly payload?: Record<string, unknown> | string;
|
||||
}
|
||||
|
||||
export interface VerifyOperationResponse {
|
||||
@@ -308,15 +315,22 @@ export interface DeleteAgentResponse {
|
||||
readonly deleted: boolean;
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
readonly webhook_id: string;
|
||||
readonly org_id: string;
|
||||
readonly endpoint_url: string;
|
||||
readonly events: string[];
|
||||
readonly status: 'active' | 'disabled';
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
export interface ListWebhooksResponse {
|
||||
readonly webhooks: unknown[];
|
||||
readonly webhooks: Webhook[];
|
||||
}
|
||||
|
||||
export interface RegisterWebhookResponse {
|
||||
readonly webhook_id: string;
|
||||
readonly endpoint_url: string;
|
||||
readonly events: string[];
|
||||
readonly created_at: number;
|
||||
readonly webhook: Webhook;
|
||||
}
|
||||
|
||||
export interface ListMembersResponse {
|
||||
@@ -338,16 +352,26 @@ export interface DeepHealthResponse {
|
||||
readonly status: 'healthy' | 'degraded';
|
||||
readonly version: string;
|
||||
readonly protocol_version: string;
|
||||
readonly capabilities: Record<string, string>;
|
||||
readonly timestamp: number;
|
||||
readonly dependencies: {
|
||||
readonly d1: { status: 'ok' | 'failed'; latency_ms: number; error?: string };
|
||||
readonly r2: { status: 'ok' | 'failed'; latency_ms: number; error?: string };
|
||||
readonly kv: { status: 'ok' | 'failed'; latency_ms: number; error?: string };
|
||||
readonly d1: { status: 'ok' | 'failed'; latency_ms: number };
|
||||
readonly auth_storage: { status: 'ok' | 'failed'; latency_ms: number; contract_phase?: 'expand' | 'contract' };
|
||||
readonly r2: { status: 'ok' | 'failed'; latency_ms: number };
|
||||
readonly kv: { status: 'ok' | 'failed'; latency_ms: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMeResponse {
|
||||
readonly user: User;
|
||||
readonly user: Omit<User, 'org_id'> & {
|
||||
readonly org_id: string | null;
|
||||
readonly onboarding_completed: boolean;
|
||||
};
|
||||
readonly current_organization: {
|
||||
readonly org_id: string | null;
|
||||
readonly ba_org_id: string | null;
|
||||
readonly role: RbacRole;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueApiTokenResponse {
|
||||
@@ -360,12 +384,14 @@ export interface RotateApiTokenResponse {
|
||||
readonly token: string;
|
||||
readonly expires_at: number | null;
|
||||
readonly token_id: string;
|
||||
readonly previous_token_grace_until: number;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
readonly status: string;
|
||||
readonly version: string;
|
||||
readonly protocol_version: string;
|
||||
readonly capabilities: Record<string, string>;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
@@ -402,10 +428,6 @@ export interface ErrorResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ElydoraClientConfig {
|
||||
readonly orgId: string;
|
||||
readonly agentId: string;
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user