48 Commits
Author SHA1 Message Date
ZacharyZhang-NY a009446fdb docs: add AGit pull request instructions 2026-10-09 19:46:37 +00:00
雷电芽衣 6c39ce7781 chore(go): release 2.1.1
Carries the non-idempotent retry guard, previous_token_grace_until, and
agent-id path escaping.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 21:47:57 -04:00
雷电芽衣 b84c79816c docs: drop the em-dash from the genesis hash comment
Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 21:07:25 -04:00
雷电芽衣 ce0c924c58 fix: stop replaying non-idempotent requests and expose the rotation grace
A retried POST /v1/auth/rotate loses the replacement token because the
presented token is already in grace, and a retried webhook registration
creates duplicates. Only idempotent methods now retry. Add
previous_token_grace_until to RotateApiTokenResponse and percent-encode
the agent id in request paths.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 20:12:48 -04:00
雷电芽衣 fcdc2af2b9 chore(go): release 2.1.0
Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 02:44:20 -04:00
雷电芽衣 301210ff25 docs(go): audit README and AGENTS
Align the README API reference with the exported surface, add the
upgrade note, document the shared modules and Linux test setup in
AGENTS.md, and scope the Grok expandable-path assertion to Windows.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 02:43:55 -04:00
雷电芽衣 96489cd051 chore(go): audit providers kirocli kiroide opencode
Move Kiro IDE onto managed_runtime.go and shell_command.go, share
cloneJSONObject across providers, make OpenCode status verify the managed
runtime contract instead of any hook.js, propagate script removal errors,
and build the legacy runtime config through buildManagedRuntimeConfig.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 02:32:20 -04:00
雷电芽衣 34ff4c1ea8 chore(go): audit providers kimi grok qwen letta
Move Kimi, Grok, and Qwen onto managed_runtime.go and shell_command.go;
share the encoded and shell command builders and the quoted Windows
legacy parser with Droid; split managed_changes.go; dedupe Letta's hook
entry filtering and stop swallowing settings read errors.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 02:16:58 -04:00
雷电芽衣 1ede03d989 chore(go): audit providers copilot droid augment cline
Move the four providers onto managed_runtime.go and shell_command.go;
keep the Auggie wrappers, Cline metadata wrappers, and the Copilot and
Droid precondition handling provider-local.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 01:56:07 -04:00
雷电芽衣 a7fc75bf6b chore(go): audit providers claudecode codex cursor gemini
Add managed_runtime.go (paths, config validation, identity, runtime file
changes, status checks) and shell_command.go (quoting, PowerShell
encoding, command parsing) and move the four providers onto them.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 01:42:27 -04:00
雷电芽衣 c1caed3d42 chore(go): audit transaction engine
Share change filtering between the durable and test transaction paths,
collapse repeated precondition handling, unify journal sequence parsing,
and split the subprocess worker test file.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 01:20:38 -04:00
雷电芽衣 b085947547 chore(go): audit shared plugin helpers
Drop unused expandHome and standardizeJSONC, share the runtime base URL
validator between the guard and audit templates, and trim comments.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 01:17:26 -04:00
雷电芽衣 5e08ab20f5 chore(go): audit cmd/elydora root
Drop the tautological secret file identity check and the test-only
guard path wrapper.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 01:09:31 -04:00
雷电芽衣 27b720ca69 chore(go): audit root package
Share one request, decode, and error path across the client, public
endpoints, and export download; align types with the Backend contract
and split types_api.go.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 01:06:42 -04:00
雷电芽衣 f5341ed557 fix(go): retry rejected prev_chain_hash within a bounded budget and serialize chain-state updates; release 2.0.1
Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
2026-09-02 04:46:31 -04:00
雷电芽衣 a08d3c386c chore(go): release 2.0.0 under the /v2 module path
Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
2026-09-02 00:50:27 -04:00
ZacharyZhang-NY eb41b991bf feat(go): durable transaction engine and Kiro IDE 1.0 adapter
Split the monolithic transaction into focused modules: append-only
crash-recoverable journal under ~/.elydora/transactions with owner
tokens and per-transaction namespaces, staged atomic publication
(stage .pending, fsync, rename, fsync), platform-specific atomic
replacement (MoveFileEx, renameat2, renamex_np), private-directory
hardening per OS, lock-guarded recovery, and workspace cleanup that
removes only transaction-created empty directories.

Add the Kiro IDE 1.0 plugin: strict v1 hook schema with one enabled
PreToolUse guard and PostToolUse audit pair bound to the active
workspace, agent-to-workspace identity binding in runtime config, and
exact migration of the legacy elydora-audit.kiro.hook contract.

Gates: go test ./... and -race pass, go vet clean, govulncheck reports
zero reachable vulnerabilities, git diff --check clean.
2026-07-21 19:02:57 -04:00
ZacharyZhang-NY dd72793cdb fix(qwen): align Go hooks with Qwen Code 0.20 2026-07-20 16:06:52 -04:00
ZacharyZhang-NY 4521cbe32f fix(droid): align hooks with Factory 0.175 2026-07-20 14:34:54 -04:00
ZacharyZhang-NY 827374e7f8 fix(copilot): implement official transactional hook contract 2026-07-20 12:58:30 -04:00
ZacharyZhang-NY 050d0043ea fix(cline): make Go hook installation transactional 2026-07-20 11:33:44 -04:00
ZacharyZhang-NY bb7186630d fix(augment): make hook installation transactional 2026-07-20 10:18:24 -04:00
ZacharyZhang-NY f619b04289 fix(claude): accept async rewake metadata 2026-07-20 09:11:30 -04:00
ZacharyZhang-NY 2dc633a506 fix(gemini): implement native transactional hooks 2026-07-20 08:39:37 -04:00
ZacharyZhang-NY 15d044cf63 fix(claude): implement official Go hook contract 2026-07-20 07:23:12 -04:00
ZacharyZhang-NY f71db95502 fix: align Grok hooks with official contracts 2026-07-20 06:07:52 -04:00
ZacharyZhang-NY c60b46b994 fix: align Kimi hooks with official contracts 2026-07-20 04:56:41 -04:00
ZacharyZhang-NY f0e4694da2 fix: make Codex hooks transactional 2026-07-20 03:22:58 -04:00
ZacharyZhang-NY 415a884efe fix: harden Cursor hook enforcement 2026-07-20 00:15:21 -04:00
ZacharyZhang-NY bf777a2ff0 fix: harden GitHub Copilot hooks 2026-07-19 23:13:24 -04:00
ZacharyZhang-NY 996672a6a7 fix(cli): protect Go install credentials 2026-07-19 17:33:34 -04:00
ZacharyZhang-NY 3488ab778c fix(cli): validate runtimes before uninstall 2026-07-19 16:40:05 -04:00
ZacharyZhang-NY 7bf84a35e0 fix(cli): confine agent runtime paths 2026-07-19 16:26:00 -04:00
ZacharyZhang-NY c9d1075585 fix: align agent registration contract 2026-07-19 14:16:49 -04:00
ZacharyZhang-NY eae9015a61 feat: add native Qwen Code hook integration 2026-07-19 13:14:57 -04:00
ZacharyZhang-NY 577e3609b5 feat: add native Factory Droid hook integration 2026-07-19 11:47:30 -04:00
ZacharyZhang-NY 2eff1ae119 feat: add native Cline hook integration 2026-07-19 10:19:29 -04:00
ZacharyZhang-NY 9d03a68477 feat: add native Auggie hook integration 2026-07-19 08:50:01 -04:00
ZacharyZhang-NY a0fe657959 feat: add native Grok hook integration 2026-07-19 07:50:22 -04:00
ZacharyZhang-NY 287322977b feat: add native Kimi hook integration 2026-07-19 07:02:04 -04:00
ZacharyZhang-NY dfddb02f66 feat: add native Codex hook integration 2026-07-19 05:48:24 -04:00
ZacharyZhang-NY 758d129dbf feat: support Kiro CLI v2 and v3 hooks 2026-07-19 04:58:54 -04:00
ZacharyZhang-NY 598b0bd2bb fix: update OpenCode plugin integration 2026-07-19 01:58:29 -04:00
ZacharyZhang-NY 0234652963 fix: enforce frozen agent hook blocking 2026-07-19 01:44:55 -04:00
ZacharyZhang-NYandClaude Opus 4.6 a643ead036 Bump version to 1.2.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:52:32 -04:00
ZacharyZhang-NYandClaude Opus 4.6 99b0948da9 Deprecate legacy Login/Register, add admin events, members, webhooks
- Mark Register() and Login() as Deprecated (use Better Auth directly)
- Add admin_events.go, members.go, webhooks.go to match backend API
- Sync types with backend changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:48:39 -04:00
ZacharyZhang-NYandClaude Opus 4.6 75c2095f30 Update auth references from JWT to session tokens
- Remove PreviousTokenGraceUntil from RotateApiTokenResponse
- Update comments and CLI flag descriptions
- Remove grace period test from integration test
- Update README

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:13:37 -04:00
ZacharyZhang-NYandClaude Opus 4.6 3683bd28dc Sync types and operations with backend for social auth and agent assignments
Adds SocialAuthProvider, AgentAssignment types, and integration compat test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:42:38 -04:00
236 changed files with 41067 additions and 2167 deletions

No files matched your search

+1
View File
@@ -31,3 +31,4 @@ Thumbs.db
.env.local
*.pem
*.key
/elydora
+91
View File
@@ -0,0 +1,91 @@
# Elydora Go SDK Engineering Contract
## Scope
This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing behavior, generated Node.js hook runtimes, and Go-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 adapter delivery claims aligned with executable tests in this repository.
- Synchronize completed provider behavior into the Node SDK, Python 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, flush it, and atomically replace the target.
- Resolve and quote the Node.js runtime 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 both generated runtime scripts and both scripts exist.
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default and canonicalize configured existing directories. Preserve additive TOML, project, plugin, and managed sources. Register exact `PreToolUse` and `PostToolUse` match-all command groups, retain the native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, and commit user hooks plus all four runtime artifacts through one rollback-capable transaction. Require `/hooks` approval for both definition hashes.
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
- Select Kimi Code from an explicit `KIMI_CODE_HOME` or an existing `~/.kimi-code` home, and select legacy `kimi-cli` from an existing `~/.kimi` home. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; executable lookup is outside the activation contract, and equal stable and legacy config paths collapse to the stable contract.
- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. Own only exact three-field hooks for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; require one complete triple per runtime identity, use encoded PowerShell commands on Windows, and retain the native snake_case payload.
- Generate Kimi guard, config, private key, and audit runtimes together with every selected TOML document in one rollback-capable transaction. Preflight physical paths and existing runtime identity before creating state; status validates the complete runtime metadata, canonical private key, exact agent identity, and non-empty physical scripts.
- Write Claude Code hooks only to `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` as the default; keep project, local, managed, plugin, skill, and agent sources read-only. Validate the complete shipped handler schema, including command exec form, async rewake metadata, HTTP, MCP tool, prompt, and agent handlers. Preserve the native snake_case payload, own one exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple, propagate freeze and revocation through exit code `2`, and treat `disableAllHooks` as an explicit installation and health gate.
- Generate Claude Code guard, config, private key, and audit runtimes with user settings in one rollback-capable transaction. Validate the full official event and handler schema before writes, migrate only the exact prior Go command contract, require strict runtime identity for health, and direct users to `/hooks` plus `claude doctor` for effective-source verification.
- Write Gemini CLI hooks only to `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` as the default. Keep workspace, system defaults, system override, and extension settings read-only. Preserve JSONC comments and line endings, reject trailing commas and duplicate keys, validate the official event and command-handler schema, and treat `hooksConfig.enabled` plus `hooksConfig.disabled` as installation and health gates.
- Own one exact matchless `BeforeTool`/`AfterTool` pair named `elydora-guard` and `elydora-audit` with 10,000 millisecond timeouts. Generate guard, config, private key, and native-payload audit runtimes with user settings in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit `{}` for successful hook execution, propagate frozen and revoked agents through exit code `2`, require strict runtime identity for health, and direct users to `/hooks list` for effective-source verification.
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat project `.grok/hooks`, plugin hooks, Claude Code, and Cursor compatibility files as read-only integration sources. Own only exact matchless three-field command groups for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; accept official non-negative integer timeouts and string-valued environments; reject lifecycle matchers; and preserve native camelCase payloads.
- Generate Grok guard, config, private key, and audit runtimes with the user hook document in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit native deny JSON with exit code `2`, preflight physical paths and runtime identity, and require one exact event triple plus strict runtime metadata and a canonical private key for healthy status.
- 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`.
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from matcher validation so recovery remains available offline. 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`.
- Cline CLI 3.0.46 discovers exact `PreToolUse` and `PostToolUse` file basenames across `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, workspace `.clinerules/hooks`, and workspace `.cline/hooks`; Elydora owns the `$CLINE_DIR/hooks` pair and keeps the other roots read-only. Commit both wrappers, generated guard and audit runtimes, strict config, and canonical private key through one rollback-capable transaction. Preserve official input byte-for-byte, emit pure JSON cancellation control for guard exit code `2`, reject linked managed paths, and require exact generated sources plus runtime identity for healthy status. Cline surfaces hook errors and continues the task, so wrapper stderr and runtime `error.log` are operational evidence.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Keep repository, Claude-compatible, inline, plugin, and policy hook sources additive; migrate only exact Elydora-owned entries from project `.github/hooks/hooks.json`. Resolve `disableAllHooks` through legacy user config, user settings, Claude repository settings, and Copilot repository settings in official precedence order; settings accept JSONC and trailing commas, while hook documents remain strict JSON.
- Validate the complete GitHub Copilot CLI event and command, HTTP, and prompt handler schema before writes, then validate matchers with Node.js regular-expression semantics during install. Own one exact `preToolUse`, `postToolUse`, and `postToolUseFailure` triple, preserve native camelCase payloads, and propagate freeze and revocation through exit code `2`. Generate guard, config, private key, and audit runtimes with both hook documents in one rollback-capable transaction guarded by read-only settings snapshots. Healthy status requires the enabled three-event contract, strict runtime identity, canonical private key, and exact generated runtime sources.
- Resolve Kiro IDE 1.0 hooks through the active workspace `.kiro/hooks/elydora-audit.json`. Preserve unrelated workspace hooks, validate the strict `version: "v1"` schema, and own one exact enabled `PreToolUse` guard plus `PostToolUse` audit pair with `.*` matchers and ten-second timeouts. Use encoded PowerShell commands on Windows, preserve Kiro's complete native payload, and propagate frozen or revoked state through exit code `2`.
- Treat `~/.kiro/hooks/elydora-audit.kiro.hook` solely as exact legacy Go adapter migration input. Commit its removal, workspace hooks, guard, strict runtime config, canonical private key, and native-payload audit runtime through one rollback-capable transaction with unchanged-source preconditions. Healthy status requires a unique exact pair, physical workspace and runtime paths, matching runtime identity, canonical private key, and exact generated runtime sources; verify both entries in Kiro's Agent Hooks panel.
- Factory Droid `droid@0.175.0` selects one complete user hook source in this order: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Write the current root `{ "hooks": { ... } }` container, accept unknown future event arrays, preserve JSONC syntax, and remove exact Elydora entries from every discovered user source during migration or uninstall.
- Resolve Factory `hooksDisabled` through system, project, nested-folder, and matching `settings.local.json` extension-only precedence before user settings. Treat system `allowManagedHooksOnly` as an installation gate, snapshot every read-only policy layer as a transaction precondition, and direct users to `/hooks` for local and organization-managed review.
- Generate Factory Droid guard, strict config, canonical private key, and native-payload audit runtime with every affected hook document in one rollback-capable transaction. Windows commands use Factory's native PowerShell contract with explicit `$LASTEXITCODE` propagation. Healthy status requires one exact current `PreToolUse`/`PostToolUse` pair, strict runtime identity, physical files, and exact generated runtime sources.
- Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default. Follow the official `QWEN_HOME` and `QWEN_RUNTIME_DIR` `.env` bootstrap, then resolve system defaults, user, trusted workspace, and system override settings in precedence order. Keep every non-user settings source and `trustedFolders.json` read-only and guard each source snapshot through the write transaction.
- Preserve JSONC comments, line endings, and unknown future hook fields. Reject trailing commas and duplicate keys, validate all 21 current events plus command, HTTP, and prompt handlers, reject settings-defined function handlers, and apply Node.js `RegExp` semantics only to the 15 matcher-enabled events.
- Own one exact named matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple with 10,000 millisecond timeouts. Generate guard, strict runtime config, canonical private key, and native-payload audit runtime with user settings in one rollback-capable transaction. Propagate frozen and revoked agents through exit code `2`; require effective hooks to be enabled, one exact triple, physical runtime files, strict identity, and exact generated sources for healthy status; direct users to `/hooks` for review.
## Shared Modules
- `cmd/elydora/plugins/managed_runtime.go` owns install-config validation, managed runtime paths, runtime config validation, runtime identity checks, and the strict and presence-only status contracts. `managed_changes.go` owns runtime config encoding, the four runtime file changes, and the shared directory-plus-commit writer.
- `cmd/elydora/plugins/shell_command.go` owns Node.js resolution, POSIX and PowerShell quoting, encoded Windows commands, the shell and encoded command builders and parsers, and the pre-2.1 quoted Windows parser.
- `cmd/elydora/plugins/jsonc.go` owns strict JSON object decoding, JSONC editing, and `cloneJSONObject`; `managed_files.go` owns physical file and directory inspection; `transaction*.go` owns the rollback-capable commit.
- Provider files keep only their hook document schema, ownership rules, rendering, and provider-specific preconditions. Add a provider-local helper only when the shared module cannot express the provider contract.
- `jsonutil.go` and `legacy_hook_entries.go` serve the Kiro CLI, Letta, and OpenCode adapters, which write settings without the managed transaction; moving them onto the managed model is a product decision.
- Recognizing pre-2.1 Elydora commands stays in place so `elydora install` replaces older entries during upgrades.
## Code Quality
- Preserve the minimum Go version declared in `go.mod`.
- Keep production source files at or below 500 lines.
- Keep functions focused on one ownership boundary.
- Return unexpected errors to the CLI boundary with operation and path context.
- Use documented defaults only for genuinely optional configuration.
- Avoid compatibility shims without a named public or user configuration contract.
- 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.
- Accept CLI install credentials through terminal-echo-disabled input or physical owner-only credential files. Keep credentials out of process arguments, reject legacy secret options with redacted errors, and require one UTF-8 line of at most 64 KiB.
- Commit runtime config, private key, and audit script through one rollback-capable transaction. Persist the signing key once with mode `0600`; generated runtimes validate physical identity, size, and Unix permissions before reading config or key material.
- Write the guard runtime through a flushed same-directory temporary file followed by atomic replacement. Surface permission, close, synchronization, commit, cleanup, and recovery failures.
- Read status cache, chain state, and error logs through physical 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.
## Verification
Run the focused adapter test during development, then execute all release gates before commit:
```sh
go test ./cmd/elydora/plugins -run <Provider> -count=1
go test ./...
go test -race ./...
go vet ./...
go build ./cmd/elydora
govulncheck ./...
git diff --check
```
The Linux test environment leaks provider config into `$HOME`; run the suite with `HOME` and `XDG_CONFIG_HOME` pointed at a scratch directory while `GOPATH`, `GOMODCACHE`, and `GOCACHE` keep their real values.
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.
+15
View File
@@ -0,0 +1,15 @@
# Contributing
## Opening a pull request on git.zacharyzhang.com
Forking is disabled on this server. Open pull requests with AGit instead:
1. Register an account at https://git.zacharyzhang.com
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
```bash
git clone https://git.zacharyzhang.com/Elydora-Infrastructure/Elydora-Go-SDK.git
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
```
3. To update the pull request, push again with the same `topic`.
+101 -35
View File
@@ -5,10 +5,12 @@ Official Go SDK for the [Elydora](https://elydora.com) tamper-evident audit plat
## Installation
```bash
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
```
Requires Go 1.21+. Zero third-party dependencies (stdlib only).
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
Upgrading from 2.0.x: run `elydora install` again for every agent. The generated runtimes changed, and `elydora status` does not detect stale runtimes for every adapter.
## Quick Start
@@ -19,22 +21,16 @@ import (
"fmt"
"log"
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
)
func main() {
// Authenticate
auth, err := elydora.Login("https://api.elydora.com", "user@example.com", "password")
if err != nil {
log.Fatal(err)
}
// Create client
// Create client with an API token issued in the Console
client, err := elydora.NewClient(&elydora.Config{
OrgID: auth.User.OrgID,
OrgID: "org-123",
AgentID: "my-agent-id",
PrivateKey: "<base64url-encoded-ed25519-seed>",
Token: auth.Token,
Token: "<api-token>",
})
if err != nil {
log.Fatal(err)
@@ -63,38 +59,79 @@ func main() {
The SDK includes a CLI for installing audit hooks into AI coding agents.
Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical directory/config checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
```bash
go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest
go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora@latest
elydora install \
--agent claudecode \
--org-id org-123 \
--agent-id agent-456 \
--private-key <key> \
--private-key-file /secure/path/private.key \
--token-file /secure/path/api.token \
--kid agent-456-key-v1
```
Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Provider settings, `guard.js`, `config.json`, `private.key`, and `hook.js` commit as one rollback-capable transaction, and the generated scripts validate protected files at execution time.
Durable managed-file transactions support Windows, Linux, and macOS. The CLI serializes writers across processes, records append-only recovery state under `~/.elydora/transactions`, stages transaction-owned files on each target filesystem, and completes pending recovery before accepting new writes. Windows requires protected owner-only DACLs, hard-link support, atomic `MoveFileEx` replacement, and writable directory handles with `FlushFileBuffers`; Linux filesystems require `renameat2` with `RENAME_NOREPLACE` and `RENAME_EXCHANGE`; macOS filesystems require `renamex_np` with `RENAME_EXCL` and `RENAME_SWAP`. Unsupported platforms and filesystems fail before managed targets change. Recovery and cleanup verify file and directory identities and retain changed or unexpected objects for manual recovery.
Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` exec-form hooks to `$CLAUDE_CONFIG_DIR/settings.json` (`~/.claude/settings.json` by default). Elydora preserves unrelated user settings, keeps project, local, managed, plugin, skill, and agent sources unchanged, forwards native snake_case payloads, and uses exit code `2` for frozen or revoked agents. Installation validates the complete official hook schema and commits settings with all four runtime artifacts in one rollback-capable transaction. Run `/hooks` and `claude doctor` after installation to inspect the effective hook sources.
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
Cursor installation writes native global `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json`. Elydora preserves unrelated user hooks, leaves project and enterprise sources unchanged, emits valid JSON hook responses, fails closed when enforcement or audit execution cannot complete, and commits its 10-second hook contract with all managed runtime files as one rollback-capable transaction.
Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` command handlers to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Elydora preserves JSONC comments, line endings, unrelated user hooks, and additive workspace, system, system-override, and extension sources. The user settings document and all four generated runtime artifacts commit in one rollback-capable transaction. Generated hooks retain Gemini's native snake_case payload, emit `{}` on successful or fail-open execution, surface runtime failures through stderr and `error.log`, and use exit code `2` for frozen or revoked agents. Installation honors `hooksConfig` controls and uses encoded PowerShell commands on Windows. Run `/hooks list` after installation to inspect the effective hook inventory.
GitHub Copilot CLI installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` user hooks to `$COPILOT_HOME/hooks/elydora-audit.json`, with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves additive hook sources, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, validates the official handler schema and JavaScript matcher syntax, resolves `disableAllHooks` through the CLI's settings precedence, and commits all runtime and hook files transactionally. The generated commands preserve native camelCase payloads and the guard propagates exit code `2`; restart active Copilot sessions after installation.
Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key.
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
Kiro IDE 1.0 installation writes an exact enabled `PreToolUse` guard and `PostToolUse` audit pair to the active workspace `.kiro/hooks/elydora-audit.json`. Both entries use Kiro's `version: "v1"` schema, `.*` matchers, ten-second timeouts, and platform-safe absolute commands. Elydora preserves unrelated workspace hooks, forwards the complete native event payload, and propagates frozen or revoked state through exit code `2`. Workspace hooks, exact legacy `~/.kiro/hooks/elydora-audit.kiro.hook` migration, generated runtimes, strict runtime metadata, and the canonical private key commit through one rollback-capable transaction. Confirm both entries in Kiro's Agent Hooks panel after installation.
Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal.
Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory.
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.
Factory Droid 0.175 installation follows its whole-source precedence: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Elydora writes the current `{ "hooks": { ... } }` root contract, preserves JSONC comments and formatting, accepts future hook events, and removes exact legacy Elydora commands during migration. System, project, and nested-folder settings remain read-only policy inputs; `hooksDisabled` and organization-managed hook policy block installation before runtime creation. The guard, native-payload audit runtime, strict config, canonical private key, and every affected hook document commit in one rollback-capable transaction. Windows commands use Factory's native PowerShell execution contract and propagate `$LASTEXITCODE`. Run `/hooks` in Droid after installation to review the effective local and organization-managed hooks.
Qwen Code `0.20.0` installation writes native user hooks to `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default. Elydora follows Qwen's `QWEN_HOME` and `QWEN_RUNTIME_DIR` `.env` bootstrap, reads system defaults, user, trusted workspace, and system override settings in official precedence order, and keeps every non-user source read-only. The adapter preserves JSONC formatting and future event payloads, validates all 21 current events plus command, HTTP, and prompt handlers, applies JavaScript regular-expression semantics only to matcher-enabled events, and owns one named matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple. Guard, native-payload audit, strict runtime metadata, canonical private key, and user settings commit in one rollback-capable transaction guarded by source snapshots. Healthy status requires the enabled exact triple and exact generated runtime sources. Run `/hooks` in Qwen Code after installation to review the effective hooks.
### Commands
| Command | Description |
|---------|-------------|
| `elydora install` | Install Elydora audit hook for a coding agent |
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
| `elydora status` | Show installation status for all agents |
| `elydora status` | Show installation status for one agent or all agents |
| `elydora agents` | List supported coding agents |
| `elydora version` | Print the CLI version |
### Supported Agents
| Agent | Key |
|-------|-----|
| Augment Code CLI | `augment` |
| Claude Code | `claudecode` |
| Copilot CLI | `copilot` |
| OpenAI Codex | `codex` |
| Cline | `cline` |
| GitHub Copilot CLI | `copilot` |
| Cursor | `cursor` |
| Factory Droid | `droid` |
| Gemini CLI | `gemini` |
| Grok Build | `grok` |
| Kiro CLI | `kirocli` |
| Kiro IDE | `kiroide` |
| Kimi Code | `kimi` |
| Letta Code | `letta` |
| OpenCode | `opencode` |
| Qwen Code | `qwen` |
## API Reference
@@ -108,38 +145,37 @@ client, err := elydora.NewClient(&elydora.Config{
BaseURL: "https://...", // API base URL (default: https://api.elydora.com)
TTLMs: 30000, // Operation TTL in ms (default: 30000)
MaxRetries: 3, // Max retries on transient failures (default: 3)
Token: "<jwt>", // JWT token for authenticated requests
Token: "<token>", // API token for authenticated requests
})
```
### Client Methods
```go
// Update the JWT token at runtime
client.SetToken("new-jwt-token")
// Update the API token at runtime
client.SetToken("new-api-token")
```
### Authentication
```go
// Register a new user and organization
reg, err := elydora.Register(baseURL, email, password,
elydora.WithDisplayName("Alice"),
elydora.WithOrgName("Acme Corp"),
)
// Login and receive a JWT
auth, err := elydora.Login(baseURL, email, password)
// Get current authenticated user profile
// Get the authenticated user and current organization
me, err := client.GetMe()
// me.User.Email, me.User.Role, me.CurrentOrganization.OrgID
// Issue a new API token (with optional TTL)
// Issue an API token; this call needs a client whose Token is a Console session token
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
TTLSeconds: &ttlSeconds,
})
client.SetToken(tokenResp.Token)
// Rotate the installed API token; the response carries the replacement
rotated, err := client.RotateApiToken()
client.SetToken(rotated.Token)
```
`elydora.Register` and `elydora.Login` remain for password accounts and are deprecated; use the Console sign-in flow.
### Operations
```go
@@ -170,6 +206,7 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
AgentID: "my-agent",
DisplayName: "My Agent",
ResponsibleEntity: "team@example.com",
IntegrationType: elydora.IntegrationTypeSDK,
Keys: []elydora.RegisterAgentKeyInput{
{KID: "key-v1", PublicKey: "<base64url>", Algorithm: "ed25519"},
},
@@ -178,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
// Get agent details
details, err := client.GetAgent(agentID)
// Change the integration type
updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode)
// Freeze an agent
err := client.FreezeAgent(agentID, "security review")
frozen, err := client.FreezeAgent(agentID, "security review")
// Revoke a key
err := client.RevokeKey(agentID, kid, "key rotation")
@@ -188,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation")
agents, err := client.ListAgents()
// Unfreeze a previously frozen agent
err := client.UnfreezeAgent(agentID, "review complete")
unfrozen, err := client.UnfreezeAgent(agentID, "review complete")
// Delete an agent permanently
deleted, err := client.DeleteAgent(agentID)
@@ -229,6 +269,25 @@ detail, err := client.GetExport(exportID)
data, err := client.DownloadExport(exportID)
```
### Webhooks
```go
webhook, err := client.RegisterWebhook(&elydora.RegisterWebhookRequest{
EndpointURL: "https://example.com/elydora",
Events: []string{"operation.accepted"},
Secret: "<signing-secret>",
})
webhooks, err := client.ListWebhooks()
err = client.DeleteWebhook(webhook.Webhook.WebhookID)
```
### Organization
```go
members, err := client.ListMembers()
events, err := client.ListAdminEvents(50)
```
### JWKS
```go
@@ -240,13 +299,17 @@ jwks, err := client.GetJWKS()
```go
// Check API health (no authentication required, does not need a client)
health, err := elydora.Health("https://api.elydora.com")
// health.Status, health.Version, health.ProtocolVersion, health.Timestamp
// health.Status, health.Version, health.ProtocolVersion, health.Capabilities, health.Timestamp
// Check dependency health (D1, auth storage, R2, KV)
deep, err := elydora.DeepHealth("https://api.elydora.com")
// deep.Dependencies.D1.Status, deep.Dependencies.KV.LatencyMs
```
### Constants
```go
// Genesis chain hash — initial prev_chain_hash for an agent's first operation
// Genesis chain hash: initial prev_chain_hash for an agent's first operation
elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
```
@@ -258,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` |
| `WebhookStatus` | `WebhookStatusActive`, `WebhookStatusDisabled` |
| `IntegrationType` | One constant per supported agent key plus `IntegrationTypeEnterprise`, `IntegrationTypeGUI`, `IntegrationTypeSDK`, `IntegrationTypeOther` |
| `AdminAction` | One constant per admin event action, for example `AdminActionAgentFreeze` |
| `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` |
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeAgentFrozen`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeKeyRetired`, `ErrorCodeAgentFrozen`, `ErrorCodeAgentRevoked`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
## Error Handling
+16
View File
@@ -0,0 +1,16 @@
package elydora
import "fmt"
// ListAdminEvents retrieves recent administrative events; limit 0 uses the server default.
func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) {
path := "/v1/admin/events"
if limit > 0 {
path = fmt.Sprintf("%s?limit=%d", path, limit)
}
var result ListAdminEventsResponse
if err := c.doGet(path, &result); err != nil {
return nil, err
}
return &result, nil
}
+29 -9
View File
@@ -3,6 +3,7 @@ package elydora
import (
"fmt"
"net/http"
"net/url"
)
// RegisterAgent registers a new AI agent within the organization.
@@ -17,22 +18,26 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons
// GetAgent retrieves an agent by ID.
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
var result GetAgentResponse
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", agentID), &result); err != nil {
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &result); err != nil {
return nil, err
}
return &result, nil
}
// FreezeAgent freezes an agent, preventing it from submitting operations.
func (c *Client) FreezeAgent(agentID, reason string) error {
return c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{
func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
var result FreezeAgentResponse
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{
Reason: reason,
}, nil)
}, &result); err != nil {
return nil, err
}
return &result, nil
}
// RevokeKey revokes a specific key for an agent.
func (c *Client) RevokeKey(agentID, kid, reason string) error {
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", agentID), &RevokeAgentRequest{
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", url.PathEscape(agentID)), &RevokeAgentRequest{
KID: kid,
Reason: reason,
}, nil)
@@ -48,16 +53,31 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) {
}
// UnfreezeAgent unfreezes a previously frozen agent.
func (c *Client) UnfreezeAgent(agentID, reason string) error {
return c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{
func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
var result UnfreezeAgentResponse
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{
Reason: reason,
}, nil)
}, &result); err != nil {
return nil, err
}
return &result, nil
}
// UpdateAgent updates an agent's integration type.
func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) {
var result UpdateAgentResponse
if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &UpdateAgentRequest{
IntegrationType: integrationType,
}, &result); err != nil {
return nil, err
}
return &result, nil
}
// DeleteAgent permanently deletes an agent and all associated data.
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
var result DeleteAgentResponse
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", agentID), nil, &result); err != nil {
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), nil, &result); err != nil {
return nil, err
}
return &result, nil
+22 -94
View File
@@ -1,128 +1,47 @@
package elydora
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
import "net/http"
// RegisterOption is a functional option for the Register function.
// RegisterOption configures a registration request.
type RegisterOption func(*AuthRegisterRequest)
// WithDisplayName sets the display_name on a registration request.
func WithDisplayName(name string) RegisterOption {
return func(req *AuthRegisterRequest) {
req.DisplayName = name
}
return func(req *AuthRegisterRequest) { req.DisplayName = name }
}
// WithOrgName sets the org_name on a registration request.
func WithOrgName(name string) RegisterOption {
return func(req *AuthRegisterRequest) {
req.OrgName = name
}
return func(req *AuthRegisterRequest) { req.OrgName = name }
}
// Register creates a new user and organization.
//
// Deprecated: use the Console sign-up flow; password registration is being phased out.
func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) {
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
req := &AuthRegisterRequest{
Email: email,
Password: password,
}
req := &AuthRegisterRequest{Email: email, Password: password}
for _, opt := range opts {
opt(req)
}
var result AuthRegisterResponse
if err := authPost(baseURL, "/v1/auth/register", req, &result); err != nil {
if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/register", req, &result); err != nil {
return nil, err
}
return &result, nil
}
// Login authenticates a user and returns a JWT token.
// Login authenticates a user and returns a session token.
//
// Deprecated: use the Console sign-in flow; password login is being phased out.
func Login(baseURL, email, password string) (*AuthLoginResponse, error) {
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
req := &AuthLoginRequest{
Email: email,
Password: password,
}
req := &AuthLoginRequest{Email: email, Password: password}
var result AuthLoginResponse
if err := authPost(baseURL, "/v1/auth/login", req, &result); err != nil {
if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/login", req, &result); err != nil {
return nil, err
}
return &result, nil
}
// authPost is a helper for unauthenticated POST requests used by Register and Login.
func authPost(baseURL, path string, body interface{}, result interface{}) error {
u := baseURL + path
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("elydora: marshal request body: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("elydora: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("elydora: http request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return fmt.Errorf("elydora: read response body: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("elydora: unmarshal response: %w", err)
}
}
return nil
}
var errResp errorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
return &ElydoraError{
StatusCode: resp.StatusCode,
Code: errResp.Error.Code,
Message: errResp.Error.Message,
RequestID: errResp.Error.RequestID,
Details: errResp.Error.Details,
}
}
return &ElydoraError{
StatusCode: resp.StatusCode,
Code: ErrorCodeInternalError,
Message: string(respBody),
}
}
// GetMe retrieves the current authenticated user's profile.
func (c *Client) GetMe() (*GetMeResponse, error) {
var result GetMeResponse
@@ -140,3 +59,12 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons
}
return &result, nil
}
// RotateApiToken rotates the current API token; the old token stays valid for 24h.
func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) {
var result RotateApiTokenResponse
if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil {
return nil, err
}
return &result, nil
}
+135 -73
View File
@@ -14,6 +14,8 @@ const (
defaultBaseURL = "https://api.elydora.com"
defaultTTLMs = 30000
defaultMaxRetries = 3
maxResponseBytes = 10 << 20
requestTimeout = 30 * time.Second
)
// Config holds the configuration for creating a new Client.
@@ -24,7 +26,7 @@ type Config struct {
BaseURL string // defaults to "https://api.elydora.com"
TTLMs int // defaults to 30000
MaxRetries int // defaults to 3
Token string // JWT token for authenticated requests
Token string // API token for authenticated requests
}
// Client is the Elydora SDK client.
@@ -44,135 +46,195 @@ func NewClient(cfg *Config) (*Client, error) {
if cfg == nil {
return nil, fmt.Errorf("elydora: config must not be nil")
}
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
ttlMs := cfg.TTLMs
if ttlMs <= 0 {
ttlMs = defaultTTLMs
}
maxRetries := cfg.MaxRetries
if maxRetries <= 0 {
maxRetries = defaultMaxRetries
}
return &Client{
orgID: cfg.OrgID,
agentID: cfg.AgentID,
privateKey: cfg.PrivateKey,
baseURL: baseURL,
baseURL: normalizeBaseURL(cfg.BaseURL),
ttlMs: ttlMs,
maxRetries: maxRetries,
token: cfg.Token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
httpClient: &http.Client{Timeout: requestTimeout},
}, nil
}
// doRequest executes an HTTP request with retries and error handling.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
u := c.baseURL + path
// SetToken sets the API token used for authenticated API requests.
func (c *Client) SetToken(token string) {
c.token = token
}
var bodyReader io.Reader
func normalizeBaseURL(baseURL string) string {
if baseURL == "" {
baseURL = defaultBaseURL
}
return strings.TrimRight(baseURL, "/")
}
func newRequest(method, url string, body []byte, token, accept string) (*http.Request, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, fmt.Errorf("elydora: create request: %w", err)
}
if accept != "" {
req.Header.Set("Accept", accept)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req, nil
}
func marshalBody(body interface{}) ([]byte, error) {
if body == nil {
return nil, nil
}
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("elydora: marshal request body: %w", err)
return nil, fmt.Errorf("elydora: marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
return data, nil
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// Reset body reader for retry
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
func readBody(resp *http.Response, limit int64) ([]byte, error) {
defer resp.Body.Close()
var reader io.Reader = resp.Body
if limit > 0 {
reader = io.LimitReader(resp.Body, limit)
}
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
}
req, err := http.NewRequest(method, u, bodyReader)
body, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("elydora: create request: %w", err)
return nil, fmt.Errorf("elydora: read response body: %w", err)
}
return body, nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
func decodeSuccess(body []byte, result interface{}) error {
if result == nil {
return nil
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("elydora: http request: %w", err)
continue
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("elydora: read response body: %w", err)
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("elydora: unmarshal response: %w", err)
}
}
return nil
}
// Parse error response
func apiError(status int, body []byte) *ElydoraError {
var errResp errorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
apiErr := &ElydoraError{
StatusCode: resp.StatusCode,
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Code != "" {
return &ElydoraError{
StatusCode: status,
Code: errResp.Error.Code,
Message: errResp.Error.Message,
RequestID: errResp.Error.RequestID,
Details: errResp.Error.Details,
}
// Don't retry 4xx errors (except 429)
if resp.StatusCode < 500 && resp.StatusCode != 429 {
return apiErr
}
lastErr = apiErr
return &ElydoraError{StatusCode: status, Code: ErrorCodeInternalError, Message: string(body)}
}
func retryable(status int) bool {
return status >= 500 || status == http.StatusTooManyRequests
}
// A lost response to a non-idempotent request must not be replayed.
func idempotent(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete:
return true
}
return false
}
// doRequest executes an authenticated request; only idempotent methods retry.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
data, err := marshalBody(body)
if err != nil {
return err
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
}
req, err := newRequest(method, c.baseURL+path, data, c.token, "application/json")
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("elydora: http request: %w", err)
if !idempotent(method) {
return lastErr
}
continue
}
lastErr = &ElydoraError{
StatusCode: resp.StatusCode,
Code: ErrorCodeInternalError,
Message: string(respBody),
respBody, err := readBody(resp, maxResponseBytes)
if err != nil {
lastErr = err
if !idempotent(method) {
return lastErr
}
if resp.StatusCode < 500 && resp.StatusCode != 429 {
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return decodeSuccess(respBody, result)
}
lastErr = apiError(resp.StatusCode, respBody)
if !retryable(resp.StatusCode) || !idempotent(method) {
return lastErr
}
}
return lastErr
}
// doGet performs a GET request.
func (c *Client) doGet(path string, result interface{}) error {
return c.doRequest(http.MethodGet, path, nil, result)
}
// doPost performs a POST request.
func (c *Client) doPost(path string, body interface{}, result interface{}) error {
return c.doRequest(http.MethodPost, path, body, result)
}
// SetToken sets the JWT token used for authenticated API requests.
func (c *Client) SetToken(token string) {
c.token = token
// publicRequest sends one unauthenticated request and decodes accepted statuses.
func publicRequest(method, url string, body interface{}, result interface{}, accepted ...int) error {
data, err := marshalBody(body)
if err != nil {
return err
}
req, err := newRequest(method, url, data, "", "application/json")
if err != nil {
return err
}
resp, err := (&http.Client{Timeout: requestTimeout}).Do(req)
if err != nil {
return fmt.Errorf("elydora: http request: %w", err)
}
respBody, err := readBody(resp, maxResponseBytes)
if err != nil {
return err
}
for _, status := range accepted {
if resp.StatusCode == status {
return decodeSuccess(respBody, result)
}
}
if len(accepted) == 0 && resp.StatusCode >= 200 && resp.StatusCode < 300 {
return decodeSuccess(respBody, result)
}
return apiError(resp.StatusCode, respBody)
}
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestAugmentCLIPreflightPreservesMalformedSettings(t *testing.T) {
homeDir := filepath.Join(t.TempDir(), "home")
settingsPath := filepath.Join(homeDir, ".augment", "settings.json")
keyPath := filepath.Join(t.TempDir(), "private.key")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0700); err != nil {
t.Fatalf("create Auggie settings directory: %v", err)
}
if err := os.WriteFile(settingsPath, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("write malformed Auggie settings: %v", err)
}
privateKey := "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
if err := os.WriteFile(keyPath, []byte(privateKey+"\n"), 0600); err != nil {
t.Fatalf("write private key: %v", err)
}
command := exec.Command(
"go",
"run",
".",
"install",
"--agent",
"augment",
"--org-id",
"org-1",
"--agent-id",
"agent-1",
"--private-key-file",
keyPath,
"--kid",
"kid-1",
"--base-url",
"https://api.elydora.com",
)
command.Env = append(
os.Environ(),
"HOME="+homeDir,
"USERPROFILE="+homeDir,
)
output, err := command.CombinedOutput()
if err == nil || !strings.Contains(
string(output),
"parse Auggie user settings",
) {
t.Fatalf("CLI preflight result = %v\n%s", err, output)
}
raw, readErr := os.ReadFile(settingsPath)
if readErr != nil || string(raw) != "{ malformed" {
t.Fatalf("malformed settings changed: %q, %v", raw, readErr)
}
if _, statErr := os.Lstat(filepath.Join(homeDir, ".elydora")); !errors.Is(
statErr,
os.ErrNotExist,
) {
t.Fatalf("runtime directory created during failed preflight: %v", statErr)
}
}
+229
View File
@@ -0,0 +1,229 @@
package main
import (
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"runtime"
"strings"
"unicode/utf8"
)
const maxSecretFileBytes = 64 * 1024
type installSecretSources struct {
privateKeyFile string
tokenFile string
}
type installSecrets struct {
privateKey string
token string
}
type secretTerminal interface {
Interactive() bool
ReadHidden(prompt string) (string, error)
}
type standardSecretTerminal struct {
input *os.File
output *os.File
}
func newStandardSecretTerminal() standardSecretTerminal {
return standardSecretTerminal{input: os.Stdin, output: os.Stderr}
}
func (terminal standardSecretTerminal) Interactive() bool {
return isInteractiveTerminal(terminal.input) &&
isInteractiveTerminal(terminal.output)
}
func (terminal standardSecretTerminal) ReadHidden(prompt string) (string, error) {
if !terminal.Interactive() {
return "", errors.New("hidden input requires an interactive terminal")
}
if _, err := fmt.Fprint(terminal.output, prompt); err != nil {
return "", fmt.Errorf("write secret prompt: %w", err)
}
raw, readErr := readHiddenLine(terminal.input)
_, newlineErr := fmt.Fprintln(terminal.output)
if readErr != nil {
clear(raw)
return "", errors.Join(
fmt.Errorf("read hidden input: %w", readErr),
newlineErr,
)
}
if newlineErr != nil {
clear(raw)
return "", fmt.Errorf("finish secret prompt: %w", newlineErr)
}
value := string(raw)
clear(raw)
return value, nil
}
func parseSingleLineSecret(raw, label string, allowEmpty bool) (string, error) {
if len(raw) > maxSecretFileBytes {
return "", fmt.Errorf("%s exceeds %d bytes", label, maxSecretFileBytes)
}
if !utf8.ValidString(raw) {
return "", fmt.Errorf("%s must contain UTF-8 text", label)
}
value := raw
if strings.HasSuffix(value, "\r\n") {
value = strings.TrimSuffix(value, "\r\n")
} else if strings.HasSuffix(value, "\n") {
value = strings.TrimSuffix(value, "\n")
}
if !allowEmpty && value == "" {
return "", fmt.Errorf("%s is empty", label)
}
if strings.ContainsAny(value, "\r\n\x00") {
return "", fmt.Errorf("%s must contain exactly one line", label)
}
return value, nil
}
func validateSecretFile(info os.FileInfo, path, label string) error {
if !info.Mode().IsRegular() {
return fmt.Errorf("%s file is not a regular file: %s", label, path)
}
if info.Size() > maxSecretFileBytes {
return fmt.Errorf("%s file exceeds %d bytes: %s", label, maxSecretFileBytes, path)
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 {
return fmt.Errorf("%s file must be accessible only by its owner: %s", label, path)
}
return nil
}
func readSecretFile(path, label string) (string, error) {
return readSecretFileWithOpen(path, label, os.Open)
}
func readSecretFileWithOpen(
path string,
label string,
openFile func(string) (*os.File, error),
) (value string, returnErr error) {
before, err := os.Lstat(path)
if err != nil {
return "", fmt.Errorf("inspect %s file at %s: %w", label, path, err)
}
if err := validateSecretFile(before, path, label); err != nil {
return "", err
}
file, err := openFile(path)
if err != nil {
return "", fmt.Errorf("open %s file at %s: %w", label, path, err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
returnErr = errors.Join(
returnErr,
fmt.Errorf("close %s file at %s: %w", label, path, closeErr),
)
}
}()
after, err := file.Stat()
if err != nil {
return "", fmt.Errorf("inspect open %s file at %s: %w", label, path, err)
}
if err := validateSecretFile(after, path, label); err != nil {
return "", err
}
if !os.SameFile(before, after) {
return "", fmt.Errorf("%s file changed while opening: %s", label, path)
}
raw, err := io.ReadAll(io.LimitReader(file, maxSecretFileBytes+1))
if err != nil {
return "", fmt.Errorf("read %s file at %s: %w", label, path, err)
}
defer clear(raw)
if len(raw) > maxSecretFileBytes {
return "", fmt.Errorf("%s file exceeds %d bytes: %s", label, maxSecretFileBytes, path)
}
return parseSingleLineSecret(string(raw), label, false)
}
func resolveInstallSecrets(
sources installSecretSources,
terminal secretTerminal,
) (installSecrets, error) {
var privateKey string
var err error
if sources.privateKeyFile != "" {
privateKey, err = readSecretFile(sources.privateKeyFile, "private key")
} else if terminal.Interactive() {
privateKey, err = terminal.ReadHidden("Private key: ")
if err == nil {
privateKey, err = parseSingleLineSecret(privateKey, "private key", false)
}
} else {
return installSecrets{}, errors.New(
"private key input requires an interactive terminal or --private-key-file <path>",
)
}
if err != nil {
return installSecrets{}, err
}
var token string
if sources.tokenFile != "" {
token, err = readSecretFile(sources.tokenFile, "API token")
} else if terminal.Interactive() {
token, err = terminal.ReadHidden("API token (optional): ")
if err == nil {
token, err = parseSingleLineSecret(token, "API token", true)
}
}
if err != nil {
return installSecrets{}, err
}
return installSecrets{privateKey: privateKey, token: token}, nil
}
func validatePrivateKey(privateKey string) error {
seed, err := base64.RawURLEncoding.DecodeString(privateKey)
if err != nil {
return fmt.Errorf("decode private key: %w", err)
}
defer clear(seed)
if len(seed) != ed25519.SeedSize {
return fmt.Errorf(
"private key seed must be %d bytes, got %d",
ed25519.SeedSize,
len(seed),
)
}
return nil
}
func rejectLegacySecretArguments(arguments []string) error {
legacyOptions := map[string]string{
"--private-key": "--private-key-file",
"-private-key": "--private-key-file",
"--token": "--token-file",
"-token": "--token-file",
}
for _, argument := range arguments {
option := strings.SplitN(argument, "=", 2)[0]
if replacement, exists := legacyOptions[option]; exists {
return fmt.Errorf(
"%s exposes credentials in process arguments; use %s or hidden terminal input",
option,
replacement,
)
}
}
return nil
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"bytes"
"encoding/base64"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
const testAPIToken = "ely_test_token"
type fakeSecretTerminal struct {
interactive bool
answers []string
prompts []string
}
func (terminal *fakeSecretTerminal) Interactive() bool {
return terminal.interactive
}
func (terminal *fakeSecretTerminal) ReadHidden(prompt string) (string, error) {
terminal.prompts = append(terminal.prompts, prompt)
if len(terminal.answers) == 0 {
return "", os.ErrInvalid
}
answer := terminal.answers[0]
terminal.answers = terminal.answers[1:]
return answer, nil
}
func testPrivateKey() string {
return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
}
func writeSecretFile(t *testing.T, path, value string) {
t.Helper()
if err := os.WriteFile(path, []byte(value), 0600); err != nil {
t.Fatalf("write secret file: %v", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(path, 0600); err != nil {
t.Fatalf("restrict secret file: %v", err)
}
}
}
func TestResolveInstallSecretsUsesHiddenPrompts(t *testing.T) {
terminal := &fakeSecretTerminal{
interactive: true,
answers: []string{testPrivateKey(), testAPIToken},
}
secrets, err := resolveInstallSecrets(installSecretSources{}, terminal)
if err != nil {
t.Fatalf("resolve install secrets: %v", err)
}
if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken {
t.Fatalf("resolved secrets have unexpected values")
}
wantPrompts := []string{"Private key: ", "API token (optional): "}
if strings.Join(terminal.prompts, "|") != strings.Join(wantPrompts, "|") {
t.Fatalf("prompts = %q, want %q", terminal.prompts, wantPrompts)
}
}
func TestResolveInstallSecretsRequiresFileWhenNoninteractive(t *testing.T) {
terminal := &fakeSecretTerminal{interactive: false}
_, err := resolveInstallSecrets(installSecretSources{}, terminal)
if err == nil || !strings.Contains(err.Error(), "--private-key-file <path>") {
t.Fatalf("resolve install secrets error = %v", err)
}
if len(terminal.prompts) != 0 {
t.Fatalf("unexpected prompts: %q", terminal.prompts)
}
}
func TestResolveInstallSecretsReadsCredentialFiles(t *testing.T) {
directory := t.TempDir()
privateKeyPath := filepath.Join(directory, "private.key")
tokenPath := filepath.Join(directory, "token")
writeSecretFile(t, privateKeyPath, testPrivateKey()+"\r\n")
writeSecretFile(t, tokenPath, testAPIToken+"\n")
secrets, err := resolveInstallSecrets(installSecretSources{
privateKeyFile: privateKeyPath,
tokenFile: tokenPath,
}, &fakeSecretTerminal{})
if err != nil {
t.Fatalf("resolve install secrets: %v", err)
}
if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken {
t.Fatalf("resolved secrets have unexpected values")
}
}
func TestReadSecretFileRejectsInvalidContent(t *testing.T) {
tests := []struct {
name string
content string
pattern string
}{
{name: "empty", content: "", pattern: "is empty"},
{name: "multiple lines", content: testPrivateKey() + "\nsecond\n", pattern: "one line"},
{name: "nul", content: "key\x00value", pattern: "one line"},
{name: "invalid UTF-8", content: string([]byte{0xff}), pattern: "UTF-8"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "private.key")
writeSecretFile(t, path, test.content)
_, err := readSecretFile(path, "private key")
if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("read secret file error = %v", err)
}
})
}
}
func TestReadSecretFileRejectsOversizedContent(t *testing.T) {
path := filepath.Join(t.TempDir(), "private.key")
writeSecretFile(t, path, strings.Repeat("a", maxSecretFileBytes+1))
_, err := readSecretFile(path, "private key")
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("read secret file error = %v", err)
}
}
func TestReadSecretFileRejectsSymbolicLink(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "target.key")
link := filepath.Join(directory, "linked.key")
writeSecretFile(t, target, testPrivateKey())
if err := os.Symlink(target, link); err != nil {
t.Skipf("file symbolic links unavailable: %v", err)
}
_, err := readSecretFile(link, "private key")
if err == nil || !strings.Contains(err.Error(), "regular file") {
t.Fatalf("read secret file error = %v", err)
}
}
func TestReadSecretFileRequiresOwnerOnlyPermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permission contract")
}
path := filepath.Join(t.TempDir(), "private.key")
writeSecretFile(t, path, testPrivateKey())
if err := os.Chmod(path, 0640); err != nil {
t.Fatalf("widen secret file permissions: %v", err)
}
_, err := readSecretFile(path, "private key")
if err == nil || !strings.Contains(err.Error(), "only by its owner") {
t.Fatalf("read secret file error = %v", err)
}
}
func TestReadSecretFileDetectsReplacementWhileOpening(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "private.key")
replacement := filepath.Join(directory, "replacement.key")
writeSecretFile(t, path, testPrivateKey())
writeSecretFile(t, replacement, testPrivateKey())
_, err := readSecretFileWithOpen(path, "private key", func(name string) (*os.File, error) {
if removeErr := os.Remove(path); removeErr != nil {
return nil, removeErr
}
if renameErr := os.Rename(replacement, path); renameErr != nil {
return nil, renameErr
}
return os.Open(name)
})
if err == nil || !strings.Contains(err.Error(), "changed while opening") {
t.Fatalf("read secret file error = %v", err)
}
}
func TestValidatePrivateKeyRejectsInvalidSeeds(t *testing.T) {
tests := []string{"invalid!", base64.RawURLEncoding.EncodeToString([]byte("short"))}
for _, privateKey := range tests {
if err := validatePrivateKey(privateKey); err == nil {
t.Fatalf("validatePrivateKey(%q) succeeded", privateKey)
}
}
if err := validatePrivateKey(testPrivateKey()); err != nil {
t.Fatalf("validate private key: %v", err)
}
}
func TestRejectLegacySecretArgumentsRedactsValues(t *testing.T) {
tests := [][]string{
{"--private-key", "must-not-appear"},
{"--private-key=must-not-appear"},
{"--token", "must-not-appear"},
{"--token=must-not-appear"},
}
for _, arguments := range tests {
err := rejectLegacySecretArguments(arguments)
if err == nil {
t.Fatalf("rejectLegacySecretArguments(%q) succeeded", arguments)
}
if strings.Contains(err.Error(), "must-not-appear") {
t.Fatalf("legacy secret value leaked in error: %v", err)
}
if !strings.Contains(err.Error(), "-file") {
t.Fatalf("replacement option missing from error: %v", err)
}
}
}
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
const clineCLIPrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
func clineCLIRepository(t *testing.T) string {
t.Helper()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve Cline CLI test source path")
}
return filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", ".."))
}
func buildClineCLIBinary(t *testing.T) string {
t.Helper()
binary := filepath.Join(t.TempDir(), "elydora")
if runtime.GOOS == "windows" {
binary += ".exe"
}
build := exec.Command("go", "build", "-o", binary, "./cmd/elydora")
build.Dir = clineCLIRepository(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
}
return binary
}
func runClineCLI(
t *testing.T,
binary string,
workingDirectory string,
environment []string,
args ...string,
) (string, string, int) {
t.Helper()
process := exec.Command(binary, args...)
process.Dir = workingDirectory
process.Env = append(os.Environ(), environment...)
var stdout, stderr strings.Builder
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return stdout.String(), stderr.String(), 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return stdout.String(), stderr.String(), exitError.ExitCode()
}
t.Fatalf("run Elydora CLI: %v", err)
return "", "", -1
}
func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string) {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home")
preparePrivateCLITestHome(t, homeDir)
clineDir := filepath.Join(root, "cline-home")
workspace := filepath.Join(root, "workspace")
if err := os.MkdirAll(workspace, 0700); err != nil {
t.Fatalf("create Cline CLI workspace: %v", err)
}
environment := []string{
"HOME=" + homeDir,
"USERPROFILE=" + homeDir,
"CLINE_DIR=" + clineDir,
}
return homeDir, clineDir, workspace, environment
}
func writeClineCLISecret(t *testing.T, path, value string) {
t.Helper()
if err := os.WriteFile(path, []byte(value+"\n"), 0600); err != nil {
t.Fatalf("write Cline CLI secret: %v", err)
}
}
func TestClineCLIPreflightPreservesCollisionBeforeRuntimeCreation(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, clineDir, workspace, environment := prepareClineCLIEnvironment(t)
hooksDir := filepath.Join(clineDir, "hooks")
auditPath := filepath.Join(hooksDir, "PostToolUse.mjs")
if err := os.MkdirAll(hooksDir, 0700); err != nil {
t.Fatalf("create Cline hooks directory: %v", err)
}
original := "// user PostToolUse hook\n"
if err := os.WriteFile(auditPath, []byte(original), 0600); err != nil {
t.Fatalf("write user Cline hook: %v", err)
}
keyPath := filepath.Join(t.TempDir(), "private.key")
writeClineCLISecret(t, keyPath, clineCLIPrivateKey)
_, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "cline",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--kid", "kid-1",
"--base-url", "https://api.elydora.test",
)
if exit == 0 || !strings.Contains(stderr, "owned by another integration") {
t.Fatalf("CLI preflight = %d, %q", exit, stderr)
}
raw, err := os.ReadFile(auditPath)
if err != nil || string(raw) != original {
t.Fatalf("user Cline hook changed: %q, %v", raw, err)
}
if _, err := os.Lstat(filepath.Join(homeDir, ".elydora")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime directory created during failed preflight: %v", err)
}
if _, err := os.Lstat(filepath.Join(hooksDir, "PreToolUse.mjs")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("guard hook created during failed preflight: %v", err)
}
}
func TestClineCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, clineDir, workspace, environment := prepareClineCLIEnvironment(t)
keyPath := filepath.Join(t.TempDir(), "private.key")
tokenPath := filepath.Join(t.TempDir(), "token")
writeClineCLISecret(t, keyPath, clineCLIPrivateKey)
writeClineCLISecret(t, tokenPath, "token-1")
stdout, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "cline",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--token-file", tokenPath,
"--kid", "kid-1",
"--base-url", "http://127.0.0.1:9",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Cline") {
t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr)
}
agentDir := filepath.Join(homeDir, ".elydora", "agent-1")
for _, path := range []string{
filepath.Join(agentDir, "guard.js"),
filepath.Join(agentDir, "config.json"),
filepath.Join(agentDir, "private.key"),
filepath.Join(agentDir, "hook.js"),
filepath.Join(clineDir, "hooks", "PreToolUse.mjs"),
filepath.Join(clineDir, "hooks", "PostToolUse.mjs"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("installed file missing at %s: %v", path, err)
}
}
stdout, stderr, exit = runClineCLI(
t, binary, workspace, environment, "status", "--agent", "cline",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr)
}
stdout, stderr, exit = runClineCLI(
t,
binary,
workspace,
environment,
"uninstall",
"--agent", "cline",
"--agent-id", "agent-1",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
t.Fatalf("CLI uninstall = %d, %q, %q", exit, stdout, stderr)
}
for _, path := range []string{
agentDir,
filepath.Join(clineDir, "hooks", "PreToolUse.mjs"),
filepath.Join(clineDir, "hooks", "PostToolUse.mjs"),
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed path remains at %s: %v", path, err)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
const droidCLIPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0"
func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
preparePrivateCLITestHome(t, homeDir)
workspace := filepath.Join(root, "workspace with spaces")
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
t.Fatalf("create Factory Droid CLI workspace: %v", err)
}
return homeDir, workspace, []string{
"HOME=" + homeDir,
"USERPROFILE=" + homeDir,
}
}
func TestDroidCLIPreflightBlocksPolicyBeforeRuntimeCreation(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, workspace, environment := prepareDroidCLIEnvironment(t)
settingsPath := filepath.Join(homeDir, ".factory", "settings.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0700); err != nil {
t.Fatalf("create Factory settings directory: %v", err)
}
original := "{\"hooksDisabled\":true}\n"
if err := os.WriteFile(settingsPath, []byte(original), 0600); err != nil {
t.Fatalf("write Factory settings: %v", err)
}
keyPath := filepath.Join(t.TempDir(), "private.key")
writeClineCLISecret(t, keyPath, droidCLIPrivateKey)
_, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "droid",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--kid", "kid-1",
"--base-url", "https://api.elydora.test",
)
if exit == 0 || !strings.Contains(stderr, "hooksDisabled") {
t.Fatalf("Factory Droid CLI preflight = %d, %q", exit, stderr)
}
raw, err := os.ReadFile(settingsPath)
if err != nil || string(raw) != original {
t.Fatalf("Factory settings changed: %q, %v", raw, err)
}
for _, path := range []string{
filepath.Join(homeDir, ".elydora"),
filepath.Join(homeDir, ".factory", "hooks.json"),
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("path created during failed preflight at %s: %v", path, err)
}
}
}
func TestDroidCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, workspace, environment := prepareDroidCLIEnvironment(t)
keyPath := filepath.Join(t.TempDir(), "private.key")
tokenPath := filepath.Join(t.TempDir(), "token")
writeClineCLISecret(t, keyPath, droidCLIPrivateKey)
writeClineCLISecret(t, tokenPath, "token-1")
stdout, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "droid",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--token-file", tokenPath,
"--kid", "kid-1",
"--base-url", "http://127.0.0.1:9",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Factory Droid") {
t.Fatalf("Factory Droid CLI install = %d, %q, %q", exit, stdout, stderr)
}
agentDir := filepath.Join(homeDir, ".elydora", "agent-1")
hooksPath := filepath.Join(homeDir, ".factory", "hooks.json")
for _, path := range []string{
filepath.Join(agentDir, "guard.js"),
filepath.Join(agentDir, "config.json"),
filepath.Join(agentDir, "private.key"),
filepath.Join(agentDir, "hook.js"),
hooksPath,
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("installed file missing at %s: %v", path, err)
}
}
hooks, err := os.ReadFile(hooksPath)
if err != nil || !strings.Contains(string(hooks), `"hooks"`) ||
!strings.Contains(string(hooks), `"PreToolUse"`) ||
!strings.Contains(string(hooks), `"PostToolUse"`) {
t.Fatalf("Factory Droid hooks = %q, %v", hooks, err)
}
stdout, stderr, exit = runClineCLI(
t,
binary,
workspace,
environment,
"status",
"--agent", "droid",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
t.Fatalf("Factory Droid CLI status = %d, %q, %q", exit, stdout, stderr)
}
stdout, stderr, exit = runClineCLI(
t,
binary,
workspace,
environment,
"uninstall",
"--agent", "droid",
"--agent-id", "agent-1",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
t.Fatalf("Factory Droid CLI uninstall = %d, %q, %q", exit, stdout, stderr)
}
for _, path := range []string{agentDir, hooksPath} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed path remains at %s: %v", path, err)
}
}
}
+73 -98
View File
@@ -1,7 +1,6 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
@@ -9,10 +8,10 @@ import (
"sort"
"strings"
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins"
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
)
const version = "1.2.0"
const version = "2.1.1"
func main() {
if len(os.Args) < 2 {
@@ -58,24 +57,25 @@ Run "elydora <command> -h" for details on each command.
`)
}
// ---------------------------------------------------------------------------
// install
// ---------------------------------------------------------------------------
func cmdInstall(args []string) {
if err := rejectLegacySecretArguments(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fs := flag.NewFlagSet("install", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.")
orgID := fs.String("org-id", "", "Organization ID (required)")
agentID := fs.String("agent-id", "", "Agent ID (required)")
privateKey := fs.String("private-key", "", "Base64url-encoded Ed25519 private key seed (required)")
privateKeyFile := fs.String("private-key-file", "", "Owner-only file containing the Ed25519 private key seed")
kid := fs.String("kid", "", "Key ID (defaults to <agent-id>-key-1)")
token := fs.String("token", "", "JWT token for authenticated requests (optional)")
tokenFile := fs.String("token-file", "", "Owner-only file containing the optional API token")
baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL")
fs.Parse(args)
if *agent == "" || *orgID == "" || *agentID == "" || *privateKey == "" {
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, --agent-id, and --private-key are required.")
if *agent == "" || *orgID == "" || *agentID == "" {
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, and --agent-id are required.")
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
@@ -90,45 +90,72 @@ func cmdInstall(args []string) {
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
os.Exit(1)
}
agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(*agentID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Generate guard script (PreToolUse — freeze enforcement)
guardScriptPath, err := guardScriptPathForAgent(*agentID)
secrets, err := resolveInstallSecrets(
installSecretSources{
privateKeyFile: *privateKeyFile,
tokenFile: *tokenFile,
},
newStandardSecretTerminal(),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if err := validatePrivateKey(secrets.privateKey); err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid private key: %v\n", err)
os.Exit(1)
}
guardScriptPath := filepath.Join(agentDirectory, "guard.js")
config := plugins.InstallConfig{
AgentName: *agent,
OrgID: *orgID,
AgentID: *agentID,
PrivateKey: secrets.privateKey,
KID: *kid,
Token: secrets.token,
BaseURL: *baseURL,
GuardScriptPath: guardScriptPath,
}
if preflighter, ok := plugin.(plugins.InstallPreflighter); ok {
if err := preflighter.PreflightInstall(config); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
manager, managesGuard := plugin.(plugins.GuardRuntimeManager)
if !managesGuard || !manager.ManagesGuardRuntime() {
agentDirectory, err = plugins.PrepareAgentRuntimeDirectory(*agentID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
guardScript := plugins.GenerateGuardScript(*agent, *agentID)
if err := os.MkdirAll(filepath.Dir(guardScriptPath), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating guard script directory: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(guardScriptPath, []byte(guardScript), 0755); err != nil {
if err := plugins.WriteRuntimeFileAtomic(
guardScriptPath,
"Elydora guard runtime",
[]byte(guardScript),
0700,
); err != nil {
fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err)
os.Exit(1)
}
fmt.Printf(" Guard script: %s\n", guardScriptPath)
config := plugins.InstallConfig{
AgentName: *agent,
OrgID: *orgID,
AgentID: *agentID,
PrivateKey: *privateKey,
KID: *kid,
Token: *token,
BaseURL: *baseURL,
GuardScriptPath: guardScriptPath,
}
if err := plugin.Install(config); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf(" Guard script: %s\n", guardScriptPath)
}
// ---------------------------------------------------------------------------
// uninstall
// ---------------------------------------------------------------------------
func cmdUninstall(args []string) {
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (required)")
@@ -149,13 +176,14 @@ func cmdUninstall(args []string) {
os.Exit(1)
}
// Resolve agentID: if not given, scan ~/.elydora/*/config.json for matching agent_name
resolvedAgentID := *agentID
if resolvedAgentID == "" {
resolvedAgentID = findAgentIDByName(*agent)
if resolvedAgentID == "" {
fmt.Fprintf(os.Stderr, "Warning: could not find agent ID for %q in ~/.elydora/*/config.json\n", *agent)
resolvedAgentID, agentDirectory, agentDirectoryExists, err := resolveAgentRuntimeForUninstall(*agent, *agentID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if resolvedAgentID == "" {
fmt.Fprintf(os.Stderr, "Error: could not find agent ID for %q in ~/.elydora/*/config.json; pass --agent-id explicitly\n", *agent)
os.Exit(1)
}
if err := plugin.Uninstall(resolvedAgentID); err != nil {
@@ -164,54 +192,14 @@ func cmdUninstall(args []string) {
}
// Remove the per-agent directory
if resolvedAgentID != "" {
home, err := os.UserHomeDir()
if err == nil {
agentDir := filepath.Join(home, ".elydora", resolvedAgentID)
if err := os.RemoveAll(agentDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to remove agent directory %s: %v\n", agentDir, err)
} else {
fmt.Printf(" Removed agent directory: %s\n", agentDir)
if agentDirectoryExists {
if err := os.RemoveAll(agentDirectory); err != nil {
fmt.Fprintf(os.Stderr, "Error removing agent directory %s: %v\n", agentDirectory, err)
os.Exit(1)
}
fmt.Printf(" Removed agent directory: %s\n", agentDirectory)
}
}
}
}
// findAgentIDByName scans ~/.elydora/*/config.json for a config whose agent_name
// matches the given name and returns the directory name (agent ID).
func findAgentIDByName(agentName string) string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
elydoraDir := filepath.Join(home, ".elydora")
entries, err := os.ReadDir(elydoraDir)
if err != nil {
return ""
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
configPath := filepath.Join(elydoraDir, entry.Name(), "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
continue
}
var config map[string]interface{}
if err := json.Unmarshal(data, &config); err != nil {
continue
}
if name, _ := config["agent_name"].(string); name == agentName {
return entry.Name()
}
}
return ""
}
// ---------------------------------------------------------------------------
// status
// ---------------------------------------------------------------------------
func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
@@ -256,10 +244,6 @@ func showAgentStatus(name string) {
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
}
// ---------------------------------------------------------------------------
// agents
// ---------------------------------------------------------------------------
func cmdAgents() {
fmt.Println("Supported agents:")
fmt.Println()
@@ -282,12 +266,3 @@ func sortedAgentNames() []string {
sort.Strings(names)
return names
}
// guardScriptPathForAgent returns the path to ~/.elydora/<agentId>/guard.js.
func guardScriptPathForAgent(agentId string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".elydora", agentId, "guard.js"), nil
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
)
func setMainTestHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return home
}
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
setMainTestHome(t)
_, err := plugins.ResolveAgentRuntimeDirectory("../escape")
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err)
}
}
func TestFindAgentIDByNameReturnsValidatedDirectoryIdentity(t *testing.T) {
home := setMainTestHome(t)
runtimeDirectory := filepath.Join(home, ".elydora", "agent-1")
if err := os.MkdirAll(runtimeDirectory, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
config := []byte(`{"agent_name":"opencode","agent_id":"agent-1"}`)
if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
agentID, err := findAgentIDByName("opencode")
if err != nil {
t.Fatalf("findAgentIDByName() error = %v", err)
}
if agentID != "agent-1" {
t.Fatalf("agent ID = %q, want agent-1", agentID)
}
}
func TestFindAgentIDByNameRejectsConfigDirectoryMismatch(t *testing.T) {
home := setMainTestHome(t)
runtimeDirectory := filepath.Join(home, ".elydora", "stored-directory")
if err := os.MkdirAll(runtimeDirectory, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
config := []byte(`{"agent_name":"opencode","agent_id":"different-agent"}`)
if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
_, err := findAgentIDByName("opencode")
if err == nil || !strings.Contains(err.Error(), "crosses its runtime directory") {
t.Fatalf("findAgentIDByName() error = %v", err)
}
}
func TestFindAgentIDByNameRejectsAmbiguousRuntimes(t *testing.T) {
home := setMainTestHome(t)
for _, agentID := range []string{"agent-1", "agent-2"} {
runtimeDirectory := filepath.Join(home, ".elydora", agentID)
if err := os.MkdirAll(runtimeDirectory, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
config := []byte(`{"agent_name":"opencode","agent_id":"` + agentID + `"}`)
if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
}
_, err := findAgentIDByName("opencode")
if err == nil || !strings.Contains(err.Error(), "multiple") {
t.Fatalf("findAgentIDByName() error = %v", err)
}
}
func TestExplicitUninstallRejectsAgentOwnershipMismatch(t *testing.T) {
home := setMainTestHome(t)
runtimeDirectory := filepath.Join(home, ".elydora", "agent-1")
if err := os.MkdirAll(runtimeDirectory, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
config := []byte(`{"agent_name":"codex","agent_id":"agent-1"}`)
if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
_, _, _, err := resolveAgentRuntimeForUninstall("opencode", "agent-1")
if err == nil || !strings.Contains(err.Error(), "belongs to") {
t.Fatalf("resolveAgentRuntimeForUninstall() error = %v", err)
}
}
func TestFindAgentIDByNameRejectsLinkedRuntime(t *testing.T) {
home := setMainTestHome(t)
root := filepath.Join(home, ".elydora")
if err := os.MkdirAll(root, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
target := t.TempDir()
link := filepath.Join(root, "agent-1")
if err := os.Symlink(target, link); err != nil {
t.Skipf("directory symbolic links unavailable: %v", err)
}
_, err := findAgentIDByName("opencode")
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("findAgentIDByName() error = %v", err)
}
}
func TestFindAgentIDByNameRejectsLinkedConfig(t *testing.T) {
home := setMainTestHome(t)
runtimeDirectory := filepath.Join(home, ".elydora", "agent-1")
if err := os.MkdirAll(runtimeDirectory, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
target := filepath.Join(t.TempDir(), "config.json")
config := []byte(`{"agent_name":"opencode","agent_id":"agent-1"}`)
if err := os.WriteFile(target, config, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.Symlink(target, filepath.Join(runtimeDirectory, "config.json")); err != nil {
t.Skipf("file symbolic links unavailable: %v", err)
}
_, err := findAgentIDByName("opencode")
if err == nil || !strings.Contains(err.Error(), "physical file") {
t.Fatalf("findAgentIDByName() error = %v", err)
}
}
+144
View File
@@ -0,0 +1,144 @@
package plugins
import (
"fmt"
"path/filepath"
)
// AugmentPlugin manages Auggie native global user hooks.
type AugmentPlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *AugmentPlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates settings and runtime identity before any write.
func (p *AugmentPlugin) PreflightInstall(config InstallConfig) error {
document, err := readAugmentDocument()
if err != nil {
return err
}
_, _, err = preflightAugmentInstallation(config, document)
return err
}
func (p *AugmentPlugin) Install(config InstallConfig) error {
document, err := readAugmentDocument()
if err != nil {
return err
}
paths, nodePath, err := preflightAugmentInstallation(config, document)
if err != nil {
return err
}
hooks, _ := removeManagedAugmentHooks(document.hooks, "", paths.runtimeRoot)
hooks["PreToolUse"] = append(
hooks["PreToolUse"],
buildAugmentGroup(buildAugmentHandler(paths.guardWrapperPath)),
)
hooks["PostToolUse"] = append(
hooks["PostToolUse"],
buildAugmentGroup(buildAugmentHandler(paths.auditWrapperPath)),
)
rendered, err := renderAugmentDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Auggie user settings: %w", err)
}
changes, err := prepareAugmentInstallationChanges(
config,
paths,
nodePath,
rendered,
)
if err != nil {
return err
}
if err := writeManagedChanges(
changes,
"Install Augment Code CLI hooks",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.configPath),
"Auggie configuration directory",
); err != nil {
return err
}
fmt.Println("Auggie: user-level PreToolUse and PostToolUse hooks installed.")
return nil
}
func (p *AugmentPlugin) Uninstall(agentID string) error {
document, err := readAugmentDocument()
if err != nil {
return err
}
if !document.exists {
return nil
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return err
}
hooks, changed := removeManagedAugmentHooks(
document.hooks,
agentID,
runtimeRoot,
)
if !changed {
return nil
}
rendered, err := renderAugmentDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Auggie user settings: %w", err)
}
change, err := prepareRenderedAugmentChange(rendered)
if err != nil {
return err
}
return writeManagedChanges(
[]*fileChange{change},
"Uninstall Augment Code CLI hooks",
p.rename,
"",
"",
filepath.Dir(document.configPath),
"Auggie configuration directory",
)
}
func (p *AugmentPlugin) Status() (PluginStatus, error) {
configPath, pathErr := augmentConfigPath()
entry := SupportedAgents[augmentAgentKey]
status := PluginStatus{
AgentName: augmentAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
}
if pathErr != nil {
return status, pathErr
}
document, err := readAugmentDocument()
if err != nil {
return status, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return status, err
}
contracts := augmentRuntimeContracts(document.hooks, runtimeRoot)
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookScriptExists, err = augmentRuntimeFilesExist(
contracts,
runtimeRoot,
)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
+398
View File
@@ -0,0 +1,398 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
"math"
"path/filepath"
"reflect"
"runtime"
"strings"
)
const (
augmentAgentKey = "augment"
augmentGuardScript = "guard.js"
augmentAuditScript = "hook.js"
augmentHookTimeout = float64(10_000)
)
var (
augmentToolEvents = map[string]struct{}{
"PreToolUse": {},
"PostToolUse": {},
}
augmentSessionEvents = map[string]struct{}{
"Stop": {},
"SessionStart": {},
"SessionEnd": {},
"Notification": {},
"PromptSubmit": {},
}
)
type augmentGroup struct {
object map[string]any
handlers []map[string]any
}
type augmentHooks map[string][]augmentGroup
type augmentDocument struct {
exists bool
configPath string
root map[string]any
hooks augmentHooks
raw []byte
}
type augmentRenderedDocument struct {
document *augmentDocument
changed bool
next []byte
remove bool
}
type augmentWrapperPaths struct {
guard string
audit string
}
type augmentRuntimeContract struct {
agentID string
guardPath string
auditPath string
guardWrapper string
auditWrapper string
}
func validateAugmentHandler(
value any,
event string,
groupIndex, handlerIndex int,
) (map[string]any, error) {
label := fmt.Sprintf(
"Auggie settings handler hooks.%s[%d].hooks[%d]",
event, groupIndex, handlerIndex,
)
handler, ok := value.(map[string]any)
if !ok || handler == nil {
return nil, fmt.Errorf("%s must be an object", label)
}
handlerType, ok := handler["type"].(string)
if !ok || handlerType != "command" {
return nil, fmt.Errorf(`%s type must be "command"`, label)
}
command, ok := handler["command"].(string)
if !ok || command == "" {
return nil, fmt.Errorf("%s requires a non-empty command", label)
}
if arguments, exists := handler["args"]; exists {
values, ok := arguments.([]any)
if !ok {
return nil, fmt.Errorf("%s args must be an array of strings", label)
}
for _, argument := range values {
if _, ok := argument.(string); !ok {
return nil, fmt.Errorf("%s args must be an array of strings", label)
}
}
}
if timeoutValue, exists := handler["timeout"]; exists {
timeout, ok := timeoutValue.(float64)
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || timeout <= 0 {
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
}
}
return cloneJSONObject(handler), nil
}
func validateAugmentMetadata(value any, label string) error {
metadata, ok := value.(map[string]any)
if !ok || metadata == nil {
return fmt.Errorf("%s metadata must be an object", label)
}
for _, key := range []string{
"includeConversationData",
"includeMCPMetadata",
"includeUserContext",
} {
if field, exists := metadata[key]; exists {
if _, ok := field.(bool); !ok {
return fmt.Errorf("%s metadata.%s must be a boolean", label, key)
}
}
}
return nil
}
func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup, error) {
label := fmt.Sprintf("Auggie settings group hooks.%s[%d]", event, groupIndex)
object, ok := value.(map[string]any)
if !ok || object == nil {
return augmentGroup{}, fmt.Errorf("%s must be an object", label)
}
if _, sessionEvent := augmentSessionEvents[event]; sessionEvent {
if _, hasMatcher := object["matcher"]; hasMatcher {
return augmentGroup{}, fmt.Errorf("%s matcher is only supported for tool events", label)
}
}
if matcher, exists := object["matcher"]; exists {
if _, ok := matcher.(string); !ok {
return augmentGroup{}, fmt.Errorf("%s matcher must be a string", label)
}
}
if metadata, exists := object["metadata"]; exists {
if err := validateAugmentMetadata(metadata, label); err != nil {
return augmentGroup{}, err
}
}
values, ok := object["hooks"].([]any)
if !ok {
return augmentGroup{}, fmt.Errorf("%s must contain a hooks array", label)
}
handlers := make([]map[string]any, 0, len(values))
for handlerIndex, value := range values {
handler, err := validateAugmentHandler(value, event, groupIndex, handlerIndex)
if err != nil {
return augmentGroup{}, err
}
handlers = append(handlers, handler)
}
return augmentGroup{object: cloneJSONObject(object), handlers: handlers}, nil
}
func readAugmentHooks(root map[string]any) (augmentHooks, error) {
value, exists := root["hooks"]
if !exists {
return augmentHooks{}, nil
}
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`Auggie settings field "hooks" must be an object`)
}
hooks := make(augmentHooks, len(object))
for event, groupValue := range object {
if _, toolEvent := augmentToolEvents[event]; !toolEvent {
if _, sessionEvent := augmentSessionEvents[event]; !sessionEvent {
return nil, fmt.Errorf(
`Auggie settings field "hooks.%s" uses an unsupported event`,
event,
)
}
}
values, ok := groupValue.([]any)
if !ok {
return nil, fmt.Errorf(
`Auggie settings field "hooks.%s" must be an array`,
event,
)
}
groups := make([]augmentGroup, 0, len(values))
for groupIndex, value := range values {
group, err := validateAugmentGroup(value, event, groupIndex)
if err != nil {
return nil, err
}
groups = append(groups, group)
}
hooks[event] = groups
}
return hooks, nil
}
func parseAugmentDocument(configPath string, raw []byte) (*augmentDocument, error) {
label := fmt.Sprintf("Auggie user settings at %s", configPath)
root, err := decodeStrictJSONObject(raw, label)
if err != nil {
return nil, err
}
hooks, err := readAugmentHooks(root)
if err != nil {
return nil, err
}
return &augmentDocument{
exists: true, configPath: configPath, root: root, hooks: hooks,
raw: append([]byte(nil), raw...),
}, nil
}
func createAugmentDocument(configPath string) *augmentDocument {
return &augmentDocument{
configPath: configPath,
root: map[string]any{},
hooks: augmentHooks{},
}
}
func renderAugmentDocument(
document *augmentDocument,
hooks augmentHooks,
) (*augmentRenderedDocument, error) {
if document == nil {
return nil, fmt.Errorf("Auggie settings document is required")
}
if reflect.DeepEqual(hooks, document.hooks) {
return &augmentRenderedDocument{document: document}, nil
}
if !document.exists && len(hooks) == 0 {
return &augmentRenderedDocument{document: document}, nil
}
root := cloneJSONObject(document.root)
if len(hooks) == 0 {
delete(root, "hooks")
} else {
root["hooks"] = renderAugmentHooks(hooks)
}
if len(root) == 0 {
return &augmentRenderedDocument{
document: document,
changed: true,
remove: true,
}, nil
}
next, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Auggie user settings: %w", err)
}
next = append(next, '\n')
if _, err := parseAugmentDocument(document.configPath, next); err != nil {
return nil, fmt.Errorf("validate rendered Auggie user settings: %w", err)
}
return &augmentRenderedDocument{
document: document,
changed: !document.exists || !bytes.Equal(next, document.raw),
next: next,
}, nil
}
func renderAugmentHooks(hooks augmentHooks) map[string]any {
result := make(map[string]any, len(hooks))
for event, groups := range hooks {
values := make([]any, 0, len(groups))
for _, group := range groups {
object := cloneJSONObject(group.object)
handlers := make([]any, 0, len(group.handlers))
for _, handler := range group.handlers {
handlers = append(handlers, handler)
}
object["hooks"] = handlers
values = append(values, object)
}
result[event] = values
}
return result
}
func readAugmentWindowsArgument(command string) (string, bool) {
if len(command) < 2 || command[0] != '"' {
return "", false
}
var value strings.Builder
for index := 1; index < len(command); index++ {
if command[index] == '\\' && index+1 < len(command) && command[index+1] == '"' {
value.WriteByte('"')
index++
continue
}
if command[index] == '"' {
return value.String(), index == len(command)-1 && value.Len() > 0
}
value.WriteByte(command[index])
}
return "", false
}
func readAugmentPOSIXArgument(command string) (string, bool) {
value, end, ok := readPOSIXArgument(command, 0)
return value, ok && end == len(command) && value != ""
}
func parseAugmentCommand(command string) (string, bool) {
if runtime.GOOS == "windows" {
return readAugmentWindowsArgument(command)
}
return readAugmentPOSIXArgument(command)
}
func managedAugmentAgentID(
handler map[string]any,
wrapperName, runtimeRoot string,
) (string, bool) {
if handler["type"] != "command" || handler["timeout"] != augmentHookTimeout {
return "", false
}
if _, hasArguments := handler["args"]; hasArguments {
return "", false
}
command, ok := handler["command"].(string)
if !ok {
return "", false
}
wrapperPath, ok := parseAugmentCommand(command)
if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) {
return "", false
}
agentDirectory := filepath.Dir(wrapperPath)
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false
}
agentID := filepath.Base(agentDirectory)
return agentID, agentID != "" && agentID != "." && agentID != ".."
}
func removeManagedAugmentGroups(
groups []augmentGroup,
wrapperName, agentID, runtimeRoot string,
) ([]augmentGroup, bool) {
result := make([]augmentGroup, 0, len(groups))
changed := false
for _, group := range groups {
handlers := make([]map[string]any, 0, len(group.handlers))
groupChanged := false
for _, handler := range group.handlers {
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID))
if remove {
changed = true
groupChanged = true
continue
}
handlers = append(handlers, handler)
}
if len(handlers) > 0 || !groupChanged {
result = append(result, augmentGroup{object: group.object, handlers: handlers})
}
}
return result, changed
}
func removeManagedAugmentHooks(
hooks augmentHooks,
agentID, runtimeRoot string,
) (augmentHooks, bool) {
result := make(augmentHooks, len(hooks))
for event, groups := range hooks {
result[event] = groups
}
changed := false
for _, contract := range []struct{ event, wrapper string }{
{"PreToolUse", augmentGuardWrapperName()},
{"PostToolUse", augmentAuditWrapperName()},
} {
groups, eventChanged := removeManagedAugmentGroups(
result[contract.event], contract.wrapper, agentID, runtimeRoot,
)
if !eventChanged {
continue
}
changed = true
if len(groups) == 0 {
delete(result, contract.event)
} else {
result[contract.event] = groups
}
}
return result, changed
}
+106
View File
@@ -0,0 +1,106 @@
package plugins
import "fmt"
type augmentRuntimePaths struct {
managedRuntimePaths
guardWrapperPath string
auditWrapperPath string
}
func augmentWrapperArtifacts(agentDirectory string) []managedArtifact {
wrappers := resolveAugmentWrapperPaths(agentDirectory)
return []managedArtifact{
{wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes},
{wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes},
}
}
func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) {
if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil {
return nil, err
}
paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript)
if err != nil {
return nil, err
}
wrappers := resolveAugmentWrapperPaths(paths.agentDirectory)
return &augmentRuntimePaths{
managedRuntimePaths: *paths,
guardWrapperPath: wrappers.guard,
auditWrapperPath: wrappers.audit,
}, nil
}
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
return validateManagedRuntimeIdentity(
agentDirectory, agentID, augmentAgentKey, "Auggie",
augmentWrapperArtifacts(agentDirectory)...,
)
}
func preflightAugmentInstallation(
config InstallConfig,
document *augmentDocument,
) (*augmentRuntimePaths, string, error) {
if document == nil || document.configPath == "" {
return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document")
}
paths, err := augmentAgentPaths(config)
if err != nil {
return nil, "", err
}
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, "", err
}
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
if err != nil {
return nil, "", err
}
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func prepareAugmentInstallationChanges(
config InstallConfig,
paths *augmentRuntimePaths,
nodePath string,
rendered *augmentRenderedDocument,
) ([]*fileChange, error) {
if paths == nil || rendered == nil {
return nil, fmt.Errorf("prepared Auggie installation is required")
}
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, err
}
changes, err := managedRuntimeFileChanges(
config,
&paths.managedRuntimePaths,
augmentAgentKey,
generateGuardScript(augmentAgentKey, config.AgentID, "", false, ""),
buildHookScriptWithOutput(augmentAgentKey, config.AgentID, "", false, true),
)
if err != nil {
return nil, err
}
for _, item := range []struct {
path, label string
content []byte
}{
{paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)},
{paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)},
} {
change, err := prepareFileChange(item.path, item.label, item.content, 0700)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
documentChange, err := prepareRenderedAugmentChange(rendered)
if err != nil {
return nil, err
}
return append(changes, documentChange), nil
}
+174
View File
@@ -0,0 +1,174 @@
package plugins
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
)
const augmentRegexValidator = `import fs from "node:fs";
const pattern = fs.readFileSync(0, "utf8");
try {
new RegExp(pattern);
} catch (error) {
process.stderr.write(error instanceof Error ? error.message : String(error));
process.exit(1);
}`
func augmentGuardWrapperName() string {
if runtime.GOOS == "windows" {
return "augment-guard.cmd"
}
return "augment-guard.sh"
}
func augmentAuditWrapperName() string {
if runtime.GOOS == "windows" {
return "augment-hook.cmd"
}
return "augment-hook.sh"
}
func augmentConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".augment", "settings.json"), nil
}
func readAugmentDocument() (*augmentDocument, error) {
configPath, err := augmentConfigPath()
if err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(configPath),
"Auggie configuration directory",
); err != nil {
return nil, err
}
snapshot, err := readManagedFile(
configPath,
"Auggie user settings",
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
if snapshot == nil {
return createAugmentDocument(configPath), nil
}
return parseAugmentDocument(configPath, snapshot.contents)
}
func prepareRenderedAugmentChange(
rendered *augmentRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSourceChange(
rendered.document.configPath,
"Auggie user settings",
rendered.document.raw,
rendered.document.exists,
rendered.next,
0600,
rendered.remove,
)
}
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
events := make([]string, 0, len(hooks))
for event := range hooks {
events = append(events, event)
}
sort.Strings(events)
for _, event := range events {
for groupIndex, group := range hooks[event] {
matcher, exists := group.object["matcher"].(string)
if !exists {
continue
}
// #nosec G204 -- nodePath is resolved with exec.LookPath and matcher data uses stdin.
command := exec.Command(
nodePath, "--input-type=module", "--eval", augmentRegexValidator,
)
command.Stdin = strings.NewReader(matcher)
output, err := command.CombinedOutput()
if err == nil {
continue
}
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return fmt.Errorf(
"Auggie settings group hooks.%s[%d] matcher "+
"must be a valid JavaScript regular expression: %s",
event,
groupIndex,
message,
)
}
}
return nil
}
func quoteAugmentWindowsCommand(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
func quoteAugmentBatchArgument(value string) string {
return `"` + strings.ReplaceAll(value, "%", "%%") + `"`
}
func buildAugmentCommand(wrapperPath string) string {
if runtime.GOOS == "windows" {
return quoteAugmentWindowsCommand(wrapperPath)
}
return quotePOSIXArgument(wrapperPath)
}
func buildAugmentWrapper(runtimePath, scriptPath string) []byte {
if runtime.GOOS == "windows" {
return []byte(
"@echo off\r\n" +
quoteAugmentBatchArgument(runtimePath) + " " +
quoteAugmentBatchArgument(scriptPath) + "\r\n" +
"exit /b %errorlevel%\r\n",
)
}
return []byte(
"#!/bin/sh\nexec " +
quotePOSIXArgument(runtimePath) + " " +
quotePOSIXArgument(scriptPath) + "\n",
)
}
func buildAugmentHandler(wrapperPath string) map[string]any {
return map[string]any{
"type": "command",
"command": buildAugmentCommand(wrapperPath),
"timeout": augmentHookTimeout,
}
}
func buildAugmentGroup(handler map[string]any) augmentGroup {
return augmentGroup{
object: map[string]any{"matcher": ".*"},
handlers: []map[string]any{handler},
}
}
func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths {
return augmentWrapperPaths{
guard: filepath.Join(agentDirectory, augmentGuardWrapperName()),
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
}
}
@@ -0,0 +1,53 @@
package plugins
import (
"os"
"os/exec"
"strings"
"testing"
)
func TestAugmentOfficialBinaryAcceptsInstalledUserHooks(t *testing.T) {
entry := os.Getenv("ELYDORA_AUGGIE_ENTRY")
if entry == "" {
t.Skip("ELYDORA_AUGGIE_ENTRY is unset")
}
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
environment := append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
)
version := exec.Command(nodePath, entry, "--version")
version.Dir = fixture.projectDir
version.Env = environment
versionOutput, err := version.CombinedOutput()
if err != nil || !strings.Contains(string(versionOutput), "0.33.0") {
t.Fatalf("official Auggie version: %v\n%s", err, versionOutput)
}
load := exec.Command(nodePath, entry, "tools", "list")
load.Dir = fixture.projectDir
load.Env = environment
loadOutput, err := load.CombinedOutput()
if err != nil {
t.Fatalf("official Auggie settings load: %v\n%s", err, loadOutput)
}
output := strings.ToLower(string(loadOutput))
for _, problem := range []string{
"invalid settings",
"settings validation",
"failed to parse",
"hook configuration error",
} {
if strings.Contains(output, problem) {
t.Fatalf("official Auggie loader reported %q:\n%s", problem, loadOutput)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
package plugins
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func installAugmentRuntimeFixture(
t *testing.T,
status string,
) (*augmentFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
fixture.config.BaseURL = server.URL
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Auggie hooks: %v", err)
}
return fixture, api, server
}
func augmentOfficialPayload(event string) map[string]any {
value := map[string]any{
"hook_event_name": event,
"conversation_id": "conversation-1",
"workspace_roots": []any{"C:/workspace"},
"tool_name": "launch-process",
"tool_input": map[string]any{
"command": "go test ./...",
"nested": map[string]any{"preserve": true},
},
"is_mcp_tool": false,
"conversation_data": []any{
map[string]any{"role": "user", "content": "preserve this"},
},
"mcp_metadata": map[string]any{
"server": "local", "transport": "stdio",
},
"user_context": map[string]any{"account": "user-1"},
"future_field": map[string]any{"survives": []any{"exactly", float64(2)}},
}
if event == "PostToolUse" {
value["tool_output"] = map[string]any{
"stdout": "passed", "stderr": "", "exit_code": float64(0),
}
}
return value
}
func marshalAugmentPayload(t *testing.T, payload map[string]any) []byte {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Auggie payload: %v", err)
}
return encoded
}
func TestAugmentGuardPropagatesOfficialBlockingExitCode(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installAugmentRuntimeFixture(t, status)
defer server.Close()
handler := augmentTestManagedHandler(
t,
readAugmentTestObject(t, fixture.configPath),
"PreToolUse",
fixture.guardWrapper,
)
exit, stdout, stderr := runAugmentCommand(
t,
handler["command"].(string),
fixture,
marshalAugmentPayload(t, augmentOfficialPayload("PreToolUse")),
)
if exit != 2 || stdout != "" ||
!strings.Contains(strings.ToLower(stderr), status) ||
!strings.Contains(stderr, "Tool execution blocked") {
t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr)
}
})
}
}
func TestAugmentAuditPreservesCompleteNativePayload(t *testing.T) {
fixture, api, server := installAugmentRuntimeFixture(t, "active")
defer server.Close()
payload := augmentOfficialPayload("PostToolUse")
handler := augmentTestManagedHandler(
t,
readAugmentTestObject(t, fixture.configPath),
"PostToolUse",
fixture.auditWrapper,
)
exit, stdout, stderr := runAugmentCommand(
t,
handler["command"].(string),
fixture,
marshalAugmentPayload(t, payload),
)
if exit != 0 || stdout != "" || stderr != "" {
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
}
_, postAuth, operations := api.snapshot()
if postAuth != "Bearer token-1" ||
len(operations) != 1 ||
!reflect.DeepEqual(operations[0]["payload"], payload) {
t.Fatalf("Auggie native operation = auth %q, %#v", postAuth, operations)
}
if operations[0]["prev_chain_hash"] !=
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" {
t.Fatalf("Auggie operation chain = %#v", operations[0])
}
}
func TestAugmentRuntimeFailuresRemainObservableAndFailOpen(t *testing.T) {
fixture, _, server := installAugmentRuntimeFixture(t, "active")
settings := readAugmentTestObject(t, fixture.configPath)
guard := augmentTestManagedHandler(
t,
settings,
"PreToolUse",
fixture.guardWrapper,
)
audit := augmentTestManagedHandler(
t,
settings,
"PostToolUse",
fixture.auditWrapper,
)
server.Close()
guardExit, _, guardError := runAugmentCommand(
t,
guard["command"].(string),
fixture,
marshalAugmentPayload(t, augmentOfficialPayload("PreToolUse")),
)
auditExit, _, _ := runAugmentCommand(
t,
audit["command"].(string),
fixture,
marshalAugmentPayload(t, augmentOfficialPayload("PostToolUse")),
)
malformedExit, _, _ := runAugmentCommand(
t,
audit["command"].(string),
fixture,
[]byte("{ malformed"),
)
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if guardExit != 0 ||
!strings.Contains(guardError, "Failed to resolve agent status") ||
auditExit != 0 ||
malformedExit != 0 ||
err != nil ||
!strings.Contains(string(log), "invalid JSON") ||
!strings.Contains(strings.ToLower(string(log)), "fetch failed") {
t.Fatalf(
"fail-open results = (%d, %q), %d, %d, log=%q, err=%v",
guardExit,
guardError,
auditExit,
malformedExit,
log,
err,
)
}
}
func TestAugmentStatusRequiresExactRuntimeFiles(t *testing.T) {
for _, name := range []string{
"config.json",
"private.key",
augmentGuardScript,
augmentAuditScript,
augmentGuardWrapperName(),
augmentAuditWrapperName(),
} {
t.Run("missing "+name, func(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured ||
!status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
path := filepath.Join(fixture.agentDir, name)
if err := os.Remove(path); err != nil {
t.Fatalf("remove %s: %v", path, err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured ||
status.HookScriptExists {
t.Fatalf("missing %s status = %#v, %v", path, status, err)
}
})
}
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks for wrapper check: %v", err)
}
writeAugmentTestFile(
t,
fixture.auditWrapper,
[]byte("tampered wrapper\n"),
0700,
)
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("tampered-wrapper status = %#v, %v", status, err)
}
fixture = prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks for key check: %v", err)
}
writeAugmentTestFile(t, fixture.privateKey, []byte("invalid"), 0600)
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "canonical 32-byte") {
t.Fatalf("invalid-key status error = %v", err)
}
}
func TestAugmentStatusSurfacesInvalidRuntimeMetadata(t *testing.T) {
for _, testCase := range []struct{ name, source, want string }{
{"malformed", "{ malformed", "parse Elydora runtime config"},
{"duplicate", `{"agent_name":"augment","agent_name":"augment"}`, "duplicate key"},
{
"unsupported",
`{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"augment","extra":true}`,
"unsupported field",
},
{
"identity",
`{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"augment"}`,
"identity does not match",
},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
writeAugmentTestFile(
t,
fixture.runtimeConfig,
[]byte(testCase.source),
0600,
)
_, err := fixture.plugin.Status()
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("status error = %v, want %q", err, testCase.want)
}
})
}
}
func TestAugmentStatusIgnoresIncompleteManagedHookPairs(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
settings := readAugmentTestObject(t, fixture.configPath)
delete(requireObject(t, settings["hooks"]), "PostToolUse")
writeAugmentTestObject(t, fixture.configPath, settings)
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("incomplete-pair status = %#v, %v", status, err)
}
}
func TestAugmentRuntimeConfigOmitsEmptyToken(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
fixture.config.Token = ""
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
config := readAugmentTestObject(t, fixture.runtimeConfig)
if _, exists := config["token"]; exists {
t.Fatalf("empty token persisted: %#v", config)
}
}
+99
View File
@@ -0,0 +1,99 @@
package plugins
import (
"bytes"
"path/filepath"
"sort"
)
func managedAugmentIDs(
groups []augmentGroup,
wrapperName string,
runtimeRoot string,
) map[string]string {
result := map[string]string{}
for _, group := range groups {
for _, handler := range group.handlers {
agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
if managed {
result[managedReferenceKey(agentID)] = agentID
}
}
}
return result
}
func augmentRuntimeContracts(
hooks augmentHooks,
runtimeRoot string,
) []augmentRuntimeContract {
guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot)
audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot)
keys := make([]string, 0, len(guards))
for key := range guards {
if _, exists := audits[key]; exists {
keys = append(keys, key)
}
}
sort.Strings(keys)
contracts := make([]augmentRuntimeContract, 0, len(keys))
for _, key := range keys {
agentID := guards[key]
agentDirectory := filepath.Join(runtimeRoot, agentID)
wrappers := resolveAugmentWrapperPaths(agentDirectory)
contracts = append(contracts, augmentRuntimeContract{
agentID: agentID,
guardPath: filepath.Join(agentDirectory, augmentGuardScript),
auditPath: filepath.Join(agentDirectory, augmentAuditScript),
guardWrapper: wrappers.guard,
auditWrapper: wrappers.audit,
})
}
return contracts
}
func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) {
wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath))
if !sameManagedPath(contract.guardWrapper, wrappers.guard) ||
!sameManagedPath(contract.auditWrapper, wrappers.audit) {
return false, nil
}
for _, item := range []struct {
path, label string
expected []byte
}{
{contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)},
{contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)},
} {
snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes)
if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) {
return false, err
}
}
return true, nil
}
func augmentRuntimeFilesExist(
contracts []augmentRuntimeContract,
runtimeRoot string,
) (bool, error) {
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
if err != nil {
return false, err
}
for _, contract := range contracts {
runtime := managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath}
exists, err := managedRuntimeContractExists(runtime, augmentAgentKey, "Auggie", nil)
if err != nil || !exists {
if err != nil {
return false, err
}
continue
}
matches, err := augmentWrappersMatch(contract, nodePath)
if err != nil || matches {
return matches, err
}
}
return false, nil
}
+288
View File
@@ -0,0 +1,288 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func TestAugmentRegistryFactoryAndGuardOwnership(t *testing.T) {
entry := SupportedAgents[augmentAgentKey]
if entry.Name != "Augment Code CLI" ||
entry.ConfigDir != "~/.augment" ||
entry.ConfigFile != "settings.json" {
t.Fatalf("Auggie registry entry = %#v", entry)
}
plugin, ok := NewPlugin(augmentAgentKey).(*AugmentPlugin)
if !ok {
t.Fatalf("Auggie plugin factory returned %T", NewPlugin(augmentAgentKey))
}
if !plugin.ManagesGuardRuntime() {
t.Fatal("Auggie plugin must own guard runtime generation")
}
}
func TestAugmentGeneratedArgumentParsers(t *testing.T) {
posixPath := "/tmp/home with spaces/agent's/augment-guard.sh"
posixCommand := quotePOSIXArgument(posixPath)
parsed, ok := readAugmentPOSIXArgument(posixCommand)
if !ok || parsed != posixPath {
t.Fatalf("parse POSIX wrapper = %q, %v", parsed, ok)
}
windowsPath := `C:\home with spaces\.elydora\agent-1\augment-guard.cmd`
windowsCommand := quoteAugmentWindowsCommand(windowsPath)
parsed, ok = readAugmentWindowsArgument(windowsCommand)
if !ok || parsed != windowsPath {
t.Fatalf("parse Windows wrapper = %q, %v", parsed, ok)
}
}
func TestAugmentInstallPreservesSettingsAndIsIdempotent(t *testing.T) {
existing := `{
"telemetryEnabled": false,
"hooks": {
"SessionStart": [{"hooks":[{"type":"command","command":"existing-command","args":["one"],"timeout":5000}],"metadata":{"includeUserContext":true},"label":"keep"}],
"PreToolUse": [{"matcher":"launch-process","hooks":[{"type":"command","command":"user-command"}]}]
}
}`
fixture := prepareAugmentFixture(
t,
augmentFixtureOptions{existingRaw: &existing},
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repeat Auggie install: %v", err)
}
settings := readAugmentTestObject(t, fixture.configPath)
if settings["telemetryEnabled"] != false {
t.Fatalf("telemetry setting changed: %#v", settings)
}
hooks := requireObject(t, settings["hooks"])
if len(requireArray(t, hooks["SessionStart"])) != 1 ||
len(requireArray(t, hooks["PreToolUse"])) != 2 ||
len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("unexpected hooks after repeat install: %#v", hooks)
}
managedGroup := requireObject(
t,
requireArray(t, hooks["PreToolUse"])[1],
)
if managedGroup["matcher"] != ".*" {
t.Fatalf("managed matcher = %#v", managedGroup["matcher"])
}
guard := augmentTestManagedHandler(
t,
settings,
"PreToolUse",
fixture.guardWrapper,
)
audit := augmentTestManagedHandler(
t,
settings,
"PostToolUse",
fixture.auditWrapper,
)
for _, handler := range []map[string]any{guard, audit} {
want := map[string]any{
"type": "command", "command": handler["command"],
"timeout": augmentHookTimeout,
}
if !reflect.DeepEqual(handler, want) {
t.Fatalf("managed handler = %#v", handler)
}
}
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
for _, item := range []struct {
path, script string
}{
{fixture.guardWrapper, fixture.guardPath},
{fixture.auditWrapper, fixture.hookPath},
} {
actual, err := os.ReadFile(item.path)
if err != nil {
t.Fatalf("read wrapper %s: %v", item.path, err)
}
want := buildAugmentWrapper(nodePath, item.script)
if string(actual) != string(want) {
t.Fatalf("wrapper %s = %q, want %q", item.path, actual, want)
}
}
runtimeConfig := readAugmentTestObject(t, fixture.runtimeConfig)
if runtimeConfig["agent_name"] != augmentAgentKey ||
runtimeConfig["agent_id"] != augmentTestAgentID {
t.Fatalf("runtime identity = %#v", runtimeConfig)
}
auditSource, err := os.ReadFile(fixture.hookPath)
if err != nil || !strings.Contains(string(auditSource), "const NATIVE_PAYLOAD = true;") {
t.Fatalf("audit runtime native payload = %v, %v", err, strings.Contains(string(auditSource), "const NATIVE_PAYLOAD = true;"))
}
for _, path := range []string{
filepath.Join(fixture.projectDir, ".augment", "settings.json"),
filepath.Join(fixture.projectDir, ".augment", "settings.local.json"),
} {
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("workspace settings written at %s: %v", path, err)
}
}
}
func TestAugmentUninstallPreservesExactUserOwnership(t *testing.T) {
existing := `{"owner":"user","hooks":{"Notification":[]}}`
fixture := prepareAugmentFixture(
t,
augmentFixtureOptions{existingRaw: &existing},
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
settings := readAugmentTestObject(t, fixture.configPath)
hooks := requireObject(t, settings["hooks"])
preGroups := requireArray(t, hooks["PreToolUse"])
preGroups = append([]any{map[string]any{
"hooks": []any{}, "label": "keep empty group",
}}, preGroups...)
managedGroup := requireObject(t, preGroups[1])
managedGroup["hooks"] = append(
requireArray(t, managedGroup["hooks"]),
map[string]any{"type": "command", "command": "user-command"},
)
preGroups = append(
preGroups,
map[string]any{"hooks": []any{buildAugmentHandler(
fixture.guardWrapper + ".backup",
)}},
map[string]any{"hooks": []any{buildAugmentHandler(filepath.Join(
filepath.Dir(fixture.agentDir),
"agent-10",
augmentGuardWrapperName(),
))}},
)
hooks["PreToolUse"] = preGroups
writeAugmentTestObject(t, fixture.configPath, settings)
uninstallID := augmentTestAgentID
if runtime.GOOS == "windows" {
uninstallID = "AGENT-1"
}
if err := fixture.plugin.Uninstall(uninstallID); err != nil {
t.Fatalf("uninstall Auggie hooks: %v", err)
}
remaining := readAugmentTestObject(t, fixture.configPath)
remainingHooks := requireObject(t, remaining["hooks"])
if remaining["owner"] != "user" ||
len(requireArray(t, remainingHooks["PreToolUse"])) != 4 {
t.Fatalf("user settings changed: %#v", remaining)
}
if !reflect.DeepEqual(
requireArray(t, remainingHooks["Notification"]),
[]any{},
) {
t.Fatalf("empty notification changed: %#v", remainingHooks["Notification"])
}
raw, _ := os.ReadFile(fixture.configPath)
if !strings.Contains(string(raw), "backup") ||
!strings.Contains(string(raw), "agent-10") {
t.Fatalf("similar handlers were removed: %s", raw)
}
if _, exists := remainingHooks["PostToolUse"]; exists {
t.Fatalf("managed PostToolUse remains: %#v", remainingHooks["PostToolUse"])
}
}
func TestAugmentInstallReplacesStaleHooksAndPreservesEmptyGroups(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
settings := readAugmentTestObject(t, fixture.configPath)
hooks := requireObject(t, settings["hooks"])
hooks["PreToolUse"] = append(
[]any{map[string]any{"hooks": []any{}, "label": "keep empty group"}},
requireArray(t, hooks["PreToolUse"])...,
)
for _, item := range []struct{ event, wrapper string }{
{"PreToolUse", augmentGuardWrapperName()},
{"PostToolUse", augmentAuditWrapperName()},
} {
hooks[item.event] = append(
requireArray(t, hooks[item.event]),
map[string]any{"hooks": []any{buildAugmentHandler(filepath.Join(
filepath.Dir(fixture.agentDir),
"agent-old",
item.wrapper,
))}},
)
}
writeAugmentTestObject(t, fixture.configPath, settings)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("replace stale Auggie hooks: %v", err)
}
raw, _ := os.ReadFile(fixture.configPath)
current := readAugmentTestObject(t, fixture.configPath)
currentHooks := requireObject(t, current["hooks"])
if strings.Contains(string(raw), "agent-old") ||
len(requireArray(t, currentHooks["PreToolUse"])) != 2 ||
len(requireArray(t, currentHooks["PostToolUse"])) != 1 {
t.Fatalf("stale hooks remain: %s", raw)
}
}
func TestAugmentUninstallRemovesOwnedSettings(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
if err := fixture.plugin.Uninstall(augmentTestAgentID); err != nil {
t.Fatalf("uninstall Auggie hooks: %v", err)
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("owned Auggie settings remain: %v", err)
}
}
func TestAugmentMalformedSettingsPreventRuntimeWrites(t *testing.T) {
for _, testCase := range []struct{ name, raw, want string }{
{"malformed", "{ malformed", "parse Auggie user settings"},
{"duplicate", `{"hooks":{},"hooks":{}}`, "duplicate key"},
{"comments", "{\"hooks\":{} // comment\n}", "invalid character"},
{"trailing-comma", `{"hooks":{},}`, "invalid character"},
{"null-root", "null", "must contain a JSON object"},
{"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`},
{"unknown-event", `{"hooks":{"UnknownEvent":[]}}`, "unsupported event"},
{"null-event", `{"hooks":{"PreToolUse":null}}`, "must be an array"},
{"null-group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"},
{"session-matcher", `{"hooks":{"SessionStart":[{"matcher":".*","hooks":[]}]}}`, "only supported for tool events"},
{"null-matcher", `{"hooks":{"PreToolUse":[{"matcher":null,"hooks":[]}]}}`, "matcher must be a string"},
{"invalid-matcher", `{"hooks":{"PreToolUse":[{"matcher":"[","hooks":[]}]}}`, "valid JavaScript regular expression"},
{"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "must contain a hooks array"},
{"http-handler", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"http","command":"x"}]}]}}`, `type must be "command"`},
{"empty-command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "non-empty command"},
{"bad-args", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","args":[1]}]}]}}`, "array of strings"},
{"zero-timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":0}]}]}}`, "positive finite number"},
{"bad-metadata", `{"hooks":{"PreToolUse":[{"hooks":[],"metadata":{"includeUserContext":"yes"}}]}}`, "must be a boolean"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareAugmentFixture(
t,
augmentFixtureOptions{existingRaw: &testCase.raw},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
raw, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(raw) != testCase.raw {
t.Fatalf("original settings changed: %q, %v", raw, readErr)
}
assertNoAugmentRuntimeWrites(t, fixture)
})
}
}
@@ -0,0 +1,225 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
const (
augmentTestAgentID = "agent-1"
augmentPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
)
type augmentFixtureOptions struct {
existingRaw *string
}
type augmentFixture struct {
plugin *AugmentPlugin
config InstallConfig
homeDir string
projectDir string
agentDir string
configPath string
guardPath string
hookPath string
guardWrapper string
auditWrapper string
runtimeConfig string
privateKey string
}
func augmentString(value string) *string {
return &value
}
func prepareAugmentFixture(
t *testing.T,
options augmentFixtureOptions,
) *augmentFixture {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(root, "project with spaces")
if err := os.MkdirAll(projectDir, 0700); err != nil {
t.Fatalf("create project directory: %v", err)
}
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
agentDir := filepath.Join(homeDir, ".elydora", augmentTestAgentID)
configPath := filepath.Join(homeDir, ".augment", "settings.json")
if options.existingRaw != nil {
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatalf("create Auggie config directory: %v", err)
}
if err := os.WriteFile(configPath, []byte(*options.existingRaw), 0600); err != nil {
t.Fatalf("write Auggie settings: %v", err)
}
}
guardPath := filepath.Join(agentDir, augmentGuardScript)
return &augmentFixture{
plugin: &AugmentPlugin{},
config: InstallConfig{
AgentName: augmentAgentKey, OrgID: "org-1", AgentID: augmentTestAgentID,
PrivateKey: augmentPrivateKey, KID: "kid-1", Token: "token-1",
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
},
homeDir: homeDir, projectDir: projectDir, agentDir: agentDir,
configPath: configPath, guardPath: guardPath,
hookPath: filepath.Join(agentDir, augmentAuditScript),
guardWrapper: filepath.Join(agentDir, augmentGuardWrapperName()),
auditWrapper: filepath.Join(agentDir, augmentAuditWrapperName()),
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
}
}
func readAugmentTestObject(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func writeAugmentTestObject(t *testing.T, path string, value map[string]any) {
t.Helper()
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("marshal Auggie settings: %v", err)
}
encoded = append(encoded, '\n')
writeAugmentTestFile(t, path, encoded, 0600)
}
func writeAugmentTestFile(
t *testing.T,
path string,
contents []byte,
mode os.FileMode,
) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create parent directory for %s: %v", path, err)
}
if err := os.WriteFile(path, contents, mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func augmentTestManagedHandler(
t *testing.T,
settings map[string]any,
event string,
wrapperPath string,
) map[string]any {
t.Helper()
expected := buildAugmentCommand(wrapperPath)
hooks := requireObject(t, settings["hooks"])
for _, groupValue := range requireArray(t, hooks[event]) {
group := requireObject(t, groupValue)
for _, handlerValue := range requireArray(t, group["hooks"]) {
handler := requireObject(t, handlerValue)
if handler["command"] == expected {
return handler
}
}
}
t.Fatalf("%s handler for %s not found", event, wrapperPath)
return nil
}
func runAugmentCommand(
t *testing.T,
command string,
fixture *augmentFixture,
payload []byte,
) (int, string, string) {
t.Helper()
var process *exec.Cmd
if runtime.GOOS == "windows" {
commandFile := filepath.Join(t.TempDir(), "run-auggie-hook.cmd")
contents := []byte("@echo off\r\n" + command + "\r\n")
if err := os.WriteFile(commandFile, contents, 0600); err != nil {
t.Fatalf("write Auggie command file: %v", err)
}
process = exec.Command("cmd.exe", "/d", "/c", commandFile)
} else {
process = exec.Command("sh", "-c", command)
}
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
)
process.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return 0, stdout.String(), stderr.String()
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode(), stdout.String(), stderr.String()
}
t.Fatalf("run Auggie hook command: %v", err)
return -1, stdout.String(), stderr.String()
}
func assertNoAugmentRuntimeWrites(t *testing.T, fixture *augmentFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig,
fixture.privateKey,
fixture.guardPath,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertNoAugmentTransactionArtifacts(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
return
}
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
name := entry.Name()
if filepath.Ext(name) == ".tmp" || filepath.Ext(name) == ".rollback" {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk Auggie fixture: %v", err)
}
}
func augmentSymlinkOrSkip(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
}
@@ -0,0 +1,394 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func prepareAugmentTestInstallation(
t *testing.T,
fixture *augmentFixture,
) ([]*fileChange, *augmentRuntimePaths, *augmentDocument) {
t.Helper()
document, err := readAugmentDocument()
if err != nil {
t.Fatalf("read Auggie document: %v", err)
}
paths, nodePath, err := preflightAugmentInstallation(fixture.config, document)
if err != nil {
t.Fatalf("preflight Auggie installation: %v", err)
}
hooks, _ := removeManagedAugmentHooks(document.hooks, "", paths.runtimeRoot)
hooks["PreToolUse"] = append(
hooks["PreToolUse"],
buildAugmentGroup(buildAugmentHandler(paths.guardWrapperPath)),
)
hooks["PostToolUse"] = append(
hooks["PostToolUse"],
buildAugmentGroup(buildAugmentHandler(paths.auditWrapperPath)),
)
rendered, err := renderAugmentDocument(document, hooks)
if err != nil {
t.Fatalf("render Auggie document: %v", err)
}
changes, err := prepareAugmentInstallationChanges(
fixture.config,
paths,
nodePath,
rendered,
)
if err != nil {
t.Fatalf("prepare Auggie installation: %v", err)
}
return changes, paths, document
}
func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) {
original := "{\"telemetryEnabled\":true}\n"
fixture := prepareAugmentFixture(
t,
augmentFixtureOptions{existingRaw: &original},
)
failed := false
fixture.plugin.rename = func(source, destination string) error {
if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") &&
!failed {
failed = true
return errors.New("injected Auggie settings failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Auggie settings failure") {
t.Fatalf("install error = %v", err)
}
raw, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(raw) != original {
t.Fatalf("original settings changed: %q, %v", raw, readErr)
}
assertNoAugmentRuntimeWrites(t, fixture)
assertNoAugmentTransactionArtifacts(t, fixture.homeDir)
}
func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) {
original := `{"telemetryEnabled":true}`
fixture := prepareAugmentFixture(
t,
augmentFixtureOptions{existingRaw: &original},
)
changes, paths, document := prepareAugmentTestInstallation(t, fixture)
concurrent := []byte(
"{\"telemetryEnabled\":false,\"hooks\":{\"Notification\":[]}}\n",
)
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err)
}
err := writeManagedChanges(
changes,
"Install Augment Code CLI hooks",
nil,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.configPath),
"Auggie configuration directory",
)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(concurrent) {
t.Fatalf("concurrent settings changed: %q, %v", actual, readErr)
}
assertNoAugmentRuntimeWrites(t, fixture)
assertNoAugmentTransactionArtifacts(t, fixture.homeDir)
}
func TestAugmentInstallDetectsConcurrentSettingsIdentityReplacement(t *testing.T) {
source := []byte(`{"owner":"original"}`)
fixture := prepareAugmentFixture(
t,
augmentFixtureOptions{existingRaw: augmentString(string(source))},
)
mutated := false
fixture.plugin.rename = func(stagedPath, destination string) error {
if !mutated && strings.HasSuffix(stagedPath, ".tmp") {
mutated = true
external := fixture.configPath + ".external"
if err := os.Rename(fixture.configPath, external); err != nil {
return err
}
if err := os.WriteFile(fixture.configPath, source, 0600); err != nil {
return err
}
}
return os.Rename(stagedPath, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent identity error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("replacement settings changed: %q, %v", actual, readErr)
}
assertNoAugmentRuntimeWrites(t, fixture)
assertNoAugmentTransactionArtifacts(t, fixture.homeDir)
}
func TestPreparedAugmentInstallRejectsStaleSettingsBeforeStaging(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
document, err := readAugmentDocument()
if err != nil {
t.Fatalf("read Auggie document: %v", err)
}
paths, nodePath, err := preflightAugmentInstallation(fixture.config, document)
if err != nil {
t.Fatalf("preflight Auggie installation: %v", err)
}
hooks, _ := removeManagedAugmentHooks(document.hooks, "", paths.runtimeRoot)
hooks["PreToolUse"] = append(
hooks["PreToolUse"],
buildAugmentGroup(buildAugmentHandler(paths.guardWrapperPath)),
)
hooks["PostToolUse"] = append(
hooks["PostToolUse"],
buildAugmentGroup(buildAugmentHandler(paths.auditWrapperPath)),
)
rendered, err := renderAugmentDocument(document, hooks)
if err != nil {
t.Fatalf("render Auggie document: %v", err)
}
writeAugmentTestFile(
t,
fixture.configPath,
[]byte("{\"hooks\":{\"Notification\":[]}}\n"),
0600,
)
_, err = prepareAugmentInstallationChanges(
fixture.config,
paths,
nodePath,
rendered,
)
if err == nil || !strings.Contains(err.Error(), "changed before update") {
t.Fatalf("stale settings error = %v", err)
}
assertNoAugmentRuntimeWrites(t, fixture)
assertNoAugmentTransactionArtifacts(t, fixture.homeDir)
}
func TestAugmentOrphanRuntimeArtifactsFailBeforeSettingsWrites(t *testing.T) {
for _, runtimeName := range []string{
"private.key",
augmentGuardScript,
augmentAuditScript,
augmentGuardWrapperName(),
augmentAuditWrapperName(),
"chain-state.json",
"status-cache.json",
"error.log",
} {
t.Run(runtimeName, func(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
artifact := filepath.Join(fixture.agentDir, runtimeName)
writeAugmentTestFile(t, artifact, []byte("orphan\n"), 0600)
err := fixture.plugin.Install(fixture.config)
if err == nil ||
!strings.Contains(err.Error(), "identity cannot be verified") {
t.Fatalf("orphan runtime error = %v", err)
}
raw, readErr := os.ReadFile(artifact)
if readErr != nil || string(raw) != "orphan\n" {
t.Fatalf("orphan artifact changed: %q, %v", raw, readErr)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings written after orphan error: %v", err)
}
})
}
}
func TestAugmentMismatchedRuntimeIdentityFailsBeforeWrites(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
writeAugmentTestObject(t, fixture.runtimeConfig, map[string]any{
"agent_id": "another-agent", "agent_name": augmentAgentKey,
})
original, err := os.ReadFile(fixture.runtimeConfig)
if err != nil {
t.Fatalf("read runtime config: %v", err)
}
err = fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("identity error = %v", err)
}
actual, readErr := os.ReadFile(fixture.runtimeConfig)
if readErr != nil || string(actual) != string(original) {
t.Fatalf("runtime config changed: %q, %v", actual, readErr)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings written after identity error: %v", err)
}
}
func TestAugmentLinkedSettingsRuntimeAndWrappersAreRejected(t *testing.T) {
for _, kind := range []string{
"configuration",
"settings",
"runtime",
"agent-directory",
"runtime-config",
"wrapper",
} {
t.Run(kind, func(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
target := filepath.Join(t.TempDir(), kind+"-target")
switch kind {
case "settings":
writeAugmentTestFile(t, target, []byte("{}\n"), 0600)
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
t.Fatalf("create settings directory: %v", err)
}
augmentSymlinkOrSkip(t, target, fixture.configPath)
case "configuration":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target directory: %v", err)
}
if err := os.MkdirAll(filepath.Dir(filepath.Dir(fixture.configPath)), 0700); err != nil {
t.Fatalf("create configuration parent: %v", err)
}
augmentSymlinkOrSkip(t, target, filepath.Dir(fixture.configPath))
case "runtime":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target directory: %v", err)
}
if err := os.MkdirAll(filepath.Dir(filepath.Dir(fixture.agentDir)), 0700); err != nil {
t.Fatalf("create runtime parent: %v", err)
}
augmentSymlinkOrSkip(t, target, filepath.Dir(fixture.agentDir))
case "agent-directory":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target directory: %v", err)
}
if err := os.MkdirAll(filepath.Dir(fixture.agentDir), 0700); err != nil {
t.Fatalf("create runtime root: %v", err)
}
augmentSymlinkOrSkip(t, target, fixture.agentDir)
case "runtime-config":
writeAugmentTestFile(
t,
target,
[]byte(`{"agent_id":"agent-1","agent_name":"augment"}`),
0600,
)
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent directory: %v", err)
}
augmentSymlinkOrSkip(t, target, fixture.runtimeConfig)
case "wrapper":
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
writeAugmentTestFile(t, target, []byte("external wrapper\n"), 0600)
if err := os.Remove(fixture.guardWrapper); err != nil {
t.Fatalf("remove guard wrapper: %v", err)
}
augmentSymlinkOrSkip(t, target, fixture.guardWrapper)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical") {
t.Fatalf("linked %s error = %v", kind, err)
}
})
}
}
func TestAugmentInstallValidatesRuntimeInputsBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name, field, value, want string
}{
{"agent-name", "agent_name", "codex", "requires agent name augment"},
{"agent-id", "agent_id", "../escape", "invalid agent ID"},
{"organization", "org_id", " ", "organization ID is required"},
{"key-id", "kid", " ", "key ID is required"},
{"private-key", "private_key", "invalid", "canonical 32-byte"},
{"token", "token", " ", "non-whitespace"},
{"base-url", "base_url", "https://api.elydora.com?q=1", "query parameters"},
{"guard-path", "guard_script_path", "outside", "managed agent directory"},
{"audit-path", "hook_script", "outside", "managed agent directory"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
switch testCase.field {
case "agent_name":
fixture.config.AgentName = testCase.value
case "agent_id":
fixture.config.AgentID = testCase.value
case "org_id":
fixture.config.OrgID = testCase.value
case "kid":
fixture.config.KID = testCase.value
case "private_key":
fixture.config.PrivateKey = testCase.value
case "token":
fixture.config.Token = testCase.value
case "base_url":
fixture.config.BaseURL = testCase.value
case "guard_script_path":
fixture.config.GuardScriptPath = testCase.value
case "hook_script":
fixture.config.HookScript = testCase.value
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("validation error = %v, want %q", err, testCase.want)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings written after validation error: %v", err)
}
assertNoAugmentRuntimeWrites(t, fixture)
})
}
}
func TestAugmentInstallLeavesNoTransactionArtifacts(t *testing.T) {
fixture := prepareAugmentFixture(t, augmentFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
assertNoAugmentTransactionArtifacts(t, fixture.homeDir)
}
func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
original := `{"owner":"user"}`
fixture := prepareAugmentFixture(
t,
augmentFixtureOptions{existingRaw: &original},
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Auggie hooks: %v", err)
}
before, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read installed settings: %v", err)
}
fixture.plugin.rename = func(source, destination string) error {
if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") {
return errors.New("injected Auggie uninstall failure")
}
return os.Rename(source, destination)
}
err = fixture.plugin.Uninstall(augmentTestAgentID)
if err == nil || !strings.Contains(err.Error(), "injected Auggie uninstall failure") {
t.Fatalf("uninstall error = %v", err)
}
after, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(after) != string(before) {
t.Fatalf("Auggie settings changed: %q, %v", after, readErr)
}
assertNoAugmentTransactionArtifacts(t, fixture.homeDir)
}
+10
View File
@@ -29,3 +29,13 @@ type AgentPlugin interface {
Uninstall(agentID string) error
Status() (PluginStatus, error)
}
// InstallPreflighter validates provider and runtime sources before CLI writes.
type InstallPreflighter interface {
PreflightInstall(config InstallConfig) error
}
// GuardRuntimeManager owns provider-specific guard generation transactionally.
type GuardRuntimeManager interface {
ManagesGuardRuntime() bool
}
+99 -231
View File
@@ -2,280 +2,148 @@ package plugins
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ClaudeCodePlugin manages the Elydora audit hook for Claude Code.
// It merges a PostToolUse hook into ~/.claude/settings.json.
type ClaudeCodePlugin struct{}
// ClaudeCodePlugin manages Claude Code global user hooks.
type ClaudeCodePlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
return true
}
func requireClaudeHooksEnabled(document *claudeDocument) error {
if document.hooksDisabled {
return fmt.Errorf(
"Claude Code hooks are disabled by disableAllHooks: %s",
document.filePath,
)
}
return nil
}
// PreflightInstall validates settings and runtime identity before any write.
func (p *ClaudeCodePlugin) PreflightInstall(config InstallConfig) error {
document, err := readClaudeDocument()
if err != nil {
return err
}
if err := requireClaudeHooksEnabled(document); err != nil {
return err
}
_, _, err = preflightClaudeInstallation(config, document)
return err
}
func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID)
document, err := readClaudeDocument()
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
if err := requireClaudeHooksEnabled(document); err != nil {
return err
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
paths, nodePath, err := preflightClaudeInstallation(config, document)
if err != nil {
return err
}
}
configDir, err := expandHome("~/.claude")
hooks, err := removeManagedClaudeHooks(document.hooks, "")
if err != nil {
return err
}
configPath := filepath.Join(configDir, "settings.json")
settings, err := readJSONFile(configPath)
for _, item := range []struct{ event, script, status string }{
{"PreToolUse", paths.guardPath, claudeGuardStatusMessage},
{"PostToolUse", paths.auditPath, claudeAuditStatusMessage},
{"PostToolUseFailure", paths.auditPath, claudeAuditStatusMessage},
} {
hooks[item.event] = append(
hooks[item.event],
buildClaudeGroup(nodePath, item.script, item.status),
)
}
rendered, err := renderClaudeDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Claude Code user settings: %w", err)
}
changes, err := prepareClaudeInstallationChanges(config, paths, rendered)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- PreToolUse (guard — freeze enforcement) ---
preToolUse, _ := hooks["PreToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + guardPath,
},
},
}
preFiltered = append(preFiltered, guardEntry)
hooks["PreToolUse"] = preFiltered
// --- PostToolUse (audit logging) ---
postToolUse, _ := hooks["PostToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + scriptPath,
},
},
}
postFiltered = append(postFiltered, hookEntry)
hooks["PostToolUse"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
if err := writeManagedChanges(
changes,
"Install Claude Code hooks",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
"Claude Code configuration directory",
); err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Claude Code at %s\n", configPath)
fmt.Printf("Claude Code hooks installed at %s.\n", document.filePath)
fmt.Println("Claude Code verification: run /hooks and claude doctor.")
return nil
}
func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
configDir, err := expandHome("~/.claude")
document, err := readClaudeDocument()
if err != nil {
return err
}
configPath := filepath.Join(configDir, "settings.json")
settings, err := readJSONFile(configPath)
hooks, err := removeManagedClaudeHooks(document.hooks, agentID)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Claude Code hooks found.")
return nil
rendered, err := renderClaudeDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Claude Code user settings: %w", err)
}
// Remove PreToolUse Elydora entries
preToolUse, _ := hooks["PreToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "PreToolUse")
} else {
hooks["PreToolUse"] = preFiltered
}
// Remove PostToolUse Elydora entries
postToolUse, _ := hooks["PostToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "PostToolUse")
} else {
hooks["PostToolUse"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
change, err := prepareRenderedClaudeChange(rendered)
if err != nil {
return err
}
if agentID != "" {
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Claude Code.")
return nil
return writeManagedChanges(
[]*fileChange{change},
"Uninstall Claude Code hooks",
p.rename,
"",
"",
filepath.Dir(document.filePath),
"Claude Code configuration directory",
)
}
func (p *ClaudeCodePlugin) Status() (PluginStatus, error) {
configDir, err := expandHome("~/.claude")
if err != nil {
return PluginStatus{}, err
}
configPath := filepath.Join(configDir, "settings.json")
configPath, pathErr := claudeSettingsPath()
entry := SupportedAgents[claudeAgentKey]
status := PluginStatus{
AgentName: "claudecode",
DisplayName: "Claude Code",
ConfigPath: configPath,
AgentName: claudeAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
}
// Check if hook is configured in settings and extract hook script path
settings, err := readJSONFile(configPath)
if pathErr != nil {
return status, pathErr
}
document, err := readClaudeDocument()
if err != nil {
return status, err
}
if document.hooksDisabled {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
status.HookConfigured = preConfigured && postConfigured
// Extract hook script path from the configured command
scriptPath := extractElydoraScriptPath(hooks["PostToolUse"])
if scriptPath != "" {
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
contracts, err := claudeRuntimeContracts(document.hooks)
if err != nil {
return status, err
}
}
}
status.Installed = status.HookConfigured && status.HookScriptExists
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
// extractElydoraScriptPath extracts the script path from a hook array's Elydora command entry.
func extractElydoraScriptPath(hookArray interface{}) string {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
// New format: { "hooks": [{ "type": "command", "command": "node /path/to/hook.js" }] }
if innerHooks, ok := m["hooks"].([]interface{}); ok {
for _, h := range innerHooks {
if hm, ok := h.(map[string]interface{}); ok {
if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") {
return extractPathFromNodeCommand(cmd)
status.HookScriptExists, err = claudeRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
}
}
}
// Old format: { "command": "node /path/to/hook.js" }
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
return extractPathFromNodeCommand(cmd)
}
}
}
return ""
}
// extractPathFromNodeCommand extracts the file path from a "node /path/to/script.js" command.
func extractPathFromNodeCommand(cmd string) string {
cmd = strings.TrimSpace(cmd)
if strings.HasPrefix(cmd, "node ") {
return strings.TrimSpace(cmd[5:])
}
return ""
}
// hasElydoraEntry checks if a hook array (interface{}) contains an Elydora entry.
func hasElydoraEntry(hookArray interface{}) bool {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
return true
}
}
}
return false
}
// isElydoraHookEntry checks if a hook entry (old or new format) is an Elydora hook.
func isElydoraHookEntry(m map[string]interface{}) bool {
// New format: { "matcher": {}, "hooks": [{ "type": "command", "command": "..." }] }
if innerHooks, ok := m["hooks"].([]interface{}); ok {
for _, h := range innerHooks {
if hm, ok := h.(map[string]interface{}); ok {
if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") {
return true
}
}
}
}
// Old format: { "command": "..." }
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
return true
}
return false
status.Installed = status.HookScriptExists
return status, nil
}
+117
View File
@@ -0,0 +1,117 @@
package plugins
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func claudeRunCLI(
t *testing.T,
binary string,
fixture *claudeFixture,
args ...string,
) (string, string, int) {
t.Helper()
process := exec.Command(binary, args...)
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"CLAUDE_CONFIG_DIR="+fixture.configDir,
)
var stdout, stderr strings.Builder
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return stdout.String(), stderr.String(), 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return stdout.String(), stderr.String(), exitError.ExitCode()
}
t.Fatalf("run Elydora CLI: %v", err)
return stdout.String(), stderr.String(), -1
}
func TestClaudeCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
repository := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
binary := filepath.Join(t.TempDir(), "elydora")
if runtime.GOOS == "windows" {
binary += ".exe"
}
build := exec.Command("go", "build", "-o", binary, "./cmd/elydora")
build.Dir = repository
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
}
fixture := prepareClaudeFixture(
t,
claudeFixtureOptions{
configEnvPresent: true,
configEnvOverride: filepath.Join(t.TempDir(), "claude config"),
},
)
privateKeyFile := filepath.Join(t.TempDir(), "private.key")
tokenFile := filepath.Join(t.TempDir(), "api.token")
if err := os.WriteFile(privateKeyFile, []byte(claudePrivateKey+"\n"), 0600); err != nil {
t.Fatalf("write private key input: %v", err)
}
if err := os.WriteFile(tokenFile, []byte("token-1\n"), 0600); err != nil {
t.Fatalf("write token input: %v", err)
}
stdout, stderr, exit := claudeRunCLI(
t,
binary,
fixture,
"install",
"--agent", claudeAgentKey,
"--org-id", "org-1",
"--agent-id", claudeTestAgentID,
"--private-key-file", privateKeyFile,
"--token-file", tokenFile,
"--kid", "kid-1",
"--base-url", "http://127.0.0.1:9",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "claude doctor") {
t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr)
}
requireStrictClaudeTriple(t, fixture)
stdout, stderr, exit = claudeRunCLI(
t,
binary,
fixture,
"status",
"--agent", claudeAgentKey,
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr)
}
stdout, stderr, exit = claudeRunCLI(
t,
binary,
fixture,
"uninstall",
"--agent", claudeAgentKey,
"--agent-id", claudeTestAgentID,
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
t.Fatalf("CLI uninstall = %d, %q, %q", exit, stdout, stderr)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed settings remain after CLI uninstall: %v", err)
}
if _, err := os.Lstat(fixture.agentDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime remains after CLI uninstall: %v", err)
}
}
+449
View File
@@ -0,0 +1,449 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net/url"
"reflect"
"sort"
)
const (
claudeAgentKey = "claudecode"
claudeConfigFile = "settings.json"
claudeGuardScript = "guard.js"
claudeAuditScript = "hook.js"
claudeHookTimeout = float64(10)
claudeGuardStatusMessage = "Checking Elydora agent state"
claudeAuditStatusMessage = "Recording Elydora tool use"
)
var claudeHookEvents = stringSet(
"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",
)
var claudeHandlerKeys = map[string]map[string]struct{}{
"command": stringSet(
"if", "once", "statusMessage", "timeout", "args", "async",
"asyncRewake", "command", "rewakeMessage", "rewakeSummary",
"shell", "type",
),
"prompt": stringSet(
"if", "once", "statusMessage", "timeout", "continueOnBlock", "model",
"prompt", "type",
),
"agent": stringSet(
"if", "once", "statusMessage", "timeout", "model", "prompt", "type",
),
"http": stringSet(
"if", "once", "statusMessage", "timeout", "allowedEnvVars", "headers",
"type", "url",
),
"mcp_tool": stringSet(
"if", "once", "statusMessage", "timeout", "input", "server", "tool",
"type",
),
}
type claudeGroup struct {
object map[string]any
handlers []map[string]any
}
type claudeHooks map[string][]claudeGroup
type claudeDocument struct {
exists bool
filePath string
root map[string]any
hooks claudeHooks
hooksDisabled bool
raw []byte
}
type claudeRenderedDocument struct {
document *claudeDocument
changed bool
next []byte
remove bool
}
type claudeRuntimeContract = managedRuntimeContract
func cloneClaudeHooks(source claudeHooks) claudeHooks {
clone := make(claudeHooks, len(source))
for event, groups := range source {
clone[event] = append([]claudeGroup(nil), groups...)
}
return clone
}
func validateClaudeKnownKeys(
value map[string]any,
allowed map[string]struct{},
label string,
) error {
fields := make([]string, 0, len(value))
for field := range value {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := allowed[field]; !ok {
return fmt.Errorf(`%s contains unsupported field %q`, label, field)
}
}
return nil
}
func requireClaudeString(value map[string]any, field, label string) (string, error) {
text, ok := value[field].(string)
if !ok || text == "" {
return "", fmt.Errorf(`%s field %q must be a non-empty string`, label, field)
}
return text, nil
}
func validateClaudeOptionalString(value map[string]any, field, label string) error {
if item, exists := value[field]; exists {
if _, ok := item.(string); !ok {
return fmt.Errorf(`%s field %q must be a string`, label, field)
}
}
return nil
}
func validateClaudeOptionalBoolean(value map[string]any, field, label string) error {
if item, exists := value[field]; exists {
if _, ok := item.(bool); !ok {
return fmt.Errorf(`%s field %q must be a boolean`, label, field)
}
}
return nil
}
func validateClaudeCommonFields(value map[string]any, label string) error {
for _, field := range []string{"if", "statusMessage"} {
if err := validateClaudeOptionalString(value, field, label); err != nil {
return err
}
}
if err := validateClaudeOptionalBoolean(value, "once", label); err != nil {
return err
}
if item, exists := value["timeout"]; exists {
timeout, ok := item.(float64)
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || timeout <= 0 {
return fmt.Errorf("%s timeout must be a positive finite number", label)
}
}
return nil
}
func validateClaudeStringArray(value any, field, label string, nonEmpty bool) error {
items, ok := value.([]any)
if !ok {
return fmt.Errorf(`%s field %q must be an array of strings`, label, field)
}
for _, item := range items {
text, ok := item.(string)
if !ok {
return fmt.Errorf(`%s field %q must be an array of strings`, label, field)
}
if nonEmpty && text == "" {
return fmt.Errorf(
`%s field %q must contain non-empty strings`,
label,
field,
)
}
}
return nil
}
func validateClaudeHTTPHandler(handler map[string]any, label string) error {
rawURL, err := requireClaudeString(handler, "url", label)
if err != nil {
return err
}
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf(`%s field "url" must be a valid URL`, label)
}
if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return fmt.Errorf(`%s field "url" must use HTTP or HTTPS`, label)
}
if headers, exists := handler["headers"]; exists {
object, ok := headers.(map[string]any)
if !ok {
return fmt.Errorf(`%s field "headers" must map names to strings`, label)
}
for _, item := range object {
if _, ok := item.(string); !ok {
return fmt.Errorf(`%s field "headers" must map names to strings`, label)
}
}
}
if allowed, exists := handler["allowedEnvVars"]; exists {
return validateClaudeStringArray(allowed, "allowedEnvVars", label, true)
}
return nil
}
func validateClaudeHandler(
value any,
event string,
groupIndex int,
handlerIndex int,
) (map[string]any, error) {
label := fmt.Sprintf(
"Claude Code settings handler hooks.%s[%d].hooks[%d]",
event,
groupIndex,
handlerIndex,
)
handler, ok := value.(map[string]any)
if !ok || handler == nil {
return nil, fmt.Errorf("%s must be an object", label)
}
handlerType, ok := handler["type"].(string)
allowed, supported := claudeHandlerKeys[handlerType]
if !ok || !supported {
return nil, fmt.Errorf(`%s has unsupported type %q`, label, fmt.Sprint(handler["type"]))
}
if err := validateClaudeKnownKeys(handler, allowed, label); err != nil {
return nil, err
}
if err := validateClaudeCommonFields(handler, label); err != nil {
return nil, err
}
switch handlerType {
case "command":
if _, err := requireClaudeString(handler, "command", label); err != nil {
return nil, err
}
if args, exists := handler["args"]; exists {
if err := validateClaudeStringArray(args, "args", label, false); err != nil {
return nil, err
}
}
for _, field := range []string{"async", "asyncRewake"} {
if err := validateClaudeOptionalBoolean(handler, field, label); err != nil {
return nil, err
}
}
for _, field := range []string{"rewakeMessage", "rewakeSummary"} {
if _, exists := handler[field]; exists {
if _, err := requireClaudeString(handler, field, label); err != nil {
return nil, err
}
}
}
if shell, exists := handler["shell"]; exists &&
shell != "bash" && shell != "powershell" {
return nil, fmt.Errorf(
`%s field "shell" must be "bash" or "powershell"`,
label,
)
}
case "prompt", "agent":
if _, err := requireClaudeString(handler, "prompt", label); err != nil {
return nil, err
}
if err := validateClaudeOptionalString(handler, "model", label); err != nil {
return nil, err
}
if handlerType == "prompt" {
if err := validateClaudeOptionalBoolean(
handler,
"continueOnBlock",
label,
); err != nil {
return nil, err
}
}
case "http":
if err := validateClaudeHTTPHandler(handler, label); err != nil {
return nil, err
}
case "mcp_tool":
for _, field := range []string{"server", "tool"} {
if _, err := requireClaudeString(handler, field, label); err != nil {
return nil, err
}
}
if input, exists := handler["input"]; exists {
if _, ok := input.(map[string]any); !ok {
return nil, fmt.Errorf(`%s field "input" must be an object`, label)
}
}
}
return cloneJSONObject(handler), nil
}
func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) {
label := fmt.Sprintf("Claude Code settings matcher group hooks.%s[%d]", event, groupIndex)
object, ok := value.(map[string]any)
if !ok || object == nil {
return claudeGroup{}, fmt.Errorf("%s must be an object", label)
}
if err := validateClaudeKnownKeys(
object,
stringSet("hooks", "matcher"),
label,
); err != nil {
return claudeGroup{}, err
}
if matcher, exists := object["matcher"]; exists {
if _, ok := matcher.(string); !ok {
return claudeGroup{}, fmt.Errorf("%s matcher must be a string", label)
}
}
values, ok := object["hooks"].([]any)
if !ok {
return claudeGroup{}, fmt.Errorf("%s must contain a hooks array", label)
}
handlers := make([]map[string]any, 0, len(values))
for handlerIndex, value := range values {
handler, err := validateClaudeHandler(value, event, groupIndex, handlerIndex)
if err != nil {
return claudeGroup{}, err
}
handlers = append(handlers, handler)
}
return claudeGroup{object: cloneJSONObject(object), handlers: handlers}, nil
}
func readClaudeHooks(root map[string]any) (claudeHooks, error) {
value, exists := root["hooks"]
if !exists {
return claudeHooks{}, nil
}
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`Claude Code settings field "hooks" must be an object`)
}
hooks := make(claudeHooks, len(object))
for event, groupsValue := range object {
if _, supported := claudeHookEvents[event]; !supported {
return nil, fmt.Errorf(
`Claude Code settings contains unsupported hook event %q`,
event,
)
}
values, ok := groupsValue.([]any)
if !ok {
return nil, fmt.Errorf(
`Claude Code settings field "hooks.%s" must be an array`,
event,
)
}
groups := make([]claudeGroup, 0, len(values))
for index, value := range values {
group, err := validateClaudeGroup(value, event, index)
if err != nil {
return nil, err
}
groups = append(groups, group)
}
hooks[event] = groups
}
return hooks, nil
}
func parseClaudeDocument(filePath string, raw []byte) (*claudeDocument, error) {
label := fmt.Sprintf("Claude Code user settings at %s", filePath)
root, err := decodeStrictJSONObject(raw, label)
if err != nil {
return nil, err
}
disabled := false
if value, exists := root["disableAllHooks"]; exists {
var ok bool
disabled, ok = value.(bool)
if !ok {
return nil, fmt.Errorf(
`Claude Code settings field "disableAllHooks" must be a boolean`,
)
}
}
hooks, err := readClaudeHooks(root)
if err != nil {
return nil, err
}
return &claudeDocument{
exists: true, filePath: filePath, root: root, hooks: hooks,
hooksDisabled: disabled, raw: append([]byte(nil), raw...),
}, nil
}
func createClaudeDocument(filePath string) *claudeDocument {
return &claudeDocument{
filePath: filePath,
root: map[string]any{},
hooks: claudeHooks{},
}
}
func renderClaudeHooks(hooks claudeHooks) map[string]any {
result := make(map[string]any, len(hooks))
for event, groups := range hooks {
values := make([]any, 0, len(groups))
for _, group := range groups {
object := cloneJSONObject(group.object)
handlers := make([]any, 0, len(group.handlers))
for _, handler := range group.handlers {
handlers = append(handlers, handler)
}
object["hooks"] = handlers
values = append(values, object)
}
result[event] = values
}
return result
}
func renderClaudeDocument(
document *claudeDocument,
hooks claudeHooks,
) (*claudeRenderedDocument, error) {
if !document.exists && len(hooks) == 0 {
return &claudeRenderedDocument{document: document}, nil
}
if reflect.DeepEqual(hooks, document.hooks) {
return &claudeRenderedDocument{document: document}, nil
}
if len(hooks) == 0 && entirelyManagedClaudeDocument(document) {
return &claudeRenderedDocument{
document: document, changed: true, remove: true,
}, nil
}
root := cloneJSONObject(document.root)
if len(hooks) == 0 {
delete(root, "hooks")
} else {
root["hooks"] = renderClaudeHooks(hooks)
}
next, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Claude Code user settings: %w", err)
}
next = append(next, '\n')
if _, err := parseClaudeDocument(document.filePath, next); err != nil {
return nil, fmt.Errorf("validate rendered Claude Code user settings: %w", err)
}
return &claudeRenderedDocument{
document: document,
changed: !document.exists || !bytes.Equal(next, document.raw),
next: next,
}, nil
}
@@ -0,0 +1,51 @@
package plugins
import "fmt"
func preflightClaudeInstallation(
config InstallConfig,
document *claudeDocument,
) (*managedRuntimePaths, string, error) {
if document == nil || document.filePath == "" {
return nil, "", fmt.Errorf("Claude Code installation requires a user settings document")
}
if err := validateManagedInstallConfig(config, claudeAgentKey, "Claude Code"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, claudeGuardScript, claudeAuditScript)
if err != nil {
return nil, "", err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, claudeAgentKey, "Claude Code",
); err != nil {
return nil, "", err
}
nodePath, err := resolveAbsoluteNodeRuntime("Claude Code")
if err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func prepareClaudeInstallationChanges(
config InstallConfig,
paths *managedRuntimePaths,
rendered *claudeRenderedDocument,
) ([]*fileChange, error) {
changes, err := managedRuntimeFileChanges(
config,
paths,
claudeAgentKey,
generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""),
buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true),
)
if err != nil {
return nil, err
}
documentChange, err := prepareRenderedClaudeChange(rendered)
if err != nil {
return nil, err
}
return append(changes, documentChange), nil
}
+73
View File
@@ -0,0 +1,73 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
func claudeConfigDirectory() (string, error) {
configured, exists := os.LookupEnv("CLAUDE_CONFIG_DIR")
if !exists {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".claude"), nil
}
resolved, err := filepath.Abs(configured)
if err != nil {
return "", fmt.Errorf("resolve CLAUDE_CONFIG_DIR at %s: %w", configured, err)
}
return resolved, nil
}
func claudeSettingsPath() (string, error) {
directory, err := claudeConfigDirectory()
if err != nil {
return "", err
}
return filepath.Join(directory, claudeConfigFile), nil
}
func readClaudeDocument() (*claudeDocument, error) {
filePath, err := claudeSettingsPath()
if err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(filePath),
"Claude Code configuration directory",
); err != nil {
return nil, err
}
snapshot, err := readManagedFile(
filePath,
"Claude Code user settings",
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
if snapshot == nil {
return createClaudeDocument(filePath), nil
}
return parseClaudeDocument(filePath, snapshot.contents)
}
func prepareRenderedClaudeChange(
rendered *claudeRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSourceChange(
rendered.document.filePath,
"Claude Code user settings",
rendered.document.raw,
rendered.document.exists,
rendered.next,
0600,
rendered.remove,
)
}
@@ -0,0 +1,64 @@
package plugins
import (
"os"
"os/exec"
"strings"
"testing"
)
func TestClaudeOfficialBinaryAcceptsInstalledSettings(t *testing.T) {
binary := os.Getenv("ELYDORA_CLAUDE_BINARY")
if binary == "" {
t.Skip("ELYDORA_CLAUDE_BINARY is unset")
}
source := `{"hooks":{
"Stop":[{"hooks":[{
"type":"command",
"command":"node",
"args":["--version"],
"asyncRewake":true,
"rewakeMessage":"Background validation failed",
"rewakeSummary":"Validation feedback"
}]}]
}}`
fixture := prepareClaudeFixture(
t,
claudeFixtureOptions{existingRaw: &source},
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
environment := append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"CLAUDE_CONFIG_DIR="+fixture.configDir,
"DISABLE_AUTOUPDATER=1",
"DISABLE_TELEMETRY=1",
)
version := exec.Command(binary, "--version")
version.Dir = fixture.projectDir
version.Env = environment
versionOutput, err := version.CombinedOutput()
if err != nil || !strings.Contains(string(versionOutput), "Claude Code") {
t.Fatalf("official Claude version: %v\n%s", err, versionOutput)
}
doctor := exec.Command(binary, "doctor")
doctor.Dir = fixture.projectDir
doctor.Env = environment
doctorOutput, err := doctor.CombinedOutput()
if err != nil {
t.Fatalf("official Claude doctor: %v\n%s", err, doctorOutput)
}
output := strings.ToLower(string(doctorOutput))
for _, problem := range []string{
"invalid settings",
"settings validation",
"failed to parse",
} {
if strings.Contains(output, problem) {
t.Fatalf("official Claude doctor reported %q:\n%s", problem, doctorOutput)
}
}
}
+257
View File
@@ -0,0 +1,257 @@
package plugins
import (
"path/filepath"
"sort"
"strings"
)
type claudeRuntimeReference = managedScriptReference
func claudeRuntimeReferenceForScript(
scriptPath string,
scriptName string,
) (*claudeRuntimeReference, error) {
return resolveManagedScript(scriptPath, scriptName)
}
func claudeLegacyReference(
command string,
scriptName string,
) (*claudeRuntimeReference, error) {
if !strings.HasPrefix(command, "node ") || strings.ContainsAny(command, "\r\n") {
return nil, nil
}
scriptPath := strings.TrimPrefix(command, "node ")
if scriptPath == "" || strings.TrimSpace(scriptPath) != scriptPath {
return nil, nil
}
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
}
func managedClaudeReference(
handler map[string]any,
scriptName string,
statusMessage string,
includeLegacy bool,
) (*claudeRuntimeReference, error) {
if len(handler) == 5 && handler["type"] == "command" &&
handler["timeout"] == claudeHookTimeout &&
handler["statusMessage"] == statusMessage {
nodePath, nodeOK := handler["command"].(string)
args, argsOK := handler["args"].([]any)
if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) &&
argsOK && len(args) == 1 {
if scriptPath, ok := args[0].(string); ok {
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
}
}
}
if includeLegacy && len(handler) == 2 && handler["type"] == "command" {
if command, ok := handler["command"].(string); ok {
return claudeLegacyReference(command, scriptName)
}
}
return nil, nil
}
func buildClaudeGroup(nodePath, scriptPath, statusMessage string) claudeGroup {
return claudeGroup{
object: map[string]any{},
handlers: []map[string]any{{
"type": "command", "command": nodePath, "args": []any{scriptPath},
"timeout": claudeHookTimeout, "statusMessage": statusMessage,
}},
}
}
func exactManagedClaudeGroup(group claudeGroup) bool {
_, hasHooks := group.object["hooks"]
return len(group.object) == 1 && hasHooks
}
func removeManagedClaudeGroups(
groups []claudeGroup,
scriptName string,
statusMessage string,
agentID string,
) ([]claudeGroup, bool, error) {
result := make([]claudeGroup, 0, len(groups))
removed := false
for _, group := range groups {
kept := make([]map[string]any, 0, len(group.handlers))
for _, handler := range group.handlers {
reference, err := managedClaudeReference(
handler,
scriptName,
statusMessage,
true,
)
if err != nil {
return nil, false, err
}
owned := reference != nil &&
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
if owned {
removed = true
continue
}
kept = append(kept, handler)
}
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
object := cloneJSONObject(group.object)
object["hooks"] = group.object["hooks"]
result = append(result, claudeGroup{object: object, handlers: kept})
}
}
return result, removed, nil
}
func removeManagedClaudeHooks(hooks claudeHooks, agentID string) (claudeHooks, error) {
result := cloneClaudeHooks(hooks)
for _, contract := range []struct{ event, script, status string }{
{"PreToolUse", claudeGuardScript, claudeGuardStatusMessage},
{"PostToolUse", claudeAuditScript, claudeAuditStatusMessage},
{"PostToolUseFailure", claudeAuditScript, claudeAuditStatusMessage},
} {
groups, removed, err := removeManagedClaudeGroups(
result[contract.event],
contract.script,
contract.status,
agentID,
)
if err != nil {
return nil, err
}
if !removed {
continue
}
if len(groups) == 0 {
delete(result, contract.event)
} else {
result[contract.event] = groups
}
}
return result, nil
}
func managedClaudeEvent(event string) (string, string, bool) {
switch event {
case "PreToolUse":
return claudeGuardScript, claudeGuardStatusMessage, true
case "PostToolUse", "PostToolUseFailure":
return claudeAuditScript, claudeAuditStatusMessage, true
default:
return "", "", false
}
}
func entirelyManagedClaudeDocument(document *claudeDocument) bool {
if !document.exists || len(document.root) != 1 || len(document.hooks) == 0 {
return false
}
if _, hasHooks := document.root["hooks"]; !hasHooks {
return false
}
handlerCount := 0
for event, groups := range document.hooks {
scriptName, statusMessage, managedEvent := managedClaudeEvent(event)
if !managedEvent || len(groups) == 0 {
return false
}
for _, group := range groups {
if !exactManagedClaudeGroup(group) || len(group.handlers) == 0 {
return false
}
for _, handler := range group.handlers {
reference, err := managedClaudeReference(
handler,
scriptName,
statusMessage,
true,
)
if err != nil || reference == nil {
return false
}
handlerCount++
}
}
}
return handlerCount > 0
}
func claudeReferencesForEvent(
groups []claudeGroup,
scriptName string,
statusMessage string,
) (map[string][]claudeRuntimeReference, error) {
result := map[string][]claudeRuntimeReference{}
for _, group := range groups {
if !exactManagedClaudeGroup(group) {
continue
}
for _, handler := range group.handlers {
reference, err := managedClaudeReference(
handler,
scriptName,
statusMessage,
false,
)
if err != nil {
return nil, err
}
if reference != nil {
key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference)
}
}
}
return result, nil
}
func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error) {
guards, err := claudeReferencesForEvent(
hooks["PreToolUse"],
claudeGuardScript,
claudeGuardStatusMessage,
)
if err != nil {
return nil, err
}
successes, err := claudeReferencesForEvent(
hooks["PostToolUse"],
claudeAuditScript,
claudeAuditStatusMessage,
)
if err != nil {
return nil, err
}
failures, err := claudeReferencesForEvent(
hooks["PostToolUseFailure"],
claudeAuditScript,
claudeAuditStatusMessage,
)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(guards))
for key := range guards {
keys = append(keys, key)
}
sort.Strings(keys)
contracts := make([]claudeRuntimeContract, 0, len(keys))
for _, key := range keys {
guard := guards[key]
success := successes[key]
failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue
}
contracts = append(contracts, claudeRuntimeContract{
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
auditPath: success[0].scriptPath,
})
}
return contracts, nil
}
@@ -0,0 +1,194 @@
package plugins
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
type mismatchAPI struct {
mu sync.Mutex
rejections int
delay time.Duration
staleHead bool
staleLock bool
liveLock bool
statePath string
operations []map[string]any
}
const mismatchExpected = "Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM"
func (api *mismatchAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) {
api.mu.Lock()
defer api.mu.Unlock()
response.Header().Set("Content-Type", "application/json")
if request.Method != http.MethodPost || request.URL.Path != "/v1/operations" {
_ = json.NewEncoder(response).Encode(map[string]any{"agent": map[string]any{"status": "active"}})
return
}
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
http.Error(response, err.Error(), http.StatusBadRequest)
return
}
api.operations = append(api.operations, operation)
count := len(api.operations)
if api.delay > 0 {
api.mu.Unlock()
time.Sleep(api.delay)
api.mu.Lock()
}
if api.statePath != "" {
_ = os.WriteFile(api.statePath, []byte(`{"prev_chain_hash":"`+strings.Repeat("B", 43)+`"}`), 0o600)
}
if count > api.rejections {
response.WriteHeader(http.StatusAccepted)
_, _ = fmt.Fprintf(response, `{"receipt":{"seq_no":%d}}`, count)
return
}
expected := mismatchExpected
if api.rejections != 1 {
expected = strings.Repeat("A", 42) + fmt.Sprint(count)
}
response.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(
response,
`{"error":{"code":"PREV_HASH_MISMATCH","message":"Expected prev_chain_hash \"%s\", got \"x\"."}}`,
expected,
)
}
func runClaudeAuditAgainst(t *testing.T, api *mismatchAPI) (*claudeFixture, []map[string]any, time.Duration) {
t.Helper()
server := httptest.NewServer(api)
defer server.Close()
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
fixture.config.BaseURL = server.URL
fixture.config.Token = "ely_test_token"
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
settings := readClaudeTestObject(t, fixture.configPath)
audit := claudeTestManagedHandler(t, settings, "PostToolUse", claudeAuditScript, claudeAuditStatusMessage)
if api.staleHead {
api.mu.Lock()
api.statePath = filepath.Join(fixture.agentDir, "chain-state.json")
api.mu.Unlock()
}
if api.staleLock || api.liveLock {
lockPath := filepath.Join(fixture.agentDir, "chain-state.json.lock")
var owner []byte
if api.liveLock {
owner = []byte(fmt.Sprint(os.Getpid()))
}
if err := os.WriteFile(lockPath, owner, 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
stale := time.Now().Add(-10 * time.Second)
if err := os.Chtimes(lockPath, stale, stale); err != nil {
t.Fatalf("age stale lock: %v", err)
}
}
started := time.Now()
exit, stdout, stderr := runClaudeHandler(t, audit, fixture, claudeOfficialPayload("PostToolUse"))
elapsed := time.Since(started)
if exit != 0 || stdout != "" || stderr != "" {
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
}
api.mu.Lock()
defer api.mu.Unlock()
return fixture, api.operations, elapsed
}
func TestClaudeAuditRetriesRejectedChainHash(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 1})
if len(operations) != 2 || operations[1]["prev_chain_hash"] != mismatchExpected {
t.Fatalf("operations = %#v", operations)
}
if operations[0]["operation_id"] == operations[1]["operation_id"] || operations[0]["nonce"] == operations[1]["nonce"] {
t.Fatalf("retry reused identifiers: %#v", operations)
}
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
if err != nil {
t.Fatalf("read chain state: %v", err)
}
if !strings.Contains(string(state), fmt.Sprint(operations[1]["chain_hash"])) {
t.Fatalf("chain state %s does not hold %v", state, operations[1]["chain_hash"])
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "resynced to server: Rxlf") {
t.Fatalf("error log = %q, %v", log, err)
}
}
func TestClaudeAuditStopsAfterFiveRejections(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99})
if len(operations) != 5 || operations[4]["prev_chain_hash"] != strings.Repeat("A", 42)+"4" {
t.Fatalf("operations = %#v", operations)
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "rejected prev_chain_hash 5 times") {
t.Fatalf("error log = %q, %v", log, err)
}
}
func TestClaudeAuditStopsRetryingWhenBudgetSpent(t *testing.T) {
fixture, operations, elapsed := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99, delay: 2600 * time.Millisecond})
if elapsed > 7500*time.Millisecond {
t.Fatalf("hook ran %s", elapsed)
}
if len(operations) < 2 || len(operations) > 3 {
t.Fatalf("submissions = %d", len(operations))
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !(strings.Contains(string(log), "aborted") || strings.Contains(string(log), "retry budget exhausted")) {
t.Fatalf("error log = %q, %v", log, err)
}
}
func TestClaudeAuditDoesNotRegressAConcurrentlyAdvancedChainHead(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleHead: true})
if len(operations) != 1 || operations[0]["prev_chain_hash"] != strings.Repeat("A", 43) {
t.Fatalf("operations = %#v", operations)
}
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
if err != nil || !strings.Contains(string(state), strings.Repeat("B", 43)) {
t.Fatalf("chain state = %s, %v", state, err)
}
}
func TestClaudeAuditClearsAStaleChainStateLock(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleLock: true})
if len(operations) != 1 {
t.Fatalf("operations = %#v", operations)
}
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); !os.IsNotExist(err) {
t.Fatalf("stale lock still present: %v", err)
}
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
if err != nil || !strings.Contains(string(state), fmt.Sprint(operations[0]["chain_hash"])) {
t.Fatalf("chain state = %s, %v", state, err)
}
}
func TestClaudeAuditDoesNotReclaimALiveOwnersLock(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{liveLock: true})
if len(operations) != 1 {
t.Fatalf("operations = %#v", operations)
}
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); err != nil {
t.Fatalf("live lock was removed: %v", err)
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "lock timed out") {
t.Fatalf("error log = %q, %v", log, err)
}
}
@@ -0,0 +1,293 @@
package plugins
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func installClaudeRuntimeFixture(
t *testing.T,
status string,
) (*claudeFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
fixture.config.BaseURL = server.URL
fixture.config.Token = "ely_test_token"
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Claude hooks: %v", err)
}
return fixture, api, server
}
func TestClaudeGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) {
fixture, api, server := installClaudeRuntimeFixture(t, "active")
defer server.Close()
settings := readClaudeTestObject(t, fixture.configPath)
guard := claudeTestManagedHandler(
t,
settings,
"PreToolUse",
claudeGuardScript,
claudeGuardStatusMessage,
)
success := claudeTestManagedHandler(
t,
settings,
"PostToolUse",
claudeAuditScript,
claudeAuditStatusMessage,
)
failure := claudeTestManagedHandler(
t,
settings,
"PostToolUseFailure",
claudeAuditScript,
claudeAuditStatusMessage,
)
if exit, stdout, stderr := runClaudeHandler(
t,
guard,
fixture,
claudeOfficialPayload("PreToolUse"),
); exit != 0 || stdout != "" || stderr != "" {
t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr)
}
successPayload := claudeOfficialPayload("PostToolUse")
failurePayload := claudeOfficialPayload("PostToolUseFailure")
for _, item := range []struct {
handler map[string]any
payload map[string]any
}{
{success, successPayload},
{failure, failurePayload},
} {
if exit, stdout, stderr := runClaudeHandler(
t,
item.handler,
fixture,
item.payload,
); exit != 0 || stdout != "" || stderr != "" {
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
}
}
getAuth, postAuth, operations := api.snapshot()
if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" {
t.Fatalf("authorization headers = %q, %q", getAuth, postAuth)
}
if len(operations) != 2 ||
!reflect.DeepEqual(operations[0]["payload"], successPayload) ||
!reflect.DeepEqual(operations[1]["payload"], failurePayload) {
t.Fatalf("Claude native operations = %#v", operations)
}
if operations[0]["prev_chain_hash"] !=
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ||
operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] {
t.Fatalf("Claude operation chain = %#v", operations)
}
}
func TestClaudeGuardUsesOfficialBlockingExitCode(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installClaudeRuntimeFixture(t, status)
defer server.Close()
guard := claudeTestManagedHandler(
t,
readClaudeTestObject(t, fixture.configPath),
"PreToolUse",
claudeGuardScript,
claudeGuardStatusMessage,
)
exit, stdout, stderr := runClaudeHandler(
t,
guard,
fixture,
claudeOfficialPayload("PreToolUse"),
)
if exit != 2 || stdout != "" ||
!strings.Contains(strings.ToLower(stderr), status) {
t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr)
}
})
}
}
func TestClaudeFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) {
fixture, api, server := installClaudeRuntimeFixture(t, "active")
guard := claudeTestManagedHandler(
t,
readClaudeTestObject(t, fixture.configPath),
"PreToolUse",
claudeGuardScript,
claudeGuardStatusMessage,
)
runtimeConfig, err := os.ReadFile(fixture.runtimeConfig)
if err != nil {
t.Fatalf("read runtime config: %v", err)
}
malformedExit, _, malformedError := runClaudeRawHandler(
t,
guard,
fixture,
[]byte("{ malformed"),
)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
configExit, _, configError := runClaudeHandler(
t,
guard,
fixture,
claudeOfficialPayload("PreToolUse"),
)
if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil {
t.Fatalf("restore runtime config: %v", err)
}
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
api.mu.Lock()
api.status = "unknown"
api.mu.Unlock()
statusExit, _, statusError := runClaudeHandler(
t,
guard,
fixture,
claudeOfficialPayload("PreToolUse"),
)
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
server.Close()
apiExit, _, apiError := runClaudeHandler(
t,
guard,
fixture,
claudeOfficialPayload("PreToolUse"),
)
for _, result := range []struct {
exit int
err string
want string
}{
{malformedExit, malformedError, "invalid JSON"},
{configExit, configError, "Failed to read agent config"},
{statusExit, statusError, "invalid agent status"},
{apiExit, apiError, "Failed to resolve agent status"},
} {
if result.exit != 0 || !strings.Contains(result.err, result.want) {
t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want)
}
}
}
func TestClaudeAuditRecordsMalformedInputAndAPIFailures(t *testing.T) {
fixture, api, server := installClaudeRuntimeFixture(t, "active")
defer server.Close()
handler := claudeTestManagedHandler(
t,
readClaudeTestObject(t, fixture.configPath),
"PostToolUseFailure",
claudeAuditScript,
claudeAuditStatusMessage,
)
malformedExit, malformedOutput, malformedError := runClaudeRawHandler(
t,
handler,
fixture,
[]byte("{ malformed"),
)
api.mu.Lock()
api.postStatus = http.StatusServiceUnavailable
api.mu.Unlock()
upstreamExit, upstreamOutput, upstreamError := runClaudeHandler(
t,
handler,
fixture,
claudeOfficialPayload("PostToolUseFailure"),
)
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if malformedExit != 0 || malformedOutput != "" || malformedError != "" ||
upstreamExit != 0 || upstreamOutput != "" || upstreamError != "" ||
err != nil || !strings.Contains(string(log), "invalid JSON") ||
!strings.Contains(string(log), "HTTP 503") {
t.Fatalf(
"audit failures = (%d, %q, %q), (%d, %q, %q), log=%q, err=%v",
malformedExit,
malformedOutput,
malformedError,
upstreamExit,
upstreamOutput,
upstreamError,
log,
err,
)
}
}
func TestClaudeStatusRequiresStrictRuntimeFiles(t *testing.T) {
for _, name := range []string{"config.json", "private.key", "guard.js", "hook.js"} {
t.Run("missing "+name, func(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
if err := os.Remove(filepath.Join(fixture.agentDir, name)); err != nil {
t.Fatalf("remove %s: %v", name, err)
}
status, err := fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured ||
status.HookScriptExists {
t.Fatalf("missing %s status = %#v, %v", name, status, err)
}
})
}
}
func TestClaudeStatusSurfacesInvalidRuntimeMetadata(t *testing.T) {
tests := []struct{ name, source, want string }{
{"malformed", "{ malformed", "parse Elydora runtime config"},
{"unsupported", `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"claudecode","extra":true}`, "unsupported field"},
{"identity", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"claudecode"}`, "identity does not match"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
if err := os.WriteFile(
fixture.runtimeConfig,
[]byte(testCase.source),
0600,
); err != nil {
t.Fatalf("replace runtime config: %v", err)
}
_, err := fixture.plugin.Status()
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("status error = %v, want %q", err, testCase.want)
}
})
}
}
func TestClaudeRuntimeConfigOmitsEmptyToken(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
fixture.config.Token = ""
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
config := readClaudeTestObject(t, fixture.runtimeConfig)
if _, exists := config["token"]; exists {
t.Fatalf("empty token persisted: %#v", config)
}
raw, err := json.Marshal(config)
if err != nil || len(raw) == 0 {
t.Fatalf("runtime config encoding = %q, %v", raw, err)
}
}
@@ -0,0 +1,221 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestClaudeRejectsLinkedSettingsBeforeRuntimeWrites(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
target := filepath.Join(filepath.Dir(fixture.homeDir), "settings-target.json")
source := []byte("{\"owner\":\"protected\"}\n")
if err := os.MkdirAll(fixture.configDir, 0700); err != nil {
t.Fatalf("create Claude config directory: %v", err)
}
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write settings target: %v", err)
}
claudeSymlinkOrSkip(t, target, fixture.configPath)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "not a physical file") {
t.Fatalf("install error = %v", err)
}
current, readErr := os.ReadFile(target)
if readErr != nil || string(current) != string(source) {
t.Fatalf("linked target changed: %q, %v", current, readErr)
}
assertNoClaudeRuntimeWrites(t, fixture)
}
func TestClaudeRejectsLinkedConfigAndRuntimeDirectories(t *testing.T) {
for _, kind := range []string{"config", "runtime"} {
t.Run(kind, func(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
target := filepath.Join(filepath.Dir(fixture.homeDir), kind+"-target")
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create symlink target: %v", err)
}
var linkPath string
if kind == "config" {
linkPath = fixture.configDir
} else {
linkPath = filepath.Join(fixture.homeDir, ".elydora")
}
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
t.Fatalf("create link parent: %v", err)
}
claudeSymlinkOrSkip(t, target, linkPath)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("install error = %v", err)
}
if kind == "config" {
assertNoClaudeRuntimeWrites(t, fixture)
} else if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings exist after runtime rejection: %v", err)
}
})
}
}
func TestClaudeRejectsLinkedRuntimeArtifacts(t *testing.T) {
for _, name := range []string{
"config.json", "private.key", "guard.js", "hook.js", "chain-state.json",
"status-cache.json", "error.log",
} {
t.Run(name, func(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
path := filepath.Join(fixture.agentDir, name)
target := filepath.Join(filepath.Dir(fixture.homeDir), "target-"+name)
source := []byte("protected")
if current, err := os.ReadFile(path); err == nil {
source = current
}
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write runtime target: %v", err)
}
_ = os.Remove(path)
claudeSymlinkOrSkip(t, target, path)
var err error
if name == "private.key" {
_, err = fixture.plugin.Status()
} else {
err = fixture.plugin.PreflightInstall(fixture.config)
}
if err == nil || !strings.Contains(err.Error(), "not a physical file") {
t.Fatalf("linked %s error = %v", name, err)
}
current, readErr := os.ReadFile(target)
if readErr != nil || string(current) != string(source) {
t.Fatalf("linked target changed: %q, %v", current, readErr)
}
})
}
}
func TestClaudeRejectsOrphanedAndMismatchedRuntimeIdentity(t *testing.T) {
for _, kind := range []string{"orphaned", "mismatched"} {
t.Run(kind, func(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent runtime: %v", err)
}
if kind == "orphaned" {
if err := os.WriteFile(fixture.guardPath, []byte("orphaned\n"), 0700); err != nil {
t.Fatalf("write orphaned guard: %v", err)
}
} else {
if err := os.WriteFile(
fixture.runtimeConfig,
[]byte(`{"agent_name":"claudecode","agent_id":"another-agent"}`),
0600,
); err != nil {
t.Fatalf("write mismatched config: %v", err)
}
}
err := fixture.plugin.Install(fixture.config)
want := "identity cannot be verified"
if kind == "mismatched" {
want = "identity does not match"
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("install error = %v, want %q", err, want)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings exist after identity rejection: %v", err)
}
})
}
}
func TestClaudeValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
tests := []struct {
name string
edit func(*claudeFixture)
want string
}{
{"agent name", func(f *claudeFixture) { f.config.AgentName = "codex" }, "requires agent name claudecode"},
{"organization", func(f *claudeFixture) { f.config.OrgID = " " }, "organization ID is required"},
{"key ID", func(f *claudeFixture) { f.config.KID = "\t" }, "key ID is required"},
{"token", func(f *claudeFixture) { f.config.Token = " " }, "token must contain"},
{"private key", func(f *claudeFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
{"base URL", func(f *claudeFixture) {
f.config.BaseURL = "https://api.elydora.com/path?token=secret"
}, "query parameters"},
{"guard path", func(f *claudeFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed agent directory"},
{"audit path", func(f *claudeFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed agent directory"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
testCase.edit(fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings exist after input rejection: %v", err)
}
assertNoClaudeRuntimeWrites(t, fixture)
})
}
}
func TestClaudeStatusRejectsInvalidPrivateKey(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "canonical 32-byte") {
t.Fatalf("invalid key status error = %v", err)
}
}
func TestClaudeOwnershipRequiresExactGroupAndHandlerFields(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
guard := buildClaudeGroup(nodePath, fixture.guardPath, claudeGuardStatusMessage)
audit := buildClaudeGroup(nodePath, fixture.hookPath, claudeAuditStatusMessage)
audit.handlers[0]["once"] = false
writeClaudeTestObject(t, fixture.configPath, map[string]any{
"hooks": map[string]any{
"PreToolUse": []any{map[string]any{
"matcher": "Bash", "hooks": []any{guard.handlers[0]},
}},
"PostToolUse": []any{map[string]any{
"hooks": []any{audit.handlers[0]},
}},
},
})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil {
t.Fatalf("uninstall Claude hooks: %v", err)
}
hooks := requireObject(t, readClaudeTestObject(t, fixture.configPath)["hooks"])
if len(requireArray(t, hooks["PreToolUse"])) != 1 ||
len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("ownership lookalikes changed: %#v", hooks)
}
if _, exists := hooks["PostToolUseFailure"]; exists {
t.Fatalf("managed failure hook remains: %#v", hooks)
}
}
+5
View File
@@ -0,0 +1,5 @@
package plugins
func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil)
}
+334
View File
@@ -0,0 +1,334 @@
package plugins
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestClaudeCodeRegistryUsesOfficialSettingsContract(t *testing.T) {
entry := SupportedAgents[claudeAgentKey]
if entry.Name != "Claude Code" || entry.ConfigDir != "$CLAUDE_CONFIG_DIR" ||
entry.ConfigFile != claudeConfigFile {
t.Fatalf("Claude Code registry entry = %#v", entry)
}
}
func TestClaudeInstallPreservesSettingsAndWritesExactTriple(t *testing.T) {
existing := `{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"model": "sonnet",
"disableAllHooks": false,
"hooks": {
"Notification": [{"matcher":"permission_prompt","hooks":[{"type":"http","url":"https://example.test/hook","timeout":1}] }],
"PreToolUse": [{"matcher":"Bash","hooks":[{"type":"command","command":"existing-command","timeout":5}]}]
}
}
`
fixture := prepareClaudeFixture(
t,
claudeFixtureOptions{existingRaw: &existing},
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
settings := readClaudeTestObject(t, fixture.configPath)
if settings["model"] != "sonnet" || settings["disableAllHooks"] != false {
t.Fatalf("user settings changed: %#v", settings)
}
hooks := requireObject(t, settings["hooks"])
if len(requireArray(t, hooks["Notification"])) != 1 ||
len(requireArray(t, hooks["PreToolUse"])) != 2 {
t.Fatalf("user hooks changed: %#v", hooks)
}
requireStrictClaudeTriple(t, fixture)
first, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read installed settings: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repeat Claude install: %v", err)
}
second, err := os.ReadFile(fixture.configPath)
if err != nil || string(second) != string(first) {
t.Fatalf("idempotent settings = %q, %v", second, err)
}
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured ||
!status.HookScriptExists {
t.Fatalf("Claude status = %#v, %v", status, err)
}
}
func TestClaudeConfigDirMatchesOfficialResolution(t *testing.T) {
tests := []struct {
name string
present bool
configured string
expectedParts []string
}{
{"default", false, "", []string{".claude", "settings.json"}},
{"absolute", true, "ABSOLUTE", []string{"custom claude", "settings.json"}},
{"relative", true, "relative claude", []string{"relative claude", "settings.json"}},
{"empty", true, "", []string{"settings.json"}},
{"literal tilde", true, "~", []string{"~", "settings.json"}},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
options := claudeFixtureOptions{configEnvPresent: testCase.present}
if testCase.configured == "ABSOLUTE" {
root := t.TempDir()
options.configEnvOverride = filepath.Join(root, "custom claude")
} else {
options.configEnvOverride = testCase.configured
}
fixture := prepareClaudeFixture(t, options)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
if _, err := os.Stat(fixture.configPath); err != nil {
t.Fatalf("resolved settings missing: %v", err)
}
for _, part := range testCase.expectedParts {
if !strings.Contains(fixture.configPath, part) {
t.Fatalf("settings path %q lacks %q", fixture.configPath, part)
}
}
})
}
}
func TestClaudeMalformedSettingsFailBeforeRuntimeCreation(t *testing.T) {
source := "{ malformed"
fixture := prepareClaudeFixture(
t,
claudeFixtureOptions{existingRaw: &source},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "parse Claude Code user settings") {
t.Fatalf("install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != source {
t.Fatalf("malformed settings changed: %q, %v", actual, readErr)
}
assertNoClaudeRuntimeWrites(t, fixture)
}
func TestClaudePreservesEveryOfficialHandlerType(t *testing.T) {
source := `{"hooks":{
"SessionStart":[{"hooks":[{"type":"prompt","prompt":"Review context","model":"haiku","continueOnBlock":true,"if":"always","once":false,"statusMessage":"Reviewing","timeout":0.5}]}],
"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"user-command","args":["--safe"],"async":false,"asyncRewake":true,"rewakeMessage":"Background validation failed","rewakeSummary":"Validation feedback","shell":"powershell"}]}],
"Stop":[{"hooks":[{"type":"agent","prompt":"Verify completion","model":"sonnet"}]}],
"Notification":[{"hooks":[{"type":"http","url":"https://example.test/hook","headers":{"Authorization":"Bearer ${TOKEN}"},"allowedEnvVars":["TOKEN"]}]}],
"PostToolUse":[{"hooks":[{"type":"mcp_tool","server":"audit","tool":"record","input":{"source":"claude"}}]}]
}}`
var existing map[string]any
if err := json.Unmarshal([]byte(source), &existing); err != nil {
t.Fatalf("decode user hooks: %v", err)
}
userHooks := requireObject(t, existing["hooks"])
fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
installed := requireObject(t, readClaudeTestObject(t, fixture.configPath)["hooks"])
for event, groups := range userHooks {
actual := requireArray(t, installed[event])[0]
want := groups.([]any)[0]
if !reflect.DeepEqual(actual, want) {
t.Fatalf("%s user hook = %#v, want %#v", event, actual, want)
}
}
}
func TestClaudeInvalidOfficialHookShapesFailBeforeWrites(t *testing.T) {
tests := []struct{ name, source, want string }{
{"duplicate", `{"hooks":{},"hooks":{}}`, "duplicate key"},
{"disable", `{"disableAllHooks":"yes"}`, "must be a boolean"},
{"hooks", `{"hooks":null}`, "must be an object"},
{"event", `{"hooks":{"MadeUp":[]}}`, "unsupported hook event"},
{"event groups", `{"hooks":{"PreToolUse":null}}`, "must be an array"},
{"group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"},
{"group field", `{"hooks":{"PreToolUse":[{"hooks":[],"label":"x"}]}}`, "unsupported field"},
{"matcher", `{"hooks":{"PreToolUse":[{"matcher":1,"hooks":[]}]}}`, "matcher must be a string"},
{"group hooks", `{"hooks":{"PreToolUse":[{}]}}`, "hooks array"},
{"handler", `{"hooks":{"PreToolUse":[{"hooks":[null]}]}}`, "handler"},
{"type", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"file"}]}]}}`, "unsupported type"},
{"handler field", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","invented":true}]}]}}`, "unsupported field"},
{"command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "non-empty string"},
{"args", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","args":[1]}]}]}}`, "array of strings"},
{"timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":0}]}]}}`, "positive finite"},
{"rewake message", `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"x","rewakeMessage":""}]}]}}`, `field "rewakeMessage" must be a non-empty string`},
{"rewake summary", `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"x","rewakeSummary":""}]}]}}`, `field "rewakeSummary" must be a non-empty string`},
{"headers", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"http","url":"https://example.test","headers":{"A":1}}]}]}}`, "map names to strings"},
{"input", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"mcp_tool","server":"s","tool":"t","input":[]}]}]}}`, "must be an object"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClaudeFixture(
t,
claudeFixtureOptions{existingRaw: &testCase.source},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != testCase.source {
t.Fatalf("invalid settings changed: %q, %v", actual, readErr)
}
assertNoClaudeRuntimeWrites(t, fixture)
})
}
}
func TestClaudeDisabledHooksBlockInstallationAndHealth(t *testing.T) {
source := `{"disableAllHooks":true}`
fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "disabled by disableAllHooks") {
t.Fatalf("install error = %v", err)
}
status, statusErr := fixture.plugin.Status()
if statusErr != nil || status.Installed || status.HookConfigured {
t.Fatalf("disabled status = %#v, %v", status, statusErr)
}
assertNoClaudeRuntimeWrites(t, fixture)
}
func TestClaudeExactLegacyHooksMigrateAndLookalikesSurvive(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
guardCommand := "node " + fixture.guardPath
auditCommand := "node " + fixture.hookPath
lookalike := guardCommand + " --inspect"
writeClaudeTestObject(t, fixture.configPath, map[string]any{
"hooks": map[string]any{
"PreToolUse": []any{
map[string]any{"hooks": []any{map[string]any{
"type": "command", "command": guardCommand,
}}},
map[string]any{"hooks": []any{map[string]any{
"type": "command", "command": lookalike,
}}},
},
"PostToolUse": []any{map[string]any{"hooks": []any{map[string]any{
"type": "command", "command": auditCommand,
}}}},
},
})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("migrate Claude hooks: %v", err)
}
requireStrictClaudeTriple(t, fixture)
if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil {
t.Fatalf("uninstall Claude hooks: %v", err)
}
remaining := readClaudeTestObject(t, fixture.configPath)
hooks := requireObject(t, remaining["hooks"])
groups := requireArray(t, hooks["PreToolUse"])
if len(groups) != 1 {
t.Fatalf("legacy lookalikes = %#v", hooks)
}
handler := requireObject(t, requireArray(t, requireObject(t, groups[0])["hooks"])[0])
if handler["command"] != lookalike {
t.Fatalf("legacy lookalike changed: %#v", handler)
}
if _, exists := hooks["PostToolUse"]; exists {
t.Fatalf("managed success hook remains: %#v", hooks)
}
if _, exists := hooks["PostToolUseFailure"]; exists {
t.Fatalf("managed failure hook remains: %#v", hooks)
}
}
func TestClaudeUninstallPreservesUserSettings(t *testing.T) {
source := `{"model":"sonnet","hooks":{"Notification":[]}}`
fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil {
t.Fatalf("uninstall Claude hooks: %v", err)
}
settings := readClaudeTestObject(t, fixture.configPath)
want := map[string]any{
"model": "sonnet", "hooks": map[string]any{"Notification": []any{}},
}
if !reflect.DeepEqual(settings, want) {
t.Fatalf("remaining user settings = %#v", settings)
}
}
func TestClaudeUninstallRemovesEntirelyManagedSettingsFile(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil {
t.Fatalf("uninstall Claude hooks: %v", err)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed settings remain: %v", err)
}
}
func TestClaudeStatusRequiresEnabledUniqueTriple(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
settings := readClaudeTestObject(t, fixture.configPath)
hooks := requireObject(t, settings["hooks"])
delete(hooks, "PostToolUseFailure")
writeClaudeTestObject(t, fixture.configPath, settings)
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("incomplete status = %#v, %v", status, err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("restore Claude hooks: %v", err)
}
settings = readClaudeTestObject(t, fixture.configPath)
hooks = requireObject(t, settings["hooks"])
groups := requireArray(t, hooks["PostToolUseFailure"])
hooks["PostToolUseFailure"] = append(groups, groups[len(groups)-1])
writeClaudeTestObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("duplicate status = %#v, %v", status, err)
}
}
func TestClaudeProjectAndLocalSettingsStayUnchanged(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
projectSettings := filepath.Join(fixture.projectDir, ".claude", "settings.json")
localSettings := filepath.Join(fixture.projectDir, ".claude", "settings.local.json")
if err := os.MkdirAll(filepath.Dir(projectSettings), 0700); err != nil {
t.Fatalf("create project settings directory: %v", err)
}
projectSource := []byte(`{"hooks":{"PreToolUse":[]}}`)
localSource := []byte(`{"model":"haiku"}`)
if err := os.WriteFile(projectSettings, projectSource, 0600); err != nil {
t.Fatalf("write project settings: %v", err)
}
if err := os.WriteFile(localSettings, localSource, 0600); err != nil {
t.Fatalf("write local settings: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
for path, want := range map[string][]byte{
projectSettings: projectSource,
localSettings: localSource,
} {
actual, err := os.ReadFile(path)
if err != nil || string(actual) != string(want) {
t.Fatalf("read-only source changed at %s: %q, %v", path, actual, err)
}
}
}
@@ -0,0 +1,316 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
)
const (
claudeTestAgentID = "agent-1"
claudePrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
)
type claudeFixtureOptions struct {
existingRaw *string
configEnvPresent bool
configEnvOverride string
}
type claudeFixture struct {
plugin *ClaudeCodePlugin
config InstallConfig
homeDir string
projectDir string
configDir string
agentDir string
configPath string
guardPath string
hookPath string
runtimeConfig string
privateKey string
}
func claudeString(value string) *string {
return &value
}
func unsetClaudeConfigDir(t *testing.T) {
t.Helper()
value, existed := os.LookupEnv("CLAUDE_CONFIG_DIR")
if err := os.Unsetenv("CLAUDE_CONFIG_DIR"); err != nil {
t.Fatalf("unset CLAUDE_CONFIG_DIR: %v", err)
}
t.Cleanup(func() {
if existed {
_ = os.Setenv("CLAUDE_CONFIG_DIR", value)
} else {
_ = os.Unsetenv("CLAUDE_CONFIG_DIR")
}
})
}
func prepareClaudeFixture(t *testing.T, options claudeFixtureOptions) *claudeFixture {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(root, "project with spaces")
if err := os.MkdirAll(projectDir, 0700); err != nil {
t.Fatalf("create project directory: %v", err)
}
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatalf("read current directory: %v", err)
}
if err := os.Chdir(projectDir); err != nil {
t.Fatalf("enter project directory: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
var configDir string
if options.configEnvPresent {
t.Setenv("CLAUDE_CONFIG_DIR", options.configEnvOverride)
configDir, err = filepath.Abs(options.configEnvOverride)
if err != nil {
t.Fatalf("resolve fixture config directory: %v", err)
}
} else {
unsetClaudeConfigDir(t)
configDir = filepath.Join(homeDir, ".claude")
}
agentDir := filepath.Join(homeDir, ".elydora", claudeTestAgentID)
configPath := filepath.Join(configDir, claudeConfigFile)
if options.existingRaw != nil {
if err := os.MkdirAll(configDir, 0700); err != nil {
t.Fatalf("create Claude config directory: %v", err)
}
if err := os.WriteFile(configPath, []byte(*options.existingRaw), 0600); err != nil {
t.Fatalf("write Claude settings: %v", err)
}
}
guardPath := filepath.Join(agentDir, claudeGuardScript)
return &claudeFixture{
plugin: &ClaudeCodePlugin{},
config: InstallConfig{
AgentName: claudeAgentKey, OrgID: "org-1", AgentID: claudeTestAgentID,
PrivateKey: claudePrivateKey, KID: "kid-1", Token: "token-1",
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
},
homeDir: homeDir, projectDir: projectDir, configDir: configDir,
agentDir: agentDir, configPath: configPath, guardPath: guardPath,
hookPath: filepath.Join(agentDir, claudeAuditScript),
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
}
}
func readClaudeTestObject(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func writeClaudeTestObject(t *testing.T, path string, value map[string]any) {
t.Helper()
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("marshal Claude settings: %v", err)
}
encoded = append(encoded, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create settings directory: %v", err)
}
if err := os.WriteFile(path, encoded, 0600); err != nil {
t.Fatalf("write Claude settings: %v", err)
}
}
func claudeTestManagedHandler(
t *testing.T,
settings map[string]any,
event string,
scriptName string,
statusMessage string,
) map[string]any {
t.Helper()
hooks := requireObject(t, settings["hooks"])
for _, groupValue := range requireArray(t, hooks[event]) {
group := requireObject(t, groupValue)
if len(group) != 1 {
continue
}
for _, handlerValue := range requireArray(t, group["hooks"]) {
handler := requireObject(t, handlerValue)
reference, err := managedClaudeReference(
handler,
scriptName,
statusMessage,
false,
)
if err != nil {
t.Fatalf("inspect managed %s handler: %v", event, err)
}
if reference != nil {
return handler
}
}
}
t.Fatalf("managed %s handler for %s not found", event, scriptName)
return nil
}
func requireStrictClaudeTriple(t *testing.T, fixture *claudeFixture) {
t.Helper()
settings := readClaudeTestObject(t, fixture.configPath)
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
for _, item := range []struct{ event, script, path, status string }{
{"PreToolUse", claudeGuardScript, fixture.guardPath, claudeGuardStatusMessage},
{"PostToolUse", claudeAuditScript, fixture.hookPath, claudeAuditStatusMessage},
{"PostToolUseFailure", claudeAuditScript, fixture.hookPath, claudeAuditStatusMessage},
} {
handler := claudeTestManagedHandler(
t,
settings,
item.event,
item.script,
item.status,
)
want := map[string]any{
"type": "command", "command": nodePath, "args": []any{item.path},
"timeout": float64(10), "statusMessage": item.status,
}
if !reflect.DeepEqual(handler, want) {
t.Fatalf("%s handler = %#v, want %#v", item.event, handler, want)
}
}
}
func claudeOfficialPayload(event string) map[string]any {
payload := map[string]any{
"session_id": "session-1", "transcript_path": "C:/transcript.jsonl",
"cwd": "C:/project", "permission_mode": "default",
"hook_event_name": event, "tool_name": "Bash",
"tool_input": map[string]any{"command": "echo test"},
"tool_use_id": "call-1",
}
if event == "PostToolUse" {
payload["tool_response"] = map[string]any{"output": "test", "success": true}
}
if event == "PostToolUseFailure" {
payload["error"] = "command failed"
payload["is_interrupt"] = false
}
return payload
}
func runClaudeHandler(
t *testing.T,
handler map[string]any,
fixture *claudeFixture,
payload map[string]any,
) (int, string, string) {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Claude payload: %v", err)
}
return runClaudeRawHandler(t, handler, fixture, encoded)
}
func runClaudeRawHandler(
t *testing.T,
handler map[string]any,
fixture *claudeFixture,
input []byte,
) (int, string, string) {
t.Helper()
command, ok := handler["command"].(string)
if !ok {
t.Fatalf("Claude command = %#v", handler["command"])
}
values := requireArray(t, handler["args"])
args := make([]string, 0, len(values))
for _, value := range values {
argument, ok := value.(string)
if !ok {
t.Fatalf("Claude argument = %#v", value)
}
args = append(args, argument)
}
process := exec.Command(command, args...)
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
)
process.Stdin = bytes.NewReader(input)
var stdout, stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return 0, stdout.String(), stderr.String()
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode(), stdout.String(), stderr.String()
}
t.Fatalf("run Claude handler: %v", err)
return -1, stdout.String(), stderr.String()
}
func assertNoClaudeRuntimeWrites(t *testing.T, fixture *claudeFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertNoClaudeTransactionArtifacts(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
return
}
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
name := entry.Name()
if filepath.Ext(name) == ".tmp" || filepath.Ext(name) == ".rollback" {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk Claude fixture: %v", err)
}
}
func claudeSymlinkOrSkip(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
}
@@ -0,0 +1,195 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func snapshotClaudeFiles(t *testing.T, paths ...string) map[string][]byte {
t.Helper()
result := make(map[string][]byte, len(paths))
for _, path := range paths {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot %s: %v", path, err)
}
result[path] = raw
}
return result
}
func requireClaudeSnapshot(t *testing.T, snapshot map[string][]byte) {
t.Helper()
for path, want := range snapshot {
actual, err := os.ReadFile(path)
if err != nil || string(actual) != string(want) {
t.Fatalf("snapshot changed at %s: %q, %v", path, actual, err)
}
}
}
func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) {
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
settings := readClaudeTestObject(t, fixture.configPath)
settings["owner"] = "user"
delete(requireObject(t, settings["hooks"]), "PostToolUseFailure")
writeClaudeTestObject(t, fixture.configPath, settings)
staleConfig := `{"org_id":"old","agent_id":"agent-1","kid":"old","base_url":"https://old.test","agent_name":"claudecode"}`
for _, item := range []struct{ path, source string }{
{fixture.guardPath, "stale guard\n"},
{fixture.runtimeConfig, staleConfig},
{fixture.privateKey, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
{fixture.hookPath, "stale audit\n"},
} {
if err := os.WriteFile(item.path, []byte(item.source), 0600); err != nil {
t.Fatalf("write stale runtime %s: %v", item.path, err)
}
}
paths := []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.configPath,
}
before := snapshotClaudeFiles(t, paths...)
fixture.plugin.rename = func(source, destination string) error {
if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") {
return errors.New("injected Claude settings failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Claude settings failure") {
t.Fatalf("install error = %v", err)
}
requireClaudeSnapshot(t, before)
assertNoClaudeTransactionArtifacts(t, fixture.homeDir)
}
func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) {
source := `{"owner":"original"}`
fixture := prepareClaudeFixture(
t,
claudeFixtureOptions{existingRaw: &source},
)
document, err := readClaudeDocument()
if err != nil {
t.Fatalf("read Claude document: %v", err)
}
paths, nodePath, err := preflightClaudeInstallation(fixture.config, document)
if err != nil {
t.Fatalf("preflight Claude installation: %v", err)
}
hooks, err := removeManagedClaudeHooks(document.hooks, "")
if err != nil {
t.Fatalf("remove managed Claude hooks: %v", err)
}
for _, item := range []struct{ event, script, status string }{
{"PreToolUse", paths.guardPath, claudeGuardStatusMessage},
{"PostToolUse", paths.auditPath, claudeAuditStatusMessage},
{"PostToolUseFailure", paths.auditPath, claudeAuditStatusMessage},
} {
hooks[item.event] = append(
hooks[item.event],
buildClaudeGroup(nodePath, item.script, item.status),
)
}
rendered, err := renderClaudeDocument(document, hooks)
if err != nil {
t.Fatalf("render Claude document: %v", err)
}
changes, err := prepareClaudeInstallationChanges(fixture.config, paths, rendered)
if err != nil {
t.Fatalf("prepare Claude installation: %v", err)
}
concurrent := []byte(`{"owner":"concurrent"}`)
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err)
}
err = writeManagedChanges(
changes,
"Install Claude Code hooks",
nil,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
"Claude Code configuration directory",
)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(concurrent) {
t.Fatalf("concurrent settings changed: %q, %v", actual, readErr)
}
assertNoClaudeRuntimeWrites(t, fixture)
assertNoClaudeTransactionArtifacts(t, fixture.homeDir)
}
func TestClaudeInstallDetectsConcurrentSettingsIdentityReplacement(t *testing.T) {
source := []byte(`{"owner":"original"}`)
fixture := prepareClaudeFixture(
t,
claudeFixtureOptions{existingRaw: claudeString(string(source))},
)
mutated := false
fixture.plugin.rename = func(stagedPath, destination string) error {
if !mutated && strings.HasSuffix(stagedPath, ".tmp") {
mutated = true
external := fixture.configPath + ".external"
if err := os.Rename(fixture.configPath, external); err != nil {
return err
}
if err := os.WriteFile(fixture.configPath, source, 0600); err != nil {
return err
}
}
return os.Rename(stagedPath, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent identity error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("replacement settings changed: %q, %v", actual, readErr)
}
assertNoClaudeRuntimeWrites(t, fixture)
assertNoClaudeTransactionArtifacts(t, fixture.homeDir)
}
func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
source := `{"owner":"user"}`
fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
before, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read installed settings: %v", err)
}
fixture.plugin.rename = func(source, destination string) error {
if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") {
return errors.New("injected Claude uninstall failure")
}
return os.Rename(source, destination)
}
err = fixture.plugin.Uninstall(claudeTestAgentID)
if err == nil || !strings.Contains(err.Error(), "injected Claude uninstall failure") {
t.Fatalf("uninstall error = %v", err)
}
after, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(after) != string(before) {
t.Fatalf("Claude settings changed: %q, %v", after, readErr)
}
assertNoClaudeTransactionArtifacts(t, fixture.homeDir)
}
+108
View File
@@ -0,0 +1,108 @@
package plugins
import "fmt"
// ClinePlugin manages Cline's native global file hooks.
type ClinePlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *ClinePlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates hook ownership and runtime identity before writes.
func (p *ClinePlugin) PreflightInstall(config InstallConfig) error {
paths, guard, audit, err := readClineHookPair()
if err != nil {
return err
}
if err := requireAvailableClineHook(guard); err != nil {
return err
}
if err := requireAvailableClineHook(audit); err != nil {
return err
}
_, err = preflightClineInstallation(config, paths)
return err
}
func (p *ClinePlugin) Install(config InstallConfig) error {
paths, guard, audit, err := readClineHookPair()
if err != nil {
return err
}
if err := requireAvailableClineHook(guard); err != nil {
return err
}
if err := requireAvailableClineHook(audit); err != nil {
return err
}
runtimePaths, err := preflightClineInstallation(config, paths)
if err != nil {
return err
}
changes, err := prepareClineInstallationChanges(
config,
runtimePaths,
guard,
audit,
)
if err != nil {
return err
}
if err := writeClineChanges(
changes,
"Install Cline hooks",
p.rename,
runtimePaths,
); err != nil {
return err
}
fmt.Println("Cline: user-level PreToolUse and PostToolUse hooks installed.")
return nil
}
func (p *ClinePlugin) Uninstall(agentID string) error {
paths, guard, audit, err := readClineHookPair()
if err != nil {
return err
}
changes, err := prepareClineUninstallChanges(
[]clineHookFile{guard, audit},
agentID,
)
if err != nil {
return err
}
return writeClineHookChanges(
changes,
"Uninstall Cline hooks",
p.rename,
paths,
)
}
func (p *ClinePlugin) Status() (PluginStatus, error) {
paths, guard, audit, err := readClineHookPair()
status := PluginStatus{
AgentName: clineAgentKey,
DisplayName: "Cline",
ConfigPath: paths.hooksDirectory,
}
if err != nil {
return status, err
}
contract, err := clineContractForFiles(guard, audit)
if err != nil || contract == nil {
return status, err
}
status.HookConfigured = true
status.HookScriptExists, err = clineRuntimeFilesExist(contract)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
+304
View File
@@ -0,0 +1,304 @@
package plugins
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
clineAgentKey = "cline"
clineGuardFileName = "PreToolUse.mjs"
clineAuditFileName = "PostToolUse.mjs"
clineGuardScript = "guard.js"
clineAuditScript = "hook.js"
clineMetadataMarker = "// @elydora-cline-hook "
clineMetadataVersion = 1
)
type clineHookMetadata struct {
Version int `json:"version"`
Kind string `json:"kind"`
AgentID string `json:"agentId"`
RuntimePath string `json:"runtimePath"`
}
type clineHookFile struct {
exists bool
filePath string
source string
metadata *clineHookMetadata
}
type clineHookPaths struct {
clineDirectory string
hooksDirectory string
guardPath string
auditPath string
}
type clineRuntimeContract struct {
agentID string
agentDirectory string
guardPath string
auditPath string
}
func clineHomeDirectory() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return home, nil
}
func resolveClineHookFiles() (clineHookPaths, error) {
clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR"))
if clineDirectory == "" {
home, err := clineHomeDirectory()
if err != nil {
return clineHookPaths{}, err
}
clineDirectory = filepath.Join(home, ".cline")
} else {
absolute, err := filepath.Abs(clineDirectory)
if err != nil {
return clineHookPaths{}, fmt.Errorf("resolve CLINE_DIR path: %w", err)
}
clineDirectory = absolute
}
hooksDirectory := filepath.Join(clineDirectory, "hooks")
return clineHookPaths{
clineDirectory: clineDirectory,
hooksDirectory: hooksDirectory,
guardPath: filepath.Join(hooksDirectory, clineGuardFileName),
auditPath: filepath.Join(hooksDirectory, clineAuditFileName),
}, nil
}
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
metadata := clineHookMetadata{
Version: clineMetadataVersion,
Kind: kind,
AgentID: agentID,
RuntimePath: runtimePath,
}
if err := validateClineMetadata(metadata); err != nil {
return clineHookMetadata{}, err
}
return metadata, nil
}
func validateClineMetadata(metadata clineHookMetadata) error {
if metadata.Version != clineMetadataVersion {
return fmt.Errorf("metadata version must be 1")
}
if metadata.Kind != "guard" && metadata.Kind != "audit" {
return fmt.Errorf(`metadata kind must be "guard" or "audit"`)
}
if metadata.AgentID == "" {
return fmt.Errorf("metadata agentId must be a non-empty string")
}
if !filepath.IsAbs(metadata.RuntimePath) {
return fmt.Errorf("metadata runtimePath must be an absolute path")
}
return nil
}
func encodeClineMetadata(metadata clineHookMetadata) (string, error) {
encoded, err := json.Marshal(metadata)
if err != nil {
return "", fmt.Errorf("encode Cline hook metadata: %w", err)
}
return base64.RawURLEncoding.EncodeToString(encoded), nil
}
func clineJSONString(value string) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(encoded), nil
}
func buildClineWrapper(metadata clineHookMetadata) (string, error) {
if err := validateClineMetadata(metadata); err != nil {
return "", err
}
encodedMetadata, err := encodeClineMetadata(metadata)
if err != nil {
return "", err
}
hookKind, err := clineJSONString(metadata.Kind)
if err != nil {
return "", fmt.Errorf("encode Cline hook kind: %w", err)
}
runtimePath, err := clineJSONString(metadata.RuntimePath)
if err != nil {
return "", fmt.Errorf("encode Cline runtime path: %w", err)
}
const template = `#!/usr/bin/env node
__METADATA_LINE__
import { spawn } from 'node:child_process';
const hookKind = __HOOK_KIND__;
const runtimePath = __RUNTIME_PATH__;
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;
});
`
return strings.NewReplacer(
"__METADATA_LINE__", clineMetadataMarker+encodedMetadata,
"__HOOK_KIND__", hookKind,
"__RUNTIME_PATH__", runtimePath,
).Replace(template), nil
}
func decodeClineMetadata(encoded string) (clineHookMetadata, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
}
expected := []string{"version", "kind", "agentId", "runtimePath"}
if len(fields) != len(expected) {
return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set")
}
for _, field := range expected {
if _, exists := fields[field]; !exists {
return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set")
}
}
var metadata clineHookMetadata
if err := json.Unmarshal(raw, &metadata); err != nil {
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
}
if err := validateClineMetadata(metadata); err != nil {
return clineHookMetadata{}, err
}
return metadata, nil
}
func parseClineMetadata(filePath, source string) (*clineHookMetadata, error) {
lines := strings.SplitN(source, "\n", 3)
if len(lines) < 2 {
return nil, nil
}
markerLine := strings.TrimSuffix(lines[1], "\r")
if !strings.HasPrefix(markerLine, clineMetadataMarker) {
return nil, nil
}
metadata, err := decodeClineMetadata(strings.TrimPrefix(markerLine, clineMetadataMarker))
if err != nil {
return nil, fmt.Errorf("parse Elydora Cline hook metadata at %s: %w", filePath, err)
}
return &metadata, nil
}
func assertClineWrapperIntegrity(file clineHookFile) error {
if file.metadata == nil {
return nil
}
expected, err := buildClineWrapper(*file.metadata)
if err != nil {
return err
}
if file.source != expected {
return fmt.Errorf("Elydora Cline hook at %s does not match the managed template", file.filePath)
}
return nil
}
func clineContractForFiles(
guardFile, auditFile clineHookFile,
) (*clineRuntimeContract, error) {
if guardFile.metadata == nil || auditFile.metadata == nil {
return nil, nil
}
if err := assertClineWrapperIntegrity(guardFile); err != nil {
return nil, err
}
if err := assertClineWrapperIntegrity(auditFile); err != nil {
return nil, err
}
guard := guardFile.metadata
audit := auditFile.metadata
if guard.Kind != "guard" || audit.Kind != "audit" {
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
}
if !sameManagedAgentID(guard.AgentID, audit.AgentID) {
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
}
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
if err != nil {
return nil, fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId: %w", err)
}
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
if !sameManagedPath(guard.RuntimePath, expectedGuard) ||
!sameManagedPath(audit.RuntimePath, expectedAudit) {
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
}
return &clineRuntimeContract{
agentID: guard.AgentID,
agentDirectory: agentDirectory,
guardPath: expectedGuard,
auditPath: expectedAudit,
}, nil
}
+111
View File
@@ -0,0 +1,111 @@
package plugins
import "fmt"
type clineRuntimePaths struct {
managedRuntimePaths
hooks clineHookPaths
}
func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) {
if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil {
return nil, err
}
paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript)
if err != nil {
return nil, err
}
return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil
}
func preflightClineInstallation(
config InstallConfig,
hooks clineHookPaths,
) (*clineRuntimePaths, error) {
paths, err := clineAgentPaths(config, hooks)
if err != nil {
return nil, err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
); err != nil {
return nil, err
}
if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil {
return nil, err
}
return paths, nil
}
func clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")),
[]byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true))
}
func prepareClineInstallationChanges(
config InstallConfig,
paths *clineRuntimePaths,
guardFile clineHookFile,
auditFile clineHookFile,
) ([]*fileChange, error) {
if paths == nil {
return nil, fmt.Errorf("prepared Cline installation is required")
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
); err != nil {
return nil, err
}
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
if err != nil {
return nil, fmt.Errorf("build Cline guard metadata: %w", err)
}
auditMetadata, err := buildClineMetadata("audit", config.AgentID, paths.auditPath)
if err != nil {
return nil, fmt.Errorf("build Cline audit metadata: %w", err)
}
guardWrapper, err := buildClineWrapper(guardMetadata)
if err != nil {
return nil, fmt.Errorf("build Cline guard wrapper: %w", err)
}
auditWrapper, err := buildClineWrapper(auditMetadata)
if err != nil {
return nil, fmt.Errorf("build Cline audit wrapper: %w", err)
}
if _, err := clineContractForFiles(
clineHookFile{exists: true, filePath: guardFile.filePath, source: guardWrapper, metadata: &guardMetadata},
clineHookFile{exists: true, filePath: auditFile.filePath, source: auditWrapper, metadata: &auditMetadata},
); err != nil {
return nil, err
}
guardScript, auditScript := clineExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript),
)
if err != nil {
return nil, err
}
for _, item := range []struct {
file clineHookFile
label string
source string
}{
{guardFile, "Cline PreToolUse hook", guardWrapper},
{auditFile, "Cline PostToolUse hook", auditWrapper},
} {
change, err := prepareSourceChange(
item.file.filePath,
item.label,
[]byte(item.file.source),
item.file.exists,
[]byte(item.source),
0700,
false,
)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
return changes, nil
}
+138
View File
@@ -0,0 +1,138 @@
package plugins
import "fmt"
func validateClineHookDirectories(paths clineHookPaths) error {
if _, err := managedPhysicalDirectoryExists(
paths.clineDirectory,
"Cline configuration directory",
); err != nil {
return err
}
_, err := managedPhysicalDirectoryExists(
paths.hooksDirectory,
"Cline hooks directory",
)
return err
}
func readClineHookFile(filePath string) (clineHookFile, error) {
snapshot, err := readManagedFile(filePath, "Cline hook", maxManagedSourceBytes)
if err != nil {
return clineHookFile{}, err
}
if snapshot == nil {
return clineHookFile{filePath: filePath}, nil
}
source := string(snapshot.contents)
metadata, err := parseClineMetadata(filePath, source)
if err != nil {
return clineHookFile{}, err
}
return clineHookFile{
exists: true, filePath: filePath, source: source, metadata: metadata,
}, nil
}
func readClineHookPair() (
clineHookPaths,
clineHookFile,
clineHookFile,
error,
) {
paths, err := resolveClineHookFiles()
if err != nil {
return clineHookPaths{}, clineHookFile{}, clineHookFile{}, err
}
if err := validateClineHookDirectories(paths); err != nil {
return paths, clineHookFile{}, clineHookFile{}, err
}
guard, err := readClineHookFile(paths.guardPath)
if err != nil {
return paths, clineHookFile{}, clineHookFile{}, err
}
audit, err := readClineHookFile(paths.auditPath)
if err != nil {
return paths, clineHookFile{}, clineHookFile{}, err
}
return paths, guard, audit, nil
}
func requireAvailableClineHook(file clineHookFile) error {
if file.exists && file.metadata == nil {
return fmt.Errorf(
"Cline hook at %s already exists and is owned by another integration",
file.filePath,
)
}
return nil
}
func writeClineHookChanges(
changes []*fileChange,
label string,
rename renameFunc,
paths clineHookPaths,
) error {
if !hasFileChanges(changes) {
return nil
}
if err := ensureManagedDirectory(
paths.clineDirectory,
"Cline configuration directory",
); err != nil {
return err
}
if err := ensureManagedDirectory(paths.hooksDirectory, "Cline hooks directory"); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
func writeClineChanges(
changes []*fileChange,
label string,
rename renameFunc,
paths *clineRuntimePaths,
) error {
if !hasFileChanges(changes) {
return nil
}
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(paths.agentDirectory); err != nil {
return err
}
return writeClineHookChanges(changes, label, rename, paths.hooks)
}
func prepareClineUninstallChanges(
files []clineHookFile,
agentID string,
) ([]*fileChange, error) {
changes := make([]*fileChange, 0, len(files))
for _, file := range files {
if file.metadata == nil ||
(agentID != "" && !sameManagedAgentID(file.metadata.AgentID, agentID)) {
continue
}
if err := assertClineWrapperIntegrity(file); err != nil {
return nil, err
}
change, err := prepareSourceChange(
file.filePath,
fmt.Sprintf("Cline %s hook", file.metadata.Kind),
[]byte(file.source),
file.exists,
nil,
0700,
true,
)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
return changes, nil
}
@@ -0,0 +1,81 @@
package plugins
import (
"encoding/json"
"os"
"os/exec"
"reflect"
"strings"
"testing"
)
func runOfficialCline(
t *testing.T,
fixture *clineFixture,
nodePath string,
args ...string,
) (string, error) {
t.Helper()
process := exec.Command(nodePath, args...)
process.Dir = fixture.workspaceDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"CLINE_DIR="+fixture.clineDir,
)
output, err := process.CombinedOutput()
return string(output), err
}
func TestClineOfficialLoaderDiscoversBothManagedFileHooks(t *testing.T) {
coreEntry := os.Getenv("ELYDORA_CLINE_CORE_ENTRY")
clineEntry := os.Getenv("ELYDORA_CLINE_ENTRY")
if coreEntry == "" || clineEntry == "" {
t.Skip("set ELYDORA_CLINE_CORE_ENTRY and ELYDORA_CLINE_ENTRY")
}
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
version, err := runOfficialCline(t, fixture, nodePath, clineEntry, "--version")
foundVersion := false
for _, line := range strings.Split(strings.ReplaceAll(version, "\r\n", "\n"), "\n") {
foundVersion = foundVersion || strings.TrimSpace(line) == "3.0.46"
}
if err != nil || !foundVersion {
t.Fatalf("official Cline version = %q, %v", version, err)
}
source := `
import { pathToFileURL } from 'node:url';
const { listHookConfigFiles } = await import(pathToFileURL(process.env.ELYDORA_CLINE_CORE_ENTRY).href);
console.log(JSON.stringify(listHookConfigFiles(process.env.ELYDORA_WORKSPACE)));
`
process := exec.Command(nodePath, "--input-type=module", "--eval", source)
process.Dir = fixture.workspaceDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"CLINE_DIR="+fixture.clineDir,
"ELYDORA_CLINE_CORE_ENTRY="+coreEntry,
"ELYDORA_WORKSPACE="+fixture.workspaceDir,
)
output, err := process.CombinedOutput()
if err != nil {
t.Fatalf("official Cline hook discovery: %v\n%s", err, output)
}
var actual []map[string]any
if err := json.Unmarshal(output, &actual); err != nil {
t.Fatalf("decode official Cline hook discovery: %v\n%s", err, output)
}
expected := []map[string]any{
{"fileName": "PostToolUse", "hookEventName": "tool_result", "path": fixture.auditWrapper},
{"fileName": "PreToolUse", "hookEventName": "tool_call", "path": fixture.guardWrapper},
}
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("official Cline hooks = %#v", actual)
}
}
+204
View File
@@ -0,0 +1,204 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestClineStatusRequiresExactPhysicalInstallation(t *testing.T) {
for _, target := range []string{
"config", "key", "guard", "audit", "guard-wrapper", "audit-wrapper",
} {
t.Run("missing "+target, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
paths := map[string]string{
"config": fixture.runtimeConfig, "key": fixture.privateKey,
"guard": fixture.guardPath, "audit": fixture.hookPath,
"guard-wrapper": fixture.guardWrapper,
"audit-wrapper": fixture.auditWrapper,
}
if err := os.Remove(paths[target]); err != nil {
t.Fatalf("remove %s: %v", target, err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("missing %s status = %#v, %v", target, status, err)
}
if strings.Contains(target, "wrapper") && status.HookConfigured {
t.Fatalf("missing wrapper remains configured: %#v", status)
}
})
}
}
func TestClineStatusSurfacesTamperingAndInvalidRuntimeMetadata(t *testing.T) {
tests := []struct {
name, target, source, want string
}{
{"guard-source", "guard", "tampered\n", ""},
{"audit-source", "audit", "tampered\n", ""},
{"private-key", "key", "invalid", "canonical 32-byte"},
{"config-malformed", "config", "{ malformed", "parse Elydora runtime config"},
{"config-duplicate", "config", `{"agent_name":"cline","agent_name":"cline"}`, "duplicate key"},
{"config-unsupported", "config", `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"cline","extra":true}`, "unsupported field"},
{"config-identity", "config", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"cline"}`, "identity does not match"},
{"guard-wrapper", "guard-wrapper", "tamper", "managed template"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
paths := map[string]string{
"guard": fixture.guardPath, "audit": fixture.hookPath,
"key": fixture.privateKey, "config": fixture.runtimeConfig,
"guard-wrapper": fixture.guardWrapper,
}
source := testCase.source
if testCase.target == "guard-wrapper" {
source = readClineTestFile(t, paths[testCase.target]) + testCase.source
}
writeClineTestFile(t, paths[testCase.target], []byte(source), 0600)
status, err := fixture.plugin.Status()
if testCase.want == "" {
if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("tampered status = %#v, %v", status, err)
}
return
}
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("status error = %v, want %q", err, testCase.want)
}
})
}
}
func TestClineStatusRejectsInvalidHookContracts(t *testing.T) {
for _, testCase := range []struct {
name, kind, agentID, runtimePath, want string
}{
{"kind", "guard", clineTestAgentID, "", "mismatched event metadata"},
{"agent", "audit", "agent-2", "", "different agents"},
{"path", "audit", clineTestAgentID, "outside", "unexpected runtime path"},
{"segment", "audit", "..", "", "invalid agentId"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
runtimePath := testCase.runtimePath
if runtimePath == "outside" {
runtimePath = filepath.Join(filepath.Dir(filepath.Dir(fixture.agentDir)), "outside", clineAuditScript)
}
if runtimePath == "" {
runtimePath = fixture.hookPath
if testCase.agentID == "agent-2" {
runtimePath = filepath.Join(filepath.Dir(fixture.agentDir), "agent-2", clineAuditScript)
}
}
metadata, err := buildClineMetadata(
testCase.kind,
testCase.agentID,
runtimePath,
)
if err != nil {
t.Fatalf("build invalid contract metadata: %v", err)
}
source, err := buildClineWrapper(metadata)
if err != nil {
t.Fatalf("build invalid contract wrapper: %v", err)
}
writeClineTestFile(t, fixture.auditWrapper, []byte(source), 0700)
if testCase.name == "segment" {
guardMetadata, buildErr := buildClineMetadata(
"guard",
testCase.agentID,
fixture.guardPath,
)
if buildErr != nil {
t.Fatalf("build invalid guard metadata: %v", buildErr)
}
guardSource, buildErr := buildClineWrapper(guardMetadata)
if buildErr != nil {
t.Fatalf("build invalid guard wrapper: %v", buildErr)
}
writeClineTestFile(t, fixture.guardWrapper, []byte(guardSource), 0700)
}
_, err = fixture.plugin.Status()
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("contract error = %v, want %q", err, testCase.want)
}
})
}
}
func TestClineInstallRejectsCollisionsAndCorruptOwnershipBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
opts clineFixtureOptions
want string
}{
{"guard-collision", clineFixtureOptions{ExistingGuard: clineString("// user PreToolUse hook\n")}, "owned by another integration"},
{"audit-collision", clineFixtureOptions{ExistingAudit: clineString("// user PostToolUse hook\n")}, "owned by another integration"},
{"corrupt-metadata", clineFixtureOptions{ExistingGuard: clineString("#!/usr/bin/env node\n// @elydora-cline-hook invalid\n")}, "parse Elydora Cline hook metadata"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, testCase.opts)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v", err)
}
for path, expected := range map[string]*string{
fixture.guardWrapper: testCase.opts.ExistingGuard,
fixture.auditWrapper: testCase.opts.ExistingAudit,
} {
if expected != nil && readClineTestFile(t, path) != *expected {
t.Fatalf("existing hook changed at %s", path)
}
}
for _, path := range []string{
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
} {
requireMissingClineTestFile(t, path)
}
})
}
}
func TestClineUninstallRemovesExactOwnershipAndPreservesAdjacentHooks(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py")
writeClineTestFile(t, userHook, []byte("# user hook\n"), 0600)
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
t.Fatalf("uninstall other agent: %v", err)
}
readClineTestFile(t, fixture.guardWrapper)
readClineTestFile(t, fixture.auditWrapper)
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
t.Fatalf("uninstall Cline hooks: %v", err)
}
for _, path := range []string{fixture.guardWrapper, fixture.auditWrapper} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed hook remains at %s: %v", path, err)
}
}
if readClineTestFile(t, userHook) != "# user hook\n" {
t.Fatal("adjacent user hook changed")
}
}
func TestClineRuntimeConfigOmitsEmptyToken(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
fixture.config.Token = ""
installClineFixture(t, fixture)
if _, exists := readClineTestObject(t, fixture.runtimeConfig)["token"]; exists {
t.Fatal("empty token persisted")
}
}
+96
View File
@@ -0,0 +1,96 @@
package plugins
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func clineOfficialPayload(event string) map[string]any {
payload := map[string]any{
"clineVersion": "3.0.46",
"hookName": event,
"timestamp": "2026-07-19T12:00:00.000Z",
"taskId": "task-1",
"workspaceRoots": []any{
"C:/workspace",
},
"userId": "user-1",
"agent_id": "cline-agent",
"parent_agent_id": nil,
}
if event == "tool_call" {
payload["tool_call"] = map[string]any{
"id": "call-1", "name": "read_file",
"input": map[string]any{"path": "README.md"},
}
payload["preToolUse"] = map[string]any{
"toolName": "read_file",
"parameters": map[string]any{"path": "README.md"},
}
}
return payload
}
func marshalClinePayload(t *testing.T, payload map[string]any) []byte {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Cline payload: %v", err)
}
return encoded
}
func installClineRuntimeFixture(
t *testing.T,
status string,
) (*clineFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareClineFixture(t, clineFixtureOptions{})
fixture.config.BaseURL = server.URL
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Cline hooks: %v", err)
}
return fixture, api, server
}
func TestClineGeneratedGuardEmitsPureCancellationForFrozenAndRevokedAgents(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installClineRuntimeFixture(t, status)
defer server.Close()
result := runClineWrapper(
t,
fixture,
fixture.guardWrapper,
marshalClinePayload(t, clineOfficialPayload("tool_call")),
)
control := decodeClineControl(t, result.stdout)
if result.exitCode != 0 || control["cancel"] != true ||
!strings.Contains(strings.ToLower(result.stderr), status) ||
!strings.Contains(result.stderr, "Tool execution blocked") {
t.Fatalf("guard result = %#v, %#v", result, control)
}
})
}
}
func TestClineGeneratedRuntimesKeepFailuresObservableAndFailOpen(t *testing.T) {
fixture, _, server := installClineRuntimeFixture(t, "active")
payload := marshalClinePayload(t, clineOfficialPayload("tool_call"))
server.Close()
guard := runClineWrapper(t, fixture, fixture.guardWrapper, payload)
audit := runClineWrapper(t, fixture, fixture.auditWrapper, []byte("{ malformed"))
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if guard.exitCode != 0 || !strings.Contains(guard.stderr, "Failed to resolve agent status") ||
audit.exitCode != 0 || err != nil ||
!strings.Contains(strings.ToLower(string(log)), "invalid json") {
t.Fatalf("fail-open results = %#v, %#v, log=%q, err=%v", guard, audit, log, err)
}
}
+15
View File
@@ -0,0 +1,15 @@
package plugins
import "path/filepath"
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
if !sameManagedPath(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) {
return false, nil
}
return managedRuntimeContractExists(
managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath},
clineAgentKey,
"Cline",
clineExpectedScripts,
)
}
+241
View File
@@ -0,0 +1,241 @@
package plugins
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestClineRegistryUsesNativeGlobalHookDirectory(t *testing.T) {
entry := SupportedAgents["cline"]
if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" {
t.Fatalf("Cline registry entry = %#v", entry)
}
plugin, ok := NewPlugin("cline").(*ClinePlugin)
if !ok {
t.Fatalf("Cline plugin is not registered")
}
if !plugin.ManagesGuardRuntime() {
t.Fatal("Cline plugin must own its runtime transaction")
}
}
func TestClineInstallWritesAllSixManagedFilesAtomicallyAndIsIdempotent(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
files := []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
}
original := make(map[string]string, len(files))
for _, path := range files {
original[path] = readClineTestFile(t, path)
}
installClineFixture(t, fixture)
for path, source := range original {
if readClineTestFile(t, path) != source {
t.Fatalf("%s changed during idempotent install", path)
}
}
if !strings.HasPrefix(original[fixture.guardWrapper], "#!/usr/bin/env node\n// @elydora-cline-hook ") {
t.Fatalf("guard wrapper has unexpected header: %q", original[fixture.guardWrapper])
}
if original[fixture.guardPath] != generateGuardScript(
clineAgentKey,
clineTestAgentID,
"",
false,
"",
) {
t.Fatal("guard runtime does not match the generated source")
}
if original[fixture.hookPath] != buildHookScriptWithOutput(
clineAgentKey,
clineTestAgentID,
"",
false,
true,
) {
t.Fatal("audit runtime does not preserve the native payload")
}
if original[fixture.privateKey] != clinePrivateKey {
t.Fatal("private key changed during installation")
}
config := readClineTestObject(t, fixture.runtimeConfig)
wantConfig := map[string]any{
"org_id": "org-1", "agent_id": clineTestAgentID, "kid": "kid-1",
"base_url": "https://api.elydora.test", "token": "token-1",
"agent_name": clineAgentKey,
}
if !reflect.DeepEqual(config, wantConfig) {
t.Fatalf("runtime config = %#v", config)
}
assertNoClineTransactionArtifacts(t, fixture.homeDir)
requireMissingClineTestFile(t, filepath.Join(fixture.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs"))
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs"))
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "hooks", "PreToolUse.mjs"))
}
func TestClineInstallUsesOfficialDefaultWhenClineDirIsEmpty(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
t.Setenv("CLINE_DIR", "")
installClineFixture(t, fixture)
defaultHooks := filepath.Join(fixture.homeDir, ".cline", "hooks")
readClineTestFile(t, filepath.Join(defaultHooks, "PreToolUse.mjs"))
readClineTestFile(t, filepath.Join(defaultHooks, "PostToolUse.mjs"))
requireMissingClineTestFile(t, fixture.guardWrapper)
}
func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
if err := os.WriteFile(
fixture.guardPath,
[]byte("process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"),
0700,
); err != nil {
t.Fatalf("write frozen guard runtime: %v", err)
}
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
captureJSON, err := json.Marshal(capturePath)
if err != nil {
t.Fatalf("marshal capture path: %v", err)
}
captureScript := "const fs = require('node:fs'); const chunks = []; " +
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
"process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", JSON.stringify({" +
"cwd: process.cwd(), input: Buffer.concat(chunks).toString('utf-8')})));\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
t.Fatalf("write capture hook: %v", err)
}
prePayload := []byte(`{"clineVersion":"3.0.46","hookName":"tool_call","taskId":"task-1","tool_call":{"id":"call-1","name":"read_file","input":{"path":"README.md"}}}`)
guard := runClineWrapper(t, fixture, fixture.guardWrapper, prePayload)
if guard.exitCode != 0 || !strings.Contains(guard.stderr, "Agent is frozen by Elydora") {
t.Fatalf("guard result = %#v", guard)
}
control := decodeClineControl(t, guard.stdout)
if control["cancel"] != true || control["errorMessage"] != "Agent is frozen by Elydora." {
t.Fatalf("guard control = %#v", control)
}
postPayload := []byte("{\n \"clineVersion\": \"3.0.46\",\n \"hookName\": \"tool_result\",\n \"taskId\": \"task-1\",\n \"tool_result\": {\"name\": \"read_file\", \"input\": {\"path\": \"README.md\"}}\n}")
audit := runClineWrapper(t, fixture, fixture.auditWrapper, postPayload)
if audit.exitCode != 0 || audit.stdout != "" {
t.Fatalf("audit result = %#v", audit)
}
var captured map[string]any
if err := json.Unmarshal([]byte(readClineTestFile(t, capturePath)), &captured); err != nil {
t.Fatalf("decode captured payload: %v", err)
}
if captured["cwd"] != fixture.workspaceDir || captured["input"] != string(postPayload) {
t.Fatalf("captured payload = %#v", captured)
}
}
func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
operations := make(chan map[string]any, 1)
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
defer request.Body.Close()
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
http.Error(response, err.Error(), http.StatusBadRequest)
return
}
operations <- operation
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusCreated)
_, _ = response.Write([]byte(`{}`))
}))
defer server.Close()
fixture := prepareClineFixture(t, clineFixtureOptions{})
fixture.config.BaseURL = server.URL
installClineFixture(t, fixture)
payloadObject := map[string]any{
"clineVersion": "3.0.46",
"hookName": "tool_result",
"timestamp": "2026-07-19T12:00:00.000Z",
"taskId": "task-1",
"workspaceRoots": []any{
fixture.workspaceDir,
},
"userId": "user-1",
"agent_id": "cline-agent",
"parent_agent_id": nil,
"tool_result": map[string]any{
"id": "call-1", "name": "read_file",
"input": map[string]any{"path": "README.md"},
"output": "ok", "durationMs": float64(5),
},
"postToolUse": map[string]any{
"toolName": "read_file",
"parameters": map[string]any{"path": "README.md"},
"result": "ok", "success": true, "executionTimeMs": float64(5),
},
}
payload, err := json.Marshal(payloadObject)
if err != nil {
t.Fatalf("marshal Cline payload: %v", err)
}
result := runClineWrapper(t, fixture, fixture.auditWrapper, payload)
if result.exitCode != 0 {
t.Fatalf("audit wrapper result = %#v", result)
}
var operation map[string]any
select {
case operation = <-operations:
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for Elydora operation")
}
if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), payloadObject) {
t.Fatalf("operation payload = %#v", operation["payload"])
}
if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) {
t.Fatalf("operation action = %#v", operation["action"])
}
if !reflect.DeepEqual(requireClineTestObject(t, operation["subject"]), map[string]any{"session_id": "task-1"}) {
t.Fatalf("operation subject = %#v", operation["subject"])
}
}
func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) {
passing := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, passing)
if err := os.WriteFile(passing.guardPath, []byte("process.stdin.resume();\n"), 0700); err != nil {
t.Fatalf("write passing guard runtime: %v", err)
}
passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`))
if passResult.exitCode != 0 || passResult.stdout != "" {
t.Fatalf("passing guard result = %#v", passResult)
}
failing := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, failing)
if err := os.WriteFile(
failing.hookPath,
[]byte("process.stderr.write('audit failed\\n'); process.exit(7);\n"),
0700,
); err != nil {
t.Fatalf("write failing audit runtime: %v", err)
}
failResult := runClineWrapper(t, failing, failing.auditWrapper, []byte(`{}`))
if failResult.exitCode != 1 || !strings.Contains(failResult.stderr, "audit failed") ||
!strings.Contains(failResult.stderr, "exited with code 7") {
t.Fatalf("failing audit result = %#v", failResult)
}
}
@@ -0,0 +1,285 @@
package plugins
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const (
clineTestAgentID = "agent-1"
clinePrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
)
type clineFixtureOptions struct {
ExistingAudit *string
ExistingGuard *string
}
type clineFixture struct {
plugin *ClinePlugin
config InstallConfig
homeDir string
workspaceDir string
clineDir string
hooksDir string
agentDir string
guardPath string
hookPath string
guardWrapper string
auditWrapper string
runtimeConfig string
privateKey string
}
type clineCommandResult struct {
exitCode int
stdout string
stderr string
}
func clineString(value string) *string {
return &value
}
func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixture {
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
workspaceDir := filepath.Join(rootDir, "workspace")
clineDir := filepath.Join(rootDir, "custom-cline-home")
hooksDir := filepath.Join(clineDir, "hooks")
agentDir := filepath.Join(homeDir, ".elydora", clineTestAgentID)
guardPath := filepath.Join(agentDir, "guard.js")
hookPath := filepath.Join(agentDir, "hook.js")
guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs")
auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs")
for _, directory := range []string{workspaceDir} {
if err := os.MkdirAll(directory, 0755); err != nil {
t.Fatalf("create fixture directory %s: %v", directory, err)
}
}
writeOptionalClineTestFile(t, guardWrapper, options.ExistingGuard)
writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit)
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("CLINE_DIR", clineDir)
return &clineFixture{
plugin: &ClinePlugin{},
homeDir: homeDir,
workspaceDir: workspaceDir,
clineDir: clineDir,
hooksDir: hooksDir,
agentDir: agentDir,
guardPath: guardPath,
hookPath: hookPath,
guardWrapper: guardWrapper,
auditWrapper: auditWrapper,
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
config: InstallConfig{
AgentName: "cline",
OrgID: "org-1",
AgentID: clineTestAgentID,
PrivateKey: clinePrivateKey,
KID: "kid-1",
Token: "token-1",
BaseURL: "https://api.elydora.test",
GuardScriptPath: guardPath,
},
}
}
func writeOptionalClineTestFile(t *testing.T, path string, contents *string) {
t.Helper()
if contents == nil {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("create directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(*contents), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func installClineFixture(t *testing.T, fixture *clineFixture) {
t.Helper()
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Cline hooks: %v", err)
}
}
func runClineWrapper(
t *testing.T,
fixture *clineFixture,
wrapperPath string,
payload []byte,
) clineCommandResult {
t.Helper()
nodePath, err := exec.LookPath("node")
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// #nosec G204 -- nodePath is resolved with exec.LookPath and the wrapper path is test-owned.
command := exec.CommandContext(ctx, nodePath, wrapperPath)
command.Dir = fixture.workspaceDir
command.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
)
command.Stdin = bytes.NewReader(payload)
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
runErr := command.Run()
if ctx.Err() != nil {
t.Fatalf("Cline wrapper timed out: %v", ctx.Err())
}
exitCode := 0
if runErr != nil {
var exitError *exec.ExitError
if !errors.As(runErr, &exitError) {
t.Fatalf("run Cline wrapper: %v", runErr)
}
exitCode = exitError.ExitCode()
}
return clineCommandResult{exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String()}
}
func readClineTestFile(t *testing.T, path string) string {
t.Helper()
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(contents)
}
func requireMissingClineTestFile(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be missing, got %v", path, err)
}
}
func requireClineTestObject(t *testing.T, value any) map[string]any {
t.Helper()
object, ok := value.(map[string]any)
if !ok {
t.Fatalf("value is not an object: %#v", value)
}
return object
}
func decodeClineControl(t *testing.T, stdout string) map[string]any {
t.Helper()
var control map[string]any
if err := json.Unmarshal([]byte(stdout), &control); err != nil {
t.Fatalf("decode Cline control output: %v", err)
}
return control
}
func writeClineTestFile(
t *testing.T,
path string,
contents []byte,
mode os.FileMode,
) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create parent directory for %s: %v", path, err)
}
if err := os.WriteFile(path, contents, mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func writeClineTestObject(t *testing.T, path string, value map[string]any) {
t.Helper()
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("marshal Cline test object: %v", err)
}
writeClineTestFile(t, path, append(encoded, '\n'), 0600)
}
func readClineTestObject(t *testing.T, path string) map[string]any {
t.Helper()
var value map[string]any
if err := json.Unmarshal([]byte(readClineTestFile(t, path)), &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func assertNoClineRuntimeWrites(t *testing.T, fixture *clineFixture) {
t.Helper()
for _, path := range []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed file exists at %s: %v", path, err)
}
}
}
func assertNoClineTransactionArtifacts(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
return
}
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
name := entry.Name()
if strings.HasSuffix(name, ".tmp") || strings.HasSuffix(name, ".rollback") {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk Cline fixture: %v", err)
}
}
func clineSymlinkOrSkip(t *testing.T, target, link string) {
t.Helper()
if runtime.GOOS == "windows" {
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
return
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
}
func sameClineTestPath(left, right string) bool {
leftPath, leftErr := filepath.Abs(left)
rightPath, rightErr := filepath.Abs(right)
return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath)
}
@@ -0,0 +1,368 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func prepareClineTestChanges(
t *testing.T,
fixture *clineFixture,
) ([]*fileChange, *clineRuntimePaths) {
t.Helper()
paths, guard, audit, err := readClineHookPair()
if err != nil {
t.Fatalf("read Cline hook pair: %v", err)
}
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
if err != nil {
t.Fatalf("preflight Cline installation: %v", err)
}
changes, err := prepareClineInstallationChanges(
fixture.config,
runtimePaths,
guard,
audit,
)
if err != nil {
t.Fatalf("prepare Cline installation: %v", err)
}
return changes, runtimePaths
}
func snapshotClineFiles(t *testing.T, paths ...string) map[string]string {
t.Helper()
result := make(map[string]string, len(paths))
for _, path := range paths {
result[path] = readClineTestFile(t, path)
}
return result
}
func requireClineSnapshot(t *testing.T, snapshot map[string]string) {
t.Helper()
for path, expected := range snapshot {
if actual := readClineTestFile(t, path); actual != expected {
t.Fatalf("snapshot changed at %s", path)
}
}
}
func TestClineInstallRollsBackAllSixFiles(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
strings.HasSuffix(source, ".tmp") {
failed = true
return errors.New("injected Cline audit hook failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Cline audit hook failure") {
t.Fatalf("install error = %v", err)
}
if !failed {
t.Fatal("audit hook failure was not injected")
}
assertNoClineRuntimeWrites(t, fixture)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineInstallRestoresAllSixExistingFilesAfterCommitFailure(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
"org_id": "old-org", "agent_id": clineTestAgentID, "kid": "old-kid",
"base_url": "https://old.elydora.test", "agent_name": clineAgentKey,
})
for path, source := range map[string]string{
fixture.guardPath: "stale guard\n",
fixture.privateKey: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc",
fixture.hookPath: "stale audit\n",
fixture.guardWrapper: readClineTestFile(t, fixture.guardWrapper) + "// stale\n",
fixture.auditWrapper: readClineTestFile(t, fixture.auditWrapper) + "// stale\n",
} {
writeClineTestFile(t, path, []byte(source), 0600)
}
paths := []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
}
before := snapshotClineFiles(t, paths...)
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
strings.HasSuffix(source, ".tmp") {
failed = true
return errors.New("injected existing Cline audit hook failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected existing Cline audit hook failure") {
t.Fatalf("install error = %v", err)
}
requireClineSnapshot(t, before)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestPreparedClineInstallRejectsStaleHookBeforeStaging(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
paths, guard, audit, err := readClineHookPair()
if err != nil {
t.Fatalf("read Cline hook pair: %v", err)
}
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
if err != nil {
t.Fatalf("preflight Cline installation: %v", err)
}
concurrent := []byte("// concurrent user hook\n")
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
_, err = prepareClineInstallationChanges(
fixture.config,
runtimePaths,
guard,
audit,
)
if err == nil || !strings.Contains(err.Error(), "changed before update") {
t.Fatalf("stale hook error = %v", err)
}
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
t.Fatal("concurrent hook changed")
}
for _, path := range []string{
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
} {
requireMissingClineTestFile(t, path)
}
}
func TestPreparedClineInstallRejectsConcurrentHookChange(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
changes, paths := prepareClineTestChanges(t, fixture)
concurrent := []byte("// concurrent user hook\n")
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
err := writeClineChanges(changes, "Install Cline hooks", nil, paths)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent hook error = %v", err)
}
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
t.Fatal("concurrent hook changed")
}
for _, path := range []string{
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
} {
requireMissingClineTestFile(t, path)
}
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineInstallDetectsConcurrentHookIdentityReplacement(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
tampered := readClineTestFile(t, fixture.auditWrapper) + "// stale\n"
writeClineTestFile(t, fixture.auditWrapper, []byte(tampered), 0700)
fixture.config.OrgID = "org-updated"
before := snapshotClineFiles(
t,
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
)
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated && strings.HasSuffix(source, ".tmp") {
mutated = true
external := fixture.auditWrapper + ".external"
if err := os.Rename(fixture.auditWrapper, external); err != nil {
return err
}
if err := os.WriteFile(fixture.auditWrapper, []byte(tampered), 0700); err != nil {
return err
}
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent identity error = %v", err)
}
requireClineSnapshot(t, before)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineOrphanRuntimeArtifactsFailBeforeHookWrites(t *testing.T) {
for _, name := range []string{
"private.key", clineGuardScript, clineAuditScript,
"chain-state.json", "status-cache.json", "error.log",
} {
t.Run(name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
artifact := filepath.Join(fixture.agentDir, name)
writeClineTestFile(t, artifact, []byte("orphan\n"), 0600)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
t.Fatalf("orphan runtime error = %v", err)
}
if readClineTestFile(t, artifact) != "orphan\n" {
t.Fatal("orphan artifact changed")
}
requireMissingClineTestFile(t, fixture.guardWrapper)
requireMissingClineTestFile(t, fixture.auditWrapper)
})
}
}
func TestClineMismatchedRuntimeIdentityFailsBeforeWrites(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
"agent_id": "another-agent", "agent_name": clineAgentKey,
})
original := readClineTestFile(t, fixture.runtimeConfig)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("identity error = %v", err)
}
if readClineTestFile(t, fixture.runtimeConfig) != original {
t.Fatal("runtime config changed")
}
requireMissingClineTestFile(t, fixture.guardWrapper)
}
func TestClineLinkedDirectoriesAndFilesAreRejected(t *testing.T) {
for _, kind := range []string{
"configuration", "hooks", "runtime", "agent-directory", "runtime-config", "hook",
} {
t.Run(kind, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
target := filepath.Join(t.TempDir(), kind+"-target")
switch kind {
case "configuration":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.clineDir)
case "hooks":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
if err := os.MkdirAll(fixture.clineDir, 0700); err != nil {
t.Fatalf("create Cline directory: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.hooksDir)
case "runtime":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
clineSymlinkOrSkip(t, target, filepath.Dir(fixture.agentDir))
case "agent-directory":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
if err := os.MkdirAll(filepath.Dir(fixture.agentDir), 0700); err != nil {
t.Fatalf("create runtime root: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.agentDir)
case "runtime-config":
writeClineTestFile(t, target, []byte(`{"agent_id":"agent-1","agent_name":"cline"}`), 0600)
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent directory: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.runtimeConfig)
case "hook":
installClineFixture(t, fixture)
writeClineTestFile(t, target, []byte("external hook\n"), 0600)
if err := os.Remove(fixture.guardWrapper); err != nil {
t.Fatalf("remove guard wrapper: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.guardWrapper)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical") {
t.Fatalf("linked %s error = %v", kind, err)
}
})
}
}
func TestClineInstallValidatesInputsBeforeWrites(t *testing.T) {
tests := []struct{ name, field, value, want string }{
{"agent-name", "agent_name", "codex", "requires agent name cline"},
{"agent-id", "agent_id", "../escape", "invalid agent ID"},
{"organization", "org_id", " ", "organization ID is required"},
{"key-id", "kid", " ", "key ID is required"},
{"private-key", "private_key", "invalid", "canonical 32-byte"},
{"token", "token", " ", "non-whitespace"},
{"base-url", "base_url", "https://api.elydora.com?q=1", "query parameters"},
{"guard-path", "guard_script_path", "outside", "managed agent directory"},
{"audit-path", "hook_script", "outside", "managed agent directory"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
switch testCase.field {
case "agent_name":
fixture.config.AgentName = testCase.value
case "agent_id":
fixture.config.AgentID = testCase.value
case "org_id":
fixture.config.OrgID = testCase.value
case "kid":
fixture.config.KID = testCase.value
case "private_key":
fixture.config.PrivateKey = testCase.value
case "token":
fixture.config.Token = testCase.value
case "base_url":
fixture.config.BaseURL = testCase.value
case "guard_script_path":
fixture.config.GuardScriptPath = testCase.value
case "hook_script":
fixture.config.HookScript = testCase.value
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("validation error = %v, want %q", err, testCase.want)
}
assertNoClineRuntimeWrites(t, fixture)
})
}
}
func TestClineUninstallRestoresBothHooksWhenSecondRemovalFails(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
before := snapshotClineFiles(t, fixture.guardWrapper, fixture.auditWrapper)
calls := 0
fixture.plugin.rename = func(source, destination string) error {
calls++
if calls == 2 {
return errors.New("injected Cline uninstall failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Uninstall(clineTestAgentID)
if err == nil || !strings.Contains(err.Error(), "injected Cline uninstall failure") {
t.Fatalf("uninstall error = %v", err)
}
requireClineSnapshot(t, before)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineInstallLeavesNoTransactionArtifacts(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
+130
View File
@@ -0,0 +1,130 @@
package plugins
import (
"fmt"
"path/filepath"
)
// CodexPlugin manages OpenAI Codex global user hooks.
type CodexPlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *CodexPlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates every source before the CLI creates runtime state.
func (p *CodexPlugin) PreflightInstall(config InstallConfig) error {
document, err := readCodexDocument()
if err != nil {
return err
}
_, _, err = preflightCodexInstallation(config, document.filePath)
return err
}
func (p *CodexPlugin) Install(config InstallConfig) error {
document, err := readCodexDocument()
if err != nil {
return err
}
paths, nodePath, err := preflightCodexInstallation(config, document.filePath)
if err != nil {
return err
}
hooks := removeManagedCodexHooks(document.hooks, "")
hooks["PreToolUse"] = append(
hooks["PreToolUse"],
codexMatcherGroup(codexHandler(
nodePath,
paths.guardPath,
codexGuardStatusMessage,
)),
)
hooks["PostToolUse"] = append(
hooks["PostToolUse"],
codexMatcherGroup(codexHandler(
nodePath,
paths.auditPath,
codexAuditStatusMessage,
)),
)
rendered, err := renderCodexDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Codex user hooks: %w", err)
}
changes, err := prepareCodexInstallationChanges(config, paths, rendered)
if err != nil {
return err
}
if err := writeManagedChanges(
changes,
"Install Codex hooks",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
"Codex hooks directory",
); err != nil {
return err
}
fmt.Printf(" Codex hooks: %s\n", document.filePath)
fmt.Println(" Codex trust: run /hooks and approve both Elydora command hooks.")
return nil
}
func (p *CodexPlugin) Uninstall(agentID string) error {
document, err := readCodexDocument()
if err != nil {
return err
}
hooks := removeManagedCodexHooks(document.hooks, agentID)
rendered, err := renderCodexDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Codex user hooks: %w", err)
}
change, err := prepareRenderedCodexChange(rendered)
if err != nil {
return err
}
return writeManagedChanges(
[]*fileChange{change},
"Uninstall Codex hooks",
p.rename,
"",
"",
filepath.Dir(document.filePath),
"Codex hooks directory",
)
}
func (p *CodexPlugin) Status() (PluginStatus, error) {
configPath, pathErr := codexConfigPath()
entry := SupportedAgents[codexAgentKey]
status := PluginStatus{
AgentName: codexAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
}
if pathErr != nil {
return status, pathErr
}
document, err := readCodexDocument()
if err != nil {
return status, err
}
contracts, err := codexRuntimeContracts(document.hooks)
if err != nil {
return status, err
}
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookScriptExists, err = codexRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
+70
View File
@@ -0,0 +1,70 @@
package plugins
import "path/filepath"
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
return map[string]any{
"type": "command",
"command": posixSource(runtimePath, scriptPath),
"commandWindows": encodedWindowsCommand(runtimePath, scriptPath),
"timeout": codexHookTimeout,
"statusMessage": statusMessage,
}
}
func codexMatcherGroup(handler map[string]any) map[string]any {
return map[string]any{"matcher": "*", "hooks": []any{handler}}
}
func exactCodexHandlerKeys(handler map[string]any) bool {
if len(handler) != 5 {
return false
}
for _, key := range []string{"type", "command", "commandWindows", "timeout", "statusMessage"} {
if _, exists := handler[key]; !exists {
return false
}
}
return true
}
func codexManagedScriptPath(handler map[string]any, status string) (string, bool) {
if !exactCodexHandlerKeys(handler) || handler["type"] != "command" ||
handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status {
return "", false
}
command, _ := handler["command"].(string)
commandWindows, _ := handler["commandWindows"].(string)
posixRuntime, posixScript, posixOK := parsePOSIXCommand(command)
windowsRuntime, windowsScript, windowsOK := parseEncodedWindowsCommand(commandWindows)
if !windowsOK && posixOK {
expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript)
if commandWindows == expected {
windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true
}
}
if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) ||
!filepath.IsAbs(posixScript) || !isNodeExecutable(posixRuntime) ||
!isNodeExecutable(windowsRuntime) ||
!sameManagedPath(posixRuntime, windowsRuntime) ||
!sameManagedPath(posixScript, windowsScript) {
return "", false
}
return posixScript, true
}
func codexManagedAgentID(
handler map[string]any,
scriptName string,
status string,
) (string, bool) {
scriptPath, managed := codexManagedScriptPath(handler, status)
if !managed {
return "", false
}
reference, err := resolveManagedScript(scriptPath, scriptName)
if err != nil || reference == nil {
return "", false
}
return reference.agentID, true
}
+282
View File
@@ -0,0 +1,282 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"sort"
)
const (
codexAgentKey = "codex"
codexConfigFile = "hooks.json"
codexGuardScript = "guard.js"
codexAuditScript = "hook.js"
codexHookTimeout = float64(10)
codexOwnedDescription = "Elydora audit and freeze enforcement"
codexGuardStatusMessage = "Checking Elydora agent state"
codexAuditStatusMessage = "Recording Elydora tool use"
)
type codexHooks map[string][]map[string]any
type codexDocument struct {
exists bool
filePath string
root map[string]any
hooks codexHooks
raw []byte
}
type codexRenderedDocument struct {
document *codexDocument
changed bool
next []byte
remove bool
}
type codexRuntimeContract = managedRuntimeContract
func cloneCodexHooks(source codexHooks) codexHooks {
clone := make(codexHooks, len(source))
for event, groups := range source {
clone[event] = append([]map[string]any(nil), groups...)
}
return clone
}
func readCodexHooks(value any, label string) (codexHooks, error) {
object, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
}
hooks := make(codexHooks, len(object))
for event, candidate := range object {
values, ok := candidate.([]any)
if !ok {
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
}
groups := make([]map[string]any, 0, len(values))
for groupIndex, value := range values {
group, ok := value.(map[string]any)
if !ok || group == nil {
return nil, fmt.Errorf(
`%s matcher group hooks.%s[%d] must be an object`,
label, event, groupIndex,
)
}
handlerValues, ok := group["hooks"].([]any)
if !ok {
return nil, fmt.Errorf(
`%s matcher group hooks.%s[%d] must contain a hooks array`,
label, event, groupIndex,
)
}
for handlerIndex, handler := range handlerValues {
if object, ok := handler.(map[string]any); !ok || object == nil {
return nil, fmt.Errorf(
`%s handler hooks.%s[%d].hooks[%d] must be an object`,
label, event, groupIndex, handlerIndex,
)
}
}
groups = append(groups, group)
}
hooks[event] = groups
}
return hooks, nil
}
func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) {
label := fmt.Sprintf("Codex user hooks at %s", filePath)
root, err := decodeStrictJSONObject(raw, label)
if err != nil {
return nil, err
}
hooks := codexHooks{}
if value, exists := root["hooks"]; exists {
hooks, err = readCodexHooks(value, label)
if err != nil {
return nil, err
}
}
return &codexDocument{
exists: true, filePath: filePath, root: root,
hooks: hooks, raw: append([]byte(nil), raw...),
}, nil
}
func createCodexDocument(filePath string) *codexDocument {
return &codexDocument{
filePath: filePath,
root: map[string]any{"description": codexOwnedDescription},
hooks: codexHooks{},
}
}
func exactCodexMatcherGroup(group map[string]any) bool {
return len(group) == 2 && group["matcher"] == "*"
}
func removeCodexGroups(
groups []map[string]any,
scriptName string,
status string,
agentID string,
) []map[string]any {
result := make([]map[string]any, 0, len(groups))
for _, group := range groups {
handlers := group["hooks"].([]any)
kept := make([]any, 0, len(handlers))
for _, value := range handlers {
handler := value.(map[string]any)
managedID, managed := codexManagedAgentID(handler, scriptName, status)
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
continue
}
kept = append(kept, handler)
}
if len(kept) > 0 || !exactCodexMatcherGroup(group) {
next := cloneJSONObject(group)
next["hooks"] = kept
result = append(result, next)
}
}
return result
}
func removeManagedCodexHooks(hooks codexHooks, agentID string) codexHooks {
next := cloneCodexHooks(hooks)
for _, contract := range []struct{ event, script, status string }{
{"PreToolUse", codexGuardScript, codexGuardStatusMessage},
{"PostToolUse", codexAuditScript, codexAuditStatusMessage},
} {
groups := removeCodexGroups(
next[contract.event], contract.script, contract.status, agentID,
)
if len(groups) == 0 {
delete(next, contract.event)
} else {
next[contract.event] = groups
}
}
return next
}
func entirelyManagedCodexDocument(document *codexDocument) bool {
if !document.exists || document.root["description"] != codexOwnedDescription ||
len(document.hooks) == 0 {
return false
}
for key := range document.root {
if key != "description" && key != "hooks" {
return false
}
}
handlerCount := 0
for event, groups := range document.hooks {
contract := map[string][2]string{
"PreToolUse": {codexGuardScript, codexGuardStatusMessage},
"PostToolUse": {codexAuditScript, codexAuditStatusMessage},
}[event]
if contract[0] == "" || len(groups) == 0 {
return false
}
for _, group := range groups {
handlers := group["hooks"].([]any)
if !exactCodexMatcherGroup(group) || len(handlers) == 0 {
return false
}
for _, value := range handlers {
if _, managed := codexManagedAgentID(
value.(map[string]any), contract[0], contract[1],
); !managed {
return false
}
handlerCount++
}
}
}
return handlerCount > 0
}
func renderCodexDocument(
document *codexDocument,
hooks codexHooks,
) (*codexRenderedDocument, error) {
if !document.exists && len(hooks) == 0 {
return &codexRenderedDocument{document: document}, nil
}
if document.exists && reflect.DeepEqual(hooks, document.hooks) {
return &codexRenderedDocument{document: document}, nil
}
if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
}
root := cloneJSONObject(document.root)
if len(hooks) == 0 {
delete(root, "hooks")
} else {
root["hooks"] = hooks
}
next, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, err
}
next = append(next, '\n')
return &codexRenderedDocument{
document: document,
changed: !document.exists || !bytes.Equal(next, document.raw),
next: next,
}, nil
}
func managedCodexIDs(
groups []map[string]any,
scriptName string,
status string,
) map[string]string {
result := map[string]string{}
for _, group := range groups {
if !exactCodexMatcherGroup(group) {
continue
}
for _, value := range group["hooks"].([]any) {
agentID, managed := codexManagedAgentID(value.(map[string]any), scriptName, status)
if !managed {
continue
}
result[managedReferenceKey(agentID)] = agentID
}
}
return result
}
func codexRuntimeContracts(hooks codexHooks) ([]codexRuntimeContract, error) {
guards := managedCodexIDs(hooks["PreToolUse"], codexGuardScript, codexGuardStatusMessage)
audits := managedCodexIDs(hooks["PostToolUse"], codexAuditScript, codexAuditStatusMessage)
keys := make([]string, 0, len(guards))
for key := range guards {
if _, exists := audits[key]; exists {
keys = append(keys, key)
}
}
sort.Strings(keys)
root, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
contracts := make([]codexRuntimeContract, 0, len(keys))
for _, key := range keys {
agentID := guards[key]
directory := filepath.Join(root, agentID)
contracts = append(contracts, codexRuntimeContract{
agentID: agentID,
guardPath: filepath.Join(directory, codexGuardScript),
auditPath: filepath.Join(directory, codexAuditScript),
})
}
return contracts, nil
}
+53
View File
@@ -0,0 +1,53 @@
package plugins
import "path/filepath"
func preflightCodexInstallation(
config InstallConfig,
hooksPath string,
) (*managedRuntimePaths, string, error) {
if err := validateManagedInstallConfig(config, codexAgentKey, "Codex"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, codexGuardScript, codexAuditScript)
if err != nil {
return nil, "", err
}
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(hooksPath), "Codex hooks directory",
); err != nil {
return nil, "", err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, codexAgentKey, "Codex",
); err != nil {
return nil, "", err
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func prepareCodexInstallationChanges(
config InstallConfig,
paths *managedRuntimePaths,
rendered *codexRenderedDocument,
) ([]*fileChange, error) {
changes, err := managedRuntimeFileChanges(
config,
paths,
codexAgentKey,
generateGuardScript(codexAgentKey, config.AgentID, "", false, ""),
buildHookScriptWithOutput(codexAgentKey, config.AgentID, "", false, true),
)
if err != nil {
return nil, err
}
documentChange, err := prepareRenderedCodexChange(rendered)
if err != nil {
return nil, err
}
return append(changes, documentChange), nil
}
+87
View File
@@ -0,0 +1,87 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
func codexHomePath() (string, error) {
configured := os.Getenv("CODEX_HOME")
if configured == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".codex"), nil
}
info, err := os.Stat(configured)
if err != nil {
return "", fmt.Errorf("resolve CODEX_HOME at %s: %w", configured, err)
}
if !info.IsDir() {
return "", fmt.Errorf("CODEX_HOME is not a directory: %s", configured)
}
canonical, err := filepath.EvalSymlinks(configured)
if err != nil {
return "", fmt.Errorf("canonicalize CODEX_HOME at %s: %w", configured, err)
}
canonical, err = filepath.Abs(canonical)
if err != nil {
return "", fmt.Errorf("resolve canonical CODEX_HOME at %s: %w", configured, err)
}
exists, err := managedPhysicalDirectoryExists(canonical, "CODEX_HOME")
if err != nil {
return "", err
}
if !exists {
return "", fmt.Errorf("CODEX_HOME is missing: %s", canonical)
}
return canonical, nil
}
func codexConfigPath() (string, error) {
home, err := codexHomePath()
if err != nil {
return "", err
}
return filepath.Join(home, codexConfigFile), nil
}
func readCodexDocument() (*codexDocument, error) {
filePath, err := codexConfigPath()
if err != nil {
return nil, err
}
directory := filepath.Dir(filePath)
if _, err := managedPhysicalDirectoryExists(directory, "Codex hooks directory"); err != nil {
return nil, err
}
snapshot, err := readManagedFile(filePath, "Codex user hooks", maxManagedSourceBytes)
if err != nil {
return nil, err
}
if snapshot == nil {
return createCodexDocument(filePath), nil
}
return parseCodexDocument(filePath, snapshot.contents)
}
func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSourceChange(
rendered.document.filePath,
"Codex user hooks",
rendered.document.raw,
rendered.document.exists,
rendered.next,
0600,
rendered.remove,
)
}
func codexRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
return managedRuntimeFilesPresent(contracts, codexAgentKey)
}
+426
View File
@@ -0,0 +1,426 @@
package plugins
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func codexSymlinkOrSkip(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
}
func assertCodexRuntimeAbsent(t *testing.T, fixture *codexFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig,
fixture.privateKey,
fixture.guardPath,
fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertNoCodexTransactionArtifacts(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(entry.Name(), ".tmp") ||
strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk transaction root: %v", err)
}
}
func TestCodexUsesConfiguredHomeAndLeavesAdditiveSourcesUntouched(t *testing.T) {
fixture := prepareCodexFixture(t, "")
customHome := filepath.Join(t.TempDir(), "custom codex home")
if err := os.MkdirAll(customHome, 0700); err != nil {
t.Fatalf("create CODEX_HOME: %v", err)
}
t.Setenv("CODEX_HOME", customHome)
userTOML := filepath.Join(customHome, "config.toml")
source := []byte("[hooks]\nexample = true\n")
if err := os.WriteFile(userTOML, source, 0600); err != nil {
t.Fatalf("write additive Codex config: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
wantHooks, err := codexConfigPath()
if err != nil {
t.Fatalf("resolve configured Codex hooks: %v", err)
}
status, err := fixture.plugin.Status()
if err != nil || status.ConfigPath != wantHooks || !status.Installed {
t.Fatalf("Codex status = %#v, %v", status, err)
}
actual, err := os.ReadFile(userTOML)
if err != nil || string(actual) != string(source) {
t.Fatalf("additive config changed: %q, %v", actual, err)
}
}
func TestCodexConfiguredHomeMustExistAndBeDirectory(t *testing.T) {
fixture := prepareCodexFixture(t, "")
missing := filepath.Join(t.TempDir(), "missing")
t.Setenv("CODEX_HOME", missing)
if err := fixture.plugin.PreflightInstall(fixture.config); err == nil ||
!strings.Contains(err.Error(), "resolve CODEX_HOME") {
t.Fatalf("missing CODEX_HOME error = %v", err)
}
filePath := filepath.Join(t.TempDir(), "codex-home-file")
if err := os.WriteFile(filePath, []byte("file"), 0600); err != nil {
t.Fatalf("write CODEX_HOME file: %v", err)
}
t.Setenv("CODEX_HOME", filePath)
if _, err := codexConfigPath(); err == nil || !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("file CODEX_HOME error = %v", err)
}
assertCodexRuntimeAbsent(t, fixture)
}
func TestCodexConfiguredHomeCanonicalizesDirectoryLinks(t *testing.T) {
fixture := prepareCodexFixture(t, "")
target := filepath.Join(t.TempDir(), "codex-target")
link := filepath.Join(t.TempDir(), "codex-link")
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create CODEX_HOME target: %v", err)
}
codexSymlinkOrSkip(t, target, link)
t.Setenv("CODEX_HOME", link)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install through canonical CODEX_HOME: %v", err)
}
if _, err := os.Stat(filepath.Join(target, codexConfigFile)); err != nil {
t.Fatalf("canonical hooks file missing: %v", err)
}
}
func TestCodexUninstallPreservesUnconfiguredEmptyHooks(t *testing.T) {
source := []byte("{\r\n \"description\": \"user\",\r\n \"hooks\": {}\r\n}\r\n")
fixture := prepareCodexFixture(t, string(source))
if err := fixture.plugin.Uninstall(codexTestAgentID); err != nil {
t.Fatalf("uninstall unconfigured Codex hooks: %v", err)
}
actual, err := os.ReadFile(fixture.configPath)
if err != nil || string(actual) != string(source) {
t.Fatalf("unconfigured hooks changed: %q, %v", actual, err)
}
}
func TestCodexInstallMigratesLegacyCommandHandlers(t *testing.T) {
fixture := prepareCodexFixture(t, "")
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
legacy := func(scriptPath, status string) map[string]any {
return map[string]any{
"type": "command",
"command": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath),
"commandWindows": quoteWindowsArgument(nodePath) + " " +
quoteWindowsArgument(scriptPath),
"timeout": codexHookTimeout, "statusMessage": status,
}
}
settings := map[string]any{"hooks": map[string]any{
"PreToolUse": []any{codexMatcherGroup(legacy(fixture.guardPath, codexGuardStatus))},
"PostToolUse": []any{codexMatcherGroup(legacy(fixture.hookPath, codexAuditStatus))},
}}
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatalf("encode legacy hooks: %v", err)
}
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
t.Fatalf("create hooks directory: %v", err)
}
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
t.Fatalf("write legacy hooks: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("migrate legacy Codex hooks: %v", err)
}
hooks := requireObject(t, readCodexTestObject(t, fixture.configPath)["hooks"])
if len(requireArray(t, hooks["PreToolUse"])) != 1 ||
len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("legacy handlers remain: %#v", hooks)
}
guard := requireCodexHandler(t, map[string]any{"hooks": hooks}, "PreToolUse", codexGuardStatus)
if !strings.Contains(guard["commandWindows"].(string), "EncodedCommand") {
t.Fatalf("migrated Windows command = %q", guard["commandWindows"])
}
}
func TestCodexRejectsLinkedDefaultHooksDirectory(t *testing.T) {
fixture := prepareCodexFixture(t, "")
target := t.TempDir()
codexSymlinkOrSkip(t, target, filepath.Dir(fixture.configPath))
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("linked hooks directory error = %v", err)
}
assertCodexRuntimeAbsent(t, fixture)
entries, readErr := os.ReadDir(target)
if readErr != nil || len(entries) != 0 {
t.Fatalf("linked directory target changed: %#v, %v", entries, readErr)
}
}
func TestCodexRejectsLinkedHookAndRuntimeFiles(t *testing.T) {
for _, name := range []string{"hooks", "config", "key", "guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
path := map[string]string{
"hooks": fixture.configPath, "config": fixture.runtimeConfig,
"key": fixture.privateKey, "guard": fixture.guardPath,
"audit": fixture.hookPath,
}[name]
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create linked file directory: %v", err)
}
target := filepath.Join(t.TempDir(), name+"-target")
source := []byte("external\n")
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write linked target: %v", err)
}
codexSymlinkOrSkip(t, target, path)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical file") {
t.Fatalf("linked %s error = %v", name, err)
}
actual, readErr := os.ReadFile(target)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("linked %s target changed: %q, %v", name, actual, readErr)
}
})
}
}
func TestCodexRejectsOrphanAndMismatchedRuntimeIdentity(t *testing.T) {
for _, testCase := range []struct {
name, config string
}{
{"orphan", ""},
{"mismatch", `{"agent_name":"cursor","agent_id":"agent-1"}`},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if testCase.config != "" {
if err := os.WriteFile(fixture.runtimeConfig, []byte(testCase.config), 0600); err != nil {
t.Fatalf("write runtime config: %v", err)
}
} else if err := os.WriteFile(fixture.guardPath, []byte("orphan"), 0700); err != nil {
t.Fatalf("write orphan guard: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "runtime") {
t.Fatalf("runtime identity error = %v", err)
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Codex hooks exist after identity rejection: %v", err)
}
})
}
}
func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) {
source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`)
fixture := prepareCodexFixture(t, string(source))
fixture.plugin.rename = func(source, destination string) error {
if sameManagedPath(destination, fixture.configPath) {
return errors.New("injected Codex hooks commit failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Codex hooks commit failure") {
t.Fatalf("install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("hooks config changed: %q, %v", actual, readErr)
}
assertCodexRuntimeAbsent(t, fixture)
assertNoCodexTransactionArtifacts(t, fixture.homeDir)
}
func TestCodexInstallPreservesConcurrentHooksMutation(t *testing.T) {
fixture := prepareCodexFixture(t, `{"hooks":{}}`)
concurrent := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"command":"concurrent"}]}]}}`)
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated && strings.HasSuffix(source, ".tmp") {
mutated = true
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
return err
}
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(concurrent) {
t.Fatalf("concurrent hooks changed: %q, %v", actual, readErr)
}
assertCodexRuntimeAbsent(t, fixture)
assertNoCodexTransactionArtifacts(t, fixture.homeDir)
}
func TestCodexInstallDetectsConcurrentHooksIdentityReplacement(t *testing.T) {
source := []byte(`{"hooks":{}}`)
fixture := prepareCodexFixture(t, string(source))
mutated := false
fixture.plugin.rename = func(stagedPath, destination string) error {
if !mutated && strings.HasSuffix(stagedPath, ".tmp") {
mutated = true
oldPath := fixture.configPath + ".external"
if err := os.Rename(fixture.configPath, oldPath); err != nil {
return err
}
if err := os.WriteFile(fixture.configPath, source, 0600); err != nil {
return err
}
}
return os.Rename(stagedPath, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent identity error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("replacement hooks changed: %q, %v", actual, readErr)
}
assertCodexRuntimeAbsent(t, fixture)
assertNoCodexTransactionArtifacts(t, fixture.homeDir)
}
func TestCodexStatusRejectsLinkedRuntimeFiles(t *testing.T) {
for _, name := range []string{"config", "key", "guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
path := map[string]string{
"config": fixture.runtimeConfig, "key": fixture.privateKey,
"guard": fixture.guardPath, "audit": fixture.hookPath,
}[name]
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read runtime file: %v", err)
}
target := filepath.Join(t.TempDir(), name+"-target")
if err := os.WriteFile(target, content, 0600); err != nil {
t.Fatalf("write linked status target: %v", err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove runtime file: %v", err)
}
codexSymlinkOrSkip(t, target, path)
status, err := fixture.plugin.Status()
if err == nil || status.Installed {
t.Fatalf("Codex status = %#v, %v", status, err)
}
})
}
}
func TestCodexStatusRequiresExactMatcherAndHandlerIdentity(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
settings := readCodexTestObject(t, fixture.configPath)
hooks := requireObject(t, settings["hooks"])
preGroup := requireObject(t, requireArray(t, hooks["PreToolUse"])[0])
preGroup["matcher"] = "Bash"
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatalf("encode modified hooks: %v", err)
}
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
t.Fatalf("write modified hooks: %v", err)
}
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("modified matcher status = %#v, %v", status, err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Codex hooks: %v", err)
}
settings = readCodexTestObject(t, fixture.configPath)
handler := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
handler["extra"] = true
encoded, err = json.Marshal(settings)
if err != nil {
t.Fatalf("encode modified handler: %v", err)
}
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
t.Fatalf("write modified handler: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("modified handler status = %#v, %v", status, err)
}
}
func TestCodexInstallValidatesIdentityCredentialsAndOrigin(t *testing.T) {
for _, testCase := range []struct {
name string
mutate func(*InstallConfig)
}{
{"agent name", func(config *InstallConfig) { config.AgentName = "cursor" }},
{"agent ID", func(config *InstallConfig) { config.AgentID = "../escape" }},
{"private key", func(config *InstallConfig) { config.PrivateKey = "invalid" }},
{"scheme", func(config *InstallConfig) { config.BaseURL = "file:///tmp/elydora" }},
{"credentials", func(config *InstallConfig) { config.BaseURL = "https://user:secret@api.elydora.com" }},
{"query", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com?tenant=one" }},
{"backslash", func(config *InstallConfig) { config.BaseURL = `https://api.elydora.com\evil` }},
{"space", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com/invalid path" }},
{"port", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com:invalid" }},
{"guard path", func(config *InstallConfig) { config.GuardScriptPath = "unmanaged.js" }},
{"audit path", func(config *InstallConfig) { config.HookScript = "unmanaged.js" }},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
testCase.mutate(&fixture.config)
err := fixture.plugin.Install(fixture.config)
if err == nil {
t.Fatal("install accepted invalid configuration")
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Codex hooks exist after validation failure: %v", err)
}
})
}
}
+374
View File
@@ -0,0 +1,374 @@
package plugins
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
)
type codexTestAPI struct {
mu sync.Mutex
status string
postStatus int
getAuth string
postAuth string
postBody string
operations []map[string]any
}
func (api *codexTestAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) {
api.mu.Lock()
defer api.mu.Unlock()
response.Header().Set("Content-Type", "application/json")
switch {
case request.Method == http.MethodGet &&
request.URL.EscapedPath() == "/v1/agents/agent-1":
api.getAuth = request.Header.Get("Authorization")
_ = json.NewEncoder(response).Encode(map[string]any{
"agent": map[string]any{"status": api.status},
})
case request.Method == http.MethodPost && request.URL.Path == "/v1/operations":
api.postAuth = request.Header.Get("Authorization")
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
http.Error(response, err.Error(), http.StatusBadRequest)
return
}
api.operations = append(api.operations, operation)
status := api.postStatus
if status == 0 {
status = http.StatusCreated
}
response.WriteHeader(status)
body := api.postBody
if body == "" {
body = `{"operation":{"accepted":true}}`
}
_, _ = response.Write([]byte(body))
default:
http.NotFound(response, request)
}
}
func (api *codexTestAPI) snapshot() (string, string, []map[string]any) {
api.mu.Lock()
defer api.mu.Unlock()
return api.getAuth, api.postAuth, append([]map[string]any(nil), api.operations...)
}
func codexCommandKey() string {
if os.PathSeparator == '\\' {
return "commandWindows"
}
return "command"
}
func codexOfficialPayload(event string) map[string]any {
payload := map[string]any{
"hook_event_name": event,
"session_id": "session-1",
"turn_id": "turn-1",
"transcript_path": nil,
"cwd": "C:/workspace",
"model": "gpt-5",
"permission_mode": "default",
"tool_name": "Bash",
"tool_use_id": "call-1",
"tool_input": map[string]any{"command": "echo test"},
}
if event == "PostToolUse" {
payload["tool_response"] = map[string]any{
"output": "test",
"success": true,
}
}
return payload
}
func installCodexRuntimeFixture(
t *testing.T,
status string,
) (*codexFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareCodexFixture(t, "")
fixture.config.BaseURL = server.URL
fixture.config.Token = "ely_test_token"
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Codex hooks: %v", err)
}
return fixture, api, server
}
func TestCodexGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) {
fixture, api, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
settings := readCodexTestObject(t, fixture.configPath)
key := codexCommandKey()
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
audit := requireCodexHandler(t, settings, "PostToolUse", codexAuditStatus)
if exitCode, stderr := runCodexCommand(
t, guard[key].(string), fixture.homeDir, codexOfficialPayload("PreToolUse"),
); exitCode != 0 || stderr != "" {
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
}
first := codexOfficialPayload("PostToolUse")
second := codexOfficialPayload("PostToolUse")
second["tool_use_id"] = "call-2"
second["tool_response"] = map[string]any{"error": "command failed", "success": false}
for _, payload := range []map[string]any{first, second} {
if exitCode, stderr := runCodexCommand(
t, audit[key].(string), fixture.homeDir, payload,
); exitCode != 0 || stderr != "" {
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
}
}
getAuth, postAuth, operations := api.snapshot()
if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" {
t.Fatalf("authorization headers = %q, %q", getAuth, postAuth)
}
if len(operations) != 2 || !reflect.DeepEqual(operations[0]["payload"], first) ||
!reflect.DeepEqual(operations[1]["payload"], second) {
t.Fatalf("native operations = %#v", operations)
}
if operations[0]["prev_chain_hash"] != "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ||
operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] {
t.Fatalf("operation chain = %#v", operations)
}
}
func TestCodexGeneratedGuardBlocksFrozenAndRevokedAgents(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, status)
defer server.Close()
guard := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PreToolUse", codexGuardStatus,
)
exitCode, stderr := runCodexCommand(
t, guard[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PreToolUse"),
)
if exitCode != 2 || !strings.Contains(stderr, "Tool execution blocked") {
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
}
})
}
}
func TestCodexFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) {
fixture, api, server := installCodexRuntimeFixture(t, "active")
settings := readCodexTestObject(t, fixture.configPath)
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
command := guard[codexCommandKey()].(string)
runtimeConfig, err := os.ReadFile(fixture.runtimeConfig)
if err != nil {
t.Fatalf("read runtime config: %v", err)
}
malformedExit, malformedError := runCodexRawCommand(
t, command, fixture.homeDir, []byte("{ malformed"),
)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
configExit, configError := runCodexCommand(
t, command, fixture.homeDir, codexOfficialPayload("PreToolUse"),
)
if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil {
t.Fatalf("restore runtime config: %v", err)
}
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
api.mu.Lock()
api.status = "unknown"
api.mu.Unlock()
statusExit, statusError := runCodexCommand(
t, command, fixture.homeDir, codexOfficialPayload("PreToolUse"),
)
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
server.Close()
apiExit, apiError := runCodexCommand(
t, command, fixture.homeDir, codexOfficialPayload("PreToolUse"),
)
for _, result := range []struct {
exit int
err string
want string
}{
{malformedExit, malformedError, "invalid JSON"},
{configExit, configError, "Failed to read agent config"},
{statusExit, statusError, "invalid agent status"},
{apiExit, apiError, "Failed to resolve agent status"},
} {
if result.exit != 0 || !strings.Contains(result.err, result.want) {
t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want)
}
}
}
func TestCodexFailOpenAuditRecordsRuntimeAndAPIFailures(t *testing.T) {
for _, testCase := range []struct {
name, target, source, want string
}{
{"input", "input", "{ malformed", "invalid JSON"},
{"config", "config.json", "{ malformed", "agent config"},
{"key", "private.key", "invalid", "Private key"},
{"chain", "chain-state.json", "{ malformed", "Chain state"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
audit := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PostToolUse", codexAuditStatus,
)
input := []byte(testCase.source)
if testCase.target != "input" {
if err := os.WriteFile(
filepath.Join(fixture.agentDir, testCase.target),
[]byte(testCase.source),
0600,
); err != nil {
t.Fatalf("write invalid runtime source: %v", err)
}
input, _ = json.Marshal(codexOfficialPayload("PostToolUse"))
}
exitCode, _ := runCodexRawCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir, input,
)
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if exitCode != 0 || err != nil ||
!strings.Contains(strings.ToLower(string(log)), strings.ToLower(testCase.want)) {
t.Fatalf("audit failure = %d, %q, %v", exitCode, log, err)
}
})
}
fixture, api, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
api.mu.Lock()
api.postStatus = http.StatusInternalServerError
api.mu.Unlock()
audit := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PostToolUse", codexAuditStatus,
)
exitCode, _ := runCodexCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PostToolUse"),
)
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if exitCode != 0 || err != nil || !strings.Contains(string(log), "HTTP 500") {
t.Fatalf("audit API failure = %d, %q, %v", exitCode, log, err)
}
}
func TestCodexAuditRejectsInvalidServerChainHash(t *testing.T) {
fixture, api, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
api.mu.Lock()
api.postStatus = http.StatusBadRequest
api.postBody = `{"error":{"code":"PREV_HASH_MISMATCH","message":"Expected prev_chain_hash \"invalid\""}}`
api.mu.Unlock()
audit := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PostToolUse", codexAuditStatus,
)
exitCode, _ := runCodexCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PostToolUse"),
)
if exitCode != 0 {
t.Fatalf("audit exit = %d", exitCode)
}
chainPath := filepath.Join(fixture.agentDir, "chain-state.json")
if _, err := os.Stat(chainPath); !os.IsNotExist(err) {
t.Fatalf("invalid server hash created chain state: %v", err)
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "invalid chain hash") {
t.Fatalf("audit error log = %q, %v", log, err)
}
}
func TestCodexGeneratedRuntimePreservesLinkedStateTargets(t *testing.T) {
for _, targetName := range []string{"status-cache.json", "chain-state.json", "error.log"} {
t.Run(targetName, func(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
target := filepath.Join(t.TempDir(), targetName+".target")
source := []byte("external")
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write state target: %v", err)
}
codexSymlinkOrSkip(t, target, filepath.Join(fixture.agentDir, targetName))
settings := readCodexTestObject(t, fixture.configPath)
var command string
var input []byte
if targetName == "status-cache.json" {
command = requireCodexHandler(
t, settings, "PreToolUse", codexGuardStatus,
)[codexCommandKey()].(string)
input, _ = json.Marshal(codexOfficialPayload("PreToolUse"))
} else {
command = requireCodexHandler(
t, settings, "PostToolUse", codexAuditStatus,
)[codexCommandKey()].(string)
if targetName == "error.log" {
input = []byte("{ malformed")
} else {
input, _ = json.Marshal(codexOfficialPayload("PostToolUse"))
}
}
if exitCode, _ := runCodexRawCommand(
t, command, fixture.homeDir, input,
); exitCode != 0 {
t.Fatalf("runtime exit = %d", exitCode)
}
actual, err := os.ReadFile(target)
if err != nil || string(actual) != string(source) {
t.Fatalf("linked state target changed: %q, %v", actual, err)
}
})
}
}
func TestCodexGeneratedHooksReportUnsafeRuntimeOrigin(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
config := readCodexTestObject(t, fixture.runtimeConfig)
config["base_url"] = `https://api.elydora.com\evil`
encoded, err := json.Marshal(config)
if err != nil {
t.Fatalf("encode unsafe runtime config: %v", err)
}
if err := os.WriteFile(fixture.runtimeConfig, encoded, 0600); err != nil {
t.Fatalf("write unsafe runtime config: %v", err)
}
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
settings := readCodexTestObject(t, fixture.configPath)
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
audit := requireCodexHandler(t, settings, "PostToolUse", codexAuditStatus)
guardExit, guardError := runCodexCommand(
t, guard[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PreToolUse"),
)
auditExit, _ := runCodexCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PostToolUse"),
)
log, readErr := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if guardExit != 0 || !strings.Contains(guardError, "absolute HTTP or HTTPS URL") ||
auditExit != 0 || readErr != nil ||
!strings.Contains(string(log), "absolute HTTP or HTTPS URL") {
t.Fatalf("unsafe origin results = %d, %q, %d, %q, %v", guardExit, guardError, auditExit, log, readErr)
}
}
+407
View File
@@ -0,0 +1,407 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
const (
codexTestAgentID = "agent-1"
codexGuardStatus = "Checking Elydora agent state"
codexAuditStatus = "Recording Elydora tool use"
codexPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
)
type codexFixture struct {
plugin *CodexPlugin
config InstallConfig
homeDir string
agentDir string
configPath string
guardPath string
hookPath string
runtimeConfig string
privateKey string
}
func prepareCodexFixture(t *testing.T, existingRaw string) *codexFixture {
t.Helper()
homeDir := filepath.Join(t.TempDir(), "home with 'quote %ELYDORA_HOOK_PATH%")
preparePrivateTransactionTestDirectory(t, homeDir)
agentDir := filepath.Join(homeDir, ".elydora", codexTestAgentID)
configPath := filepath.Join(homeDir, ".codex", "hooks.json")
guardPath := filepath.Join(agentDir, "guard.js")
if err := os.MkdirAll(agentDir, 0755); err != nil {
t.Fatalf("create agent directory: %v", err)
}
if existingRaw != "" {
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
t.Fatalf("create Codex config directory: %v", err)
}
if err := os.WriteFile(configPath, []byte(existingRaw), 0600); err != nil {
t.Fatalf("write Codex config: %v", err)
}
}
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("CODEX_HOME", "")
return &codexFixture{
plugin: &CodexPlugin{},
homeDir: homeDir,
agentDir: agentDir,
config: InstallConfig{
AgentName: "codex",
OrgID: "org-1",
AgentID: codexTestAgentID,
PrivateKey: codexPrivateKey,
KID: "kid-1",
BaseURL: "https://api.elydora.test",
GuardScriptPath: guardPath,
},
configPath: configPath,
guardPath: guardPath,
hookPath: filepath.Join(agentDir, "hook.js"),
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
}
}
func readCodexTestObject(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func requireCodexHandler(t *testing.T, settings map[string]any, event, status string) map[string]any {
t.Helper()
hooks := requireObject(t, settings["hooks"])
for _, groupValue := range requireArray(t, hooks[event]) {
group := requireObject(t, groupValue)
for _, handlerValue := range requireArray(t, group["hooks"]) {
handler := requireObject(t, handlerValue)
if handler["statusMessage"] == status {
return handler
}
}
}
t.Fatalf("handler %q not found", status)
return nil
}
func runCodexCommand(t *testing.T, command, homeDir string, payload map[string]any) (int, string) {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Codex payload: %v", err)
}
return runCodexRawCommand(t, command, homeDir, encoded)
}
func runCodexRawCommand(t *testing.T, command, homeDir string, encoded []byte) (int, string) {
t.Helper()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
commandFile := filepath.Join(t.TempDir(), "run-codex-hook.cmd")
if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil {
t.Fatalf("write hook command file: %v", err)
}
cmd = exec.Command("cmd.exe", "/d", "/c", commandFile)
} else {
cmd = exec.Command("sh", "-c", command)
}
cmd.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
cmd.Stdin = bytes.NewReader(encoded)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
if err == nil {
return 0, stderr.String()
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode(), stderr.String()
}
t.Fatalf("run Codex hook command: %v", err)
return -1, stderr.String()
}
func TestCodexRegistryPointsAtGlobalHooksContract(t *testing.T) {
entry := SupportedAgents["codex"]
if entry.Name != "OpenAI Codex" || entry.ConfigDir != "~/.codex" || entry.ConfigFile != "hooks.json" {
t.Fatalf("Codex registry entry = %#v", entry)
}
}
func TestCodexInstallPreservesHooksAndIsIdempotent(t *testing.T) {
fixture := prepareCodexFixture(t, `{
"description": "Workspace hooks",
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "existing-command"}]}],
"PreToolUse": [{"matcher": "Read", "hooks": []}]
}
}`)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repeat Codex install: %v", err)
}
settings := readCodexTestObject(t, fixture.configPath)
if settings["description"] != "Workspace hooks" {
t.Fatalf("description changed: %#v", settings["description"])
}
hooks := requireObject(t, settings["hooks"])
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("managed hooks are not idempotent: %#v", hooks)
}
if len(requireArray(t, hooks["SessionStart"])) != 1 {
t.Fatalf("unrelated hooks changed: %#v", hooks)
}
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
if guard["type"] != "command" || guard["timeout"] != float64(10) {
t.Fatalf("unexpected guard handler: %#v", guard)
}
if _, ok := guard["command"].(string); !ok {
t.Fatalf("POSIX command = %#v, want string", guard["command"])
}
if _, ok := guard["commandWindows"].(string); !ok {
t.Fatalf("Windows command = %#v, want string", guard["commandWindows"])
}
}
func TestCodexCommandsBlockAndForwardOfficialPayload(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
guardScript := "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
if err := os.WriteFile(fixture.guardPath, []byte(guardScript), 0700); err != nil {
t.Fatalf("write blocking guard: %v", err)
}
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
captureJSON, err := json.Marshal(capturePath)
if err != nil {
t.Fatalf("marshal capture path: %v", err)
}
captureScript := "const fs = require('node:fs'); const chunks = []; " +
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
"process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", Buffer.concat(chunks)));\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0600); err != nil {
t.Fatalf("write capture hook: %v", err)
}
settings := readCodexTestObject(t, fixture.configPath)
commandKey := "command"
if runtime.GOOS == "windows" {
commandKey = "commandWindows"
}
payload := map[string]any{
"hook_event_name": "PreToolUse",
"session_id": "session-1",
"turn_id": "turn-1",
"transcript_path": nil,
"cwd": fixture.homeDir,
"model": "gpt-5",
"permission_mode": "default",
"tool_name": "Bash",
"tool_use_id": "call-1",
"tool_input": map[string]any{"command": "echo test"},
}
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
if strings.Contains(guard["commandWindows"].(string), "%ELYDORA_HOOK_PATH%") {
t.Fatalf("Windows command exposes an expandable path: %q", guard["commandWindows"])
}
exitCode, stderr := runCodexCommand(t, guard[commandKey].(string), fixture.homeDir, payload)
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
}
payload["hook_event_name"] = "PostToolUse"
payload["tool_response"] = map[string]any{"output": "test"}
audit := requireCodexHandler(t, settings, "PostToolUse", codexAuditStatus)
exitCode, stderr = runCodexCommand(t, audit[commandKey].(string), fixture.homeDir, payload)
if exitCode != 0 {
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
}
captured := readCodexTestObject(t, capturePath)
if !reflect.DeepEqual(captured, payload) {
t.Fatalf("captured event = %#v, want %#v", captured, payload)
}
}
func TestCodexStatusRequiresBothRuntimes(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
if err := os.Remove(fixture.guardPath); err != nil {
t.Fatalf("remove guard: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
t.Fatalf("degraded status = %#v, %v", status, err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Codex hooks: %v", err)
}
if err := os.Remove(fixture.privateKey); err != nil {
t.Fatalf("remove private key: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
t.Fatalf("missing key status = %#v, %v", status, err)
}
}
func TestCodexInstallCommitsAllManagedRuntimeFiles(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
for _, path := range []string{
fixture.configPath,
fixture.guardPath,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
} {
if info, err := os.Lstat(path); err != nil || !info.Mode().IsRegular() {
t.Fatalf("managed file %s = %v, %v", path, info, err)
}
}
}
func TestCodexStatusSurfacesMalformedReferencedRuntimeMetadata(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
t.Fatalf("status error = %v", err)
}
}
func TestCodexInstallPreservesMatchingUserStatusText(t *testing.T) {
fixture := prepareCodexFixture(t, `{"hooks":{
"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"node ~/.elydora/user/guard.js.backup","statusMessage":"Checking Elydora agent state"}]}],
"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"node ~/.elydora/user/hook.js.backup","statusMessage":"Recording Elydora tool use"}]}]
}}`)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
hooks := requireObject(t, readCodexTestObject(t, fixture.configPath)["hooks"])
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 2 {
t.Fatalf("user handlers were removed: %#v", hooks)
}
}
func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
fixture := prepareCodexFixture(t, `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"existing-command"}]}]}}`)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
settings := readCodexTestObject(t, fixture.configPath)
otherDir := filepath.Join(filepath.Dir(fixture.agentDir), "agent-10")
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
for _, contract := range []struct{ event, status, script string }{
{"PreToolUse", codexGuardStatus, codexGuardScript},
{"PostToolUse", codexAuditStatus, codexAuditScript},
} {
other := codexHandler(nodePath, filepath.Join(otherDir, contract.script), contract.status)
hooks := requireObject(t, settings["hooks"])
hooks[contract.event] = append(requireArray(t, hooks[contract.event]), map[string]any{
"matcher": "*", "hooks": []any{other},
})
}
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatalf("marshal augmented config: %v", err)
}
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
t.Fatalf("write augmented config: %v", err)
}
if err := fixture.plugin.Uninstall(codexTestAgentID); err != nil {
t.Fatalf("uninstall Codex hooks: %v", err)
}
remaining := readCodexTestObject(t, fixture.configPath)
hooks := requireObject(t, remaining["hooks"])
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("unexpected remaining hooks: %#v", hooks)
}
_, remainingScript, parsed := parsePOSIXCommand(
requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string),
)
if !parsed || !sameManagedPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) {
t.Fatalf("other agent handler was removed: %#v", hooks)
}
}
func TestCodexUninstallRemovesOwnedConfig(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
if err := fixture.plugin.Uninstall(codexTestAgentID); err != nil {
t.Fatalf("uninstall Codex hooks: %v", err)
}
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
t.Fatalf("owned config still exists: %v", err)
}
}
func TestCodexMalformedConfigAndShapesPreventRuntimeWrites(t *testing.T) {
for _, testCase := range []struct{ name, raw, want string }{
{"malformed", "{ malformed", "parse Codex user hooks"},
{"comments", `{"hooks":{} // comment\n}`, "invalid character"},
{"trailing comma", `{"hooks":{},}`, "invalid character"},
{"duplicate root", `{"hooks":{},"hooks":{}}`, "duplicate"},
{"nested duplicate", `{"hooks":{"PreToolUse":[{"hooks":[],"hooks":[]}]}}`, "duplicate"},
{"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`},
{"null-event", `{"hooks":{"PreToolUse":null}}`, `field "hooks.PreToolUse" must be an array`},
{"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "must contain a hooks array"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCodexFixture(t, testCase.raw)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
raw, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(raw) != testCase.raw {
t.Fatalf("original config changed: %q, %v", raw, readErr)
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("runtime write occurred at %s: %v", path, err)
}
}
})
}
}
+174 -207
View File
@@ -2,260 +2,227 @@ package plugins
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// CopilotPlugin manages the Elydora audit hook for GitHub Copilot CLI.
// It writes/merges into .github/hooks/hooks.json (project-relative) using
// hooks.preToolUse[]/postToolUse[] with bash/powershell command fields.
type CopilotPlugin struct{}
func (p *CopilotPlugin) configPath() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve working directory: %w", err)
// CopilotPlugin manages GitHub Copilot CLI native user hooks.
type CopilotPlugin struct {
rename renameFunc
}
return filepath.Join(cwd, ".github", "hooks", "hooks.json"), nil
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *CopilotPlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates every source before the CLI creates runtime state.
func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
sources, _, err := readCopilotSources()
if err != nil {
return err
}
_, _, err = preflightCopilotInstallation(config, sources)
return err
}
func renderCopilotInstallation(
sources *copilotSources,
guardPath string,
auditPath string,
nodePath string,
) ([]*copilotRenderedDocument, error) {
hooks, err := removeManagedCopilotHooks(sources.user.hooks, "")
if err != nil {
return nil, err
}
for _, item := range []struct {
event string
scriptPath string
}{
{"preToolUse", guardPath},
{"postToolUse", auditPath},
{"postToolUseFailure", auditPath},
} {
hooks[item.event] = append(
hooks[item.event],
buildCopilotHandler(nodePath, item.scriptPath),
)
}
user, err := renderCopilotDocument(sources.user, hooks)
if err != nil {
return nil, fmt.Errorf("render GitHub Copilot user hooks: %w", err)
}
rendered := []*copilotRenderedDocument{user}
if sources.legacy != nil {
legacyHooks, removeErr := removeManagedCopilotHooks(sources.legacy.hooks, "")
if removeErr != nil {
return nil, removeErr
}
legacy, renderErr := renderCopilotDocument(sources.legacy, legacyHooks)
if renderErr != nil {
return nil, fmt.Errorf("render GitHub Copilot legacy project hooks: %w", renderErr)
}
rendered = append(rendered, legacy)
}
return rendered, nil
}
func (p *CopilotPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID)
sources, _, err := readCopilotSources()
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
paths, nodePath, err := preflightCopilotInstallation(config, sources)
if err != nil {
return err
}
}
configPath, err := p.configPath()
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
)
if err != nil {
return err
}
settings, err := readJSONFile(configPath)
prepared, err := prepareCopilotInstallation(config, sources, rendered)
if err != nil {
return err
}
// Ensure version field is set
settings["version"] = float64(1)
// Ensure hooks object exists
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- preToolUse (guard — freeze enforcement) ---
preToolUse, _ := hooks["preToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isCopilotElydoraEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"type": "command",
"bash": "node " + guardPath,
"powershell": "node " + guardPath,
"timeoutSec": float64(5),
}
preFiltered = append(preFiltered, guardEntry)
hooks["preToolUse"] = preFiltered
// --- postToolUse (audit logging) ---
postToolUse, _ := hooks["postToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isCopilotElydoraEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"type": "command",
"bash": "node " + scriptPath,
"powershell": "node " + scriptPath,
"timeoutSec": float64(5),
}
postFiltered = append(postFiltered, hookEntry)
hooks["postToolUse"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
if err := commitCopilotInstallation(prepared, p.rename); err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Copilot CLI at %s\n", configPath)
fmt.Printf("GitHub Copilot CLI hooks: %s\n", sources.user.filePath)
fmt.Println("GitHub Copilot CLI: restart active sessions to load updated hooks.")
return nil
}
func renderCopilotUninstall(
sources *copilotSources,
agentID string,
) ([]*copilotRenderedDocument, error) {
documents := []*copilotDocument{sources.user}
if sources.legacy != nil {
documents = append(documents, sources.legacy)
}
rendered := make([]*copilotRenderedDocument, 0, len(documents))
for _, document := range documents {
hooks, err := removeManagedCopilotHooks(document.hooks, agentID)
if err != nil {
return nil, err
}
item, err := renderCopilotDocument(document, hooks)
if err != nil {
return nil, fmt.Errorf("render GitHub Copilot hook source: %w", err)
}
rendered = append(rendered, item)
}
return rendered, nil
}
func (p *CopilotPlugin) Uninstall(agentID string) error {
configPath, err := p.configPath()
sources, _, err := readCopilotSources()
if err != nil {
return err
}
settings, err := readJSONFile(configPath)
rendered, err := renderCopilotUninstall(sources, agentID)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Copilot CLI hooks found.")
return nil
changes := make([]*fileChange, 0, len(rendered))
for _, document := range rendered {
change, changeErr := prepareRenderedCopilotChange(document)
if changeErr != nil {
return changeErr
}
changes = append(changes, change)
}
return writeCopilotChanges(
changes,
"Uninstall GitHub Copilot hooks",
p.rename,
"",
"",
nil,
)
}
// Remove preToolUse Elydora entries
preToolUse, _ := hooks["preToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isCopilotElydoraEntry(m) {
continue
func mergedCopilotContracts(
sources *copilotSources,
) ([]copilotRuntimeContract, error) {
contracts, err := copilotRuntimeContracts(sources.user.hooks)
if err != nil {
return nil, err
}
if sources.legacy != nil {
legacy, legacyErr := copilotRuntimeContracts(sources.legacy.hooks)
if legacyErr != nil {
return nil, legacyErr
}
preFiltered = append(preFiltered, entry)
contracts = append(contracts, legacy...)
}
if len(preFiltered) == 0 {
delete(hooks, "preToolUse")
} else {
hooks["preToolUse"] = preFiltered
unique := map[string]copilotRuntimeContract{}
for _, contract := range contracts {
unique[managedReferenceKey(contract.agentID)] = contract
}
result := make([]copilotRuntimeContract, 0, len(unique))
for _, contract := range unique {
result = append(result, contract)
}
return result, nil
}
// Remove postToolUse Elydora entries
postToolUse, _ := hooks["postToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isCopilotElydoraEntry(m) {
continue
func configuredCopilotPath(
sources *copilotSources,
defaultPath string,
) (string, error) {
userContracts, err := copilotRuntimeContracts(sources.user.hooks)
if err != nil {
return "", err
}
if len(userContracts) > 0 {
return sources.user.filePath, nil
}
if sources.legacy != nil {
legacyContracts, legacyErr := copilotRuntimeContracts(sources.legacy.hooks)
if legacyErr != nil {
return "", legacyErr
}
if len(legacyContracts) > 0 {
return sources.legacy.filePath, nil
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "postToolUse")
} else {
hooks["postToolUse"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
if agentID != "" {
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Copilot CLI.")
return nil
return filepath.Clean(defaultPath), nil
}
func (p *CopilotPlugin) Status() (PluginStatus, error) {
configPath, err := p.configPath()
if err != nil {
return PluginStatus{}, err
paths, pathErr := resolveCopilotPaths()
entry := SupportedAgents[copilotAgentKey]
status := PluginStatus{AgentName: copilotAgentKey, DisplayName: entry.Name}
if pathErr != nil {
return status, pathErr
}
status := PluginStatus{
AgentName: "copilot",
DisplayName: "Copilot CLI",
ConfigPath: configPath,
}
settings, err := readJSONFile(configPath)
status.ConfigPath = paths.userHookPath
sources, _, err := readCopilotSources()
if err != nil {
return status, err
}
contracts, err := mergedCopilotContracts(sources)
if err != nil {
return status, err
}
status.ConfigPath, err = configuredCopilotPath(sources, paths.userHookPath)
if err != nil {
return status, err
}
status.HookConfigured = sources.disabledBy == "" && len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasCopilotElydoraEntry(hooks["preToolUse"])
postConfigured := hasCopilotElydoraEntry(hooks["postToolUse"])
status.HookConfigured = preConfigured && postConfigured
// Extract hook script path from the configured command
scriptPath := extractCopilotElydoraScriptPath(hooks["postToolUse"])
if scriptPath != "" {
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
status.HookScriptExists, err = copilotRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
}
}
status.Installed = status.HookConfigured && status.HookScriptExists
status.Installed = status.HookScriptExists
return status, nil
}
// isCopilotElydoraEntry checks if a Copilot hook entry (bash/powershell fields) is an Elydora hook.
func isCopilotElydoraEntry(m map[string]interface{}) bool {
if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") {
return true
}
if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") {
return true
}
return false
}
// hasCopilotElydoraEntry checks if a Copilot hook array contains an Elydora entry.
func hasCopilotElydoraEntry(hookArray interface{}) bool {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
if isCopilotElydoraEntry(m) {
return true
}
}
}
return false
}
// extractCopilotElydoraScriptPath extracts the script path from a Copilot hook array's Elydora command.
func extractCopilotElydoraScriptPath(hookArray interface{}) string {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") {
return extractPathFromNodeCommand(bash)
}
if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") {
return extractPathFromNodeCommand(ps)
}
}
}
return ""
}
+314
View File
@@ -0,0 +1,314 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
)
const (
copilotAgentKey = "copilot"
copilotGuardScript = "guard.js"
copilotAuditScript = "hook.js"
copilotConfigFile = "elydora-audit.json"
copilotHookTimeout = float64(10)
copilotLegacyTimeout = float64(5)
)
var copilotManagedEvents = []struct {
event string
script string
}{
{"preToolUse", copilotGuardScript},
{"postToolUse", copilotAuditScript},
{"postToolUseFailure", copilotAuditScript},
}
type copilotHooks map[string][]map[string]any
type copilotDocument struct {
exists bool
filePath string
root map[string]any
hooks copilotHooks
raw []byte
snapshot *managedFileSnapshot
hooksDisabled bool
}
type copilotSourcePrecondition struct {
filePath string
label string
snapshot *managedFileSnapshot
}
type copilotSources struct {
user *copilotDocument
legacy *copilotDocument
disabledBy string
settingsPreconditions []copilotSourcePrecondition
}
type copilotRenderedDocument struct {
document *copilotDocument
changed bool
next []byte
remove bool
}
type copilotRuntimeContract = managedRuntimeContract
type copilotManagedEntry = managedScriptReference
func copilotManagedEntryForHandler(
handler map[string]any,
scriptName string,
) (*copilotManagedEntry, error) {
scriptPath, managed := copilotManagedScriptPath(handler)
if !managed {
return nil, nil
}
return resolveManagedScript(scriptPath, scriptName)
}
func cloneCopilotHooks(source copilotHooks) copilotHooks {
clone := make(copilotHooks, len(source))
for event, handlers := range source {
copied := make([]map[string]any, len(handlers))
copy(copied, handlers)
clone[event] = copied
}
return clone
}
func buildCopilotHandler(nodePath, scriptPath string) map[string]any {
return map[string]any{
"type": "command",
"bash": posixSource(nodePath, scriptPath),
"powershell": powerShellSource(nodePath, scriptPath),
"timeoutSec": copilotHookTimeout,
}
}
func parseCopilotLegacyCommand(command string) (string, bool) {
command = strings.TrimSpace(command)
separator := strings.IndexAny(command, " \t")
if separator < 0 {
return "", false
}
executable := command[:separator]
if !strings.EqualFold(executable, "node") && !strings.EqualFold(executable, "node.exe") {
return "", false
}
script := strings.TrimSpace(command[separator:])
if len(script) >= 2 && ((script[0] == '"' && script[len(script)-1] == '"') ||
(script[0] == '\'' && script[len(script)-1] == '\'')) {
script = script[1 : len(script)-1]
}
return script, script != ""
}
func copilotManagedScriptPath(handler map[string]any) (string, bool) {
if len(handler) != 4 || handler["type"] != "command" {
return "", false
}
bash, bashOK := handler["bash"].(string)
powershell, powershellOK := handler["powershell"].(string)
if !bashOK || !powershellOK {
return "", false
}
if handler["timeoutSec"] == copilotHookTimeout {
bashNode, bashScript, bashParsed := parsePOSIXCommand(bash)
psNode, psScript, psParsed := parsePowerShellSource(powershell)
if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) &&
isNodeExecutable(bashNode) && sameManagedPath(bashNode, psNode) &&
sameManagedPath(bashScript, psScript) {
return bashScript, true
}
}
if handler["timeoutSec"] != copilotLegacyTimeout || bash != powershell {
return "", false
}
return parseCopilotLegacyCommand(bash)
}
func removeManagedCopilotHooks(
hooks copilotHooks,
agentID string,
) (copilotHooks, error) {
result := cloneCopilotHooks(hooks)
for _, contract := range copilotManagedEvents {
kept := make([]map[string]any, 0, len(result[contract.event]))
for _, handler := range result[contract.event] {
entry, err := copilotManagedEntryForHandler(handler, contract.script)
if err != nil {
return nil, err
}
remove := entry != nil &&
(agentID == "" || sameManagedAgentID(entry.agentID, agentID))
if !remove {
kept = append(kept, handler)
}
}
if len(kept) == 0 {
delete(result, contract.event)
} else {
result[contract.event] = kept
}
}
return result, nil
}
func parseCopilotDocument(
filePath string,
snapshot *managedFileSnapshot,
label string,
) (*copilotDocument, error) {
if snapshot == nil {
return nil, fmt.Errorf("%s snapshot is required: %s", label, filePath)
}
documentLabel := fmt.Sprintf("%s at %s", label, filePath)
root, err := decodeStrictJSONObject(snapshot.contents, documentLabel)
if err != nil {
return nil, err
}
if root["version"] != float64(1) {
return nil, fmt.Errorf("%s must declare version 1", documentLabel)
}
disabled := false
if value, exists := root["disableAllHooks"]; exists {
var valid bool
disabled, valid = value.(bool)
if !valid {
return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, documentLabel)
}
}
hooks := copilotHooks{}
if value, exists := root["hooks"]; exists {
hooks, err = validateCopilotHooks(value, documentLabel)
if err != nil {
return nil, err
}
}
return &copilotDocument{
exists: true, filePath: filePath, root: root, hooks: hooks,
raw: append([]byte(nil), snapshot.contents...), snapshot: snapshot,
hooksDisabled: disabled,
}, nil
}
func createCopilotDocument(filePath string) *copilotDocument {
return &copilotDocument{
filePath: filePath,
root: map[string]any{},
hooks: copilotHooks{},
}
}
func emptyOwnedCopilotDocument(root map[string]any, hooks copilotHooks) bool {
if len(hooks) != 0 {
return false
}
for key := range root {
if key != "version" && key != "hooks" {
return false
}
}
return true
}
func renderCopilotDocument(
document *copilotDocument,
hooks copilotHooks,
) (*copilotRenderedDocument, error) {
if !document.exists && len(hooks) == 0 {
return &copilotRenderedDocument{document: document}, nil
}
if document.exists && emptyOwnedCopilotDocument(document.root, hooks) {
return &copilotRenderedDocument{
document: document, changed: true, remove: true,
}, nil
}
root := cloneJSONObject(document.root)
root["version"] = float64(1)
if len(hooks) == 0 {
delete(root, "hooks")
} else {
root["hooks"] = hooks
}
next, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, fmt.Errorf("encode GitHub Copilot hooks: %w", err)
}
next = append(next, '\n')
if _, err := parseCopilotDocument(
document.filePath,
&managedFileSnapshot{contents: next},
"GitHub Copilot rendered hooks",
); err != nil {
return nil, fmt.Errorf("validate rendered GitHub Copilot hooks: %w", err)
}
return &copilotRenderedDocument{
document: document,
changed: !document.exists || !bytes.Equal(next, document.raw),
next: next,
}, nil
}
func copilotManagedEntries(
handlers []map[string]any,
scriptName string,
) (map[string][]copilotManagedEntry, error) {
result := map[string][]copilotManagedEntry{}
for _, handler := range handlers {
entry, err := copilotManagedEntryForHandler(handler, scriptName)
if err != nil {
return nil, err
}
if entry != nil {
key := managedReferenceKey(entry.agentID)
result[key] = append(result[key], *entry)
}
}
return result, nil
}
func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, error) {
guards, err := copilotManagedEntries(hooks["preToolUse"], copilotGuardScript)
if err != nil {
return nil, err
}
successes, err := copilotManagedEntries(hooks["postToolUse"], copilotAuditScript)
if err != nil {
return nil, err
}
failures, err := copilotManagedEntries(
hooks["postToolUseFailure"], copilotAuditScript,
)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(guards))
for key := range guards {
keys = append(keys, key)
}
sort.Strings(keys)
contracts := make([]copilotRuntimeContract, 0, len(keys))
for _, key := range keys {
guard := guards[key]
success := successes[key]
failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue
}
contracts = append(contracts, copilotRuntimeContract{
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
auditPath: success[0].scriptPath,
})
}
return contracts, nil
}
+155
View File
@@ -0,0 +1,155 @@
package plugins
import (
"fmt"
)
type preparedCopilotInstallation struct {
changes []*fileChange
preconditions []filePrecondition
paths *managedRuntimePaths
}
func preflightCopilotInstallation(
config InstallConfig,
sources *copilotSources,
) (*managedRuntimePaths, string, error) {
if sources == nil || sources.user == nil || sources.user.filePath == "" {
return nil, "", fmt.Errorf(
"GitHub Copilot CLI installation requires a user hook path",
)
}
if err := requireCopilotHooksEnabled(sources); err != nil {
return nil, "", err
}
if err := validateManagedInstallConfig(config, copilotAgentKey, "GitHub Copilot CLI"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, copilotGuardScript, copilotAuditScript)
if err != nil {
return nil, "", err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, copilotAgentKey, "Copilot",
); err != nil {
return nil, "", err
}
nodePath, err := resolveAbsoluteNodeRuntime("GitHub Copilot CLI")
if err != nil {
return nil, "", err
}
hookSources := []copilotHooks{sources.user.hooks}
if sources.legacy != nil {
hookSources = append(hookSources, sources.legacy.hooks)
}
if err := validateCopilotJavaScriptMatchers(hookSources, nodePath); err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func validateCopilotRenderedSet(
sources *copilotSources,
rendered []*copilotRenderedDocument,
) error {
expected := []*copilotDocument{sources.user}
if sources.legacy != nil {
expected = append(expected, sources.legacy)
}
if len(rendered) != len(expected) {
return fmt.Errorf("GitHub Copilot rendered source set is incomplete")
}
for _, document := range expected {
matches := 0
for _, item := range rendered {
if item != nil && item.document != nil &&
sameManagedPath(item.document.filePath, document.filePath) {
matches++
}
}
if matches != 1 {
return fmt.Errorf(
"GitHub Copilot rendered source set contains unexpected paths",
)
}
}
return nil
}
func copilotInstallationPreconditions(
sources *copilotSources,
rendered []*copilotRenderedDocument,
) []filePrecondition {
result := make([]filePrecondition, 0, len(sources.settingsPreconditions)+len(rendered))
for _, item := range sources.settingsPreconditions {
result = append(result, filePrecondition{
filePath: item.filePath, label: item.label,
snapshot: item.snapshot, maximumSize: maxManagedSourceBytes,
})
}
for _, item := range rendered {
if item != nil && !item.changed {
result = append(result, filePrecondition{
filePath: item.document.filePath,
label: "GitHub Copilot hook source",
snapshot: item.document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return result
}
func prepareCopilotInstallation(
config InstallConfig,
sources *copilotSources,
rendered []*copilotRenderedDocument,
) (*preparedCopilotInstallation, error) {
paths, _, err := preflightCopilotInstallation(config, sources)
if err != nil {
return nil, err
}
if err := validateCopilotRenderedSet(sources, rendered); err != nil {
return nil, err
}
guardScript, auditScript := copilotExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, copilotAgentKey, string(guardScript), string(auditScript),
)
if err != nil {
return nil, err
}
for _, document := range rendered {
change, changeErr := prepareRenderedCopilotChange(document)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
return &preparedCopilotInstallation{
changes: changes, paths: paths,
preconditions: copilotInstallationPreconditions(sources, rendered),
}, nil
}
func commitCopilotInstallation(
prepared *preparedCopilotInstallation,
rename renameFunc,
) error {
if prepared == nil || prepared.paths == nil {
return fmt.Errorf("prepared GitHub Copilot installation is required")
}
return writeCopilotChanges(
prepared.changes,
"Install GitHub Copilot hooks",
rename,
prepared.paths.runtimeRoot,
prepared.paths.agentDirectory,
prepared.preconditions,
)
}
func copilotExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
return []byte(generateGuardScript(copilotAgentKey, agentID, "", false, "")),
[]byte(buildHookScriptWithOutput(copilotAgentKey, agentID, "", false, true))
}
+255
View File
@@ -0,0 +1,255 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type copilotSettingsLayer struct {
filePath string
label string
jsonc bool
}
type copilotDirectoryLocation struct {
path string
label string
}
type copilotPaths struct {
copilotHome string
userHooksDirectory string
userHookPath string
legacyHookPath string
settingsLayers []copilotSettingsLayer
directories []copilotDirectoryLocation
}
type parsedCopilotSettings struct {
layer copilotSettingsLayer
disabled *bool
snapshot *managedFileSnapshot
}
func resolveCopilotPaths() (*copilotPaths, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
}
override := os.Getenv("COPILOT_HOME")
copilotHome := override
if strings.TrimSpace(override) == "" {
copilotHome = filepath.Join(home, ".copilot")
} else if !filepath.IsAbs(copilotHome) {
copilotHome, err = filepath.Abs(copilotHome)
if err != nil {
return nil, fmt.Errorf("resolve COPILOT_HOME at %s: %w", override, err)
}
}
project, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve working directory: %w", err)
}
github := filepath.Join(project, ".github")
githubCopilot := filepath.Join(github, "copilot")
githubHooks := filepath.Join(github, "hooks")
claude := filepath.Join(project, ".claude")
userHooks := filepath.Join(copilotHome, "hooks")
return &copilotPaths{
copilotHome: copilotHome,
userHooksDirectory: userHooks,
userHookPath: filepath.Join(userHooks, copilotConfigFile),
legacyHookPath: filepath.Join(githubHooks, "hooks.json"),
settingsLayers: []copilotSettingsLayer{
{filepath.Join(copilotHome, "config.json"), "legacy Copilot user config", false},
{filepath.Join(copilotHome, "settings.json"), "Copilot user settings", true},
{filepath.Join(claude, "settings.json"), "Claude repository settings", true},
{filepath.Join(claude, "settings.local.json"), "Claude local settings", true},
{filepath.Join(githubCopilot, "settings.json"), "Copilot repository settings", true},
{filepath.Join(githubCopilot, "settings.local.json"), "Copilot local settings", true},
},
directories: []copilotDirectoryLocation{
{project, "Copilot working directory"},
{copilotHome, "COPILOT_HOME"},
{userHooks, "Copilot user hooks directory"},
{github, "GitHub configuration directory"},
{githubHooks, "GitHub repository hooks directory"},
{githubCopilot, "Copilot repository settings directory"},
{claude, "Claude repository settings directory"},
},
}, nil
}
func inspectCopilotDirectories(locations []copilotDirectoryLocation) error {
for _, location := range locations {
if _, err := managedPhysicalDirectoryExists(location.path, location.label); err != nil {
return err
}
}
return nil
}
func readCopilotDocument(filePath, label string) (*copilotDocument, error) {
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
if err != nil || snapshot == nil {
return nil, err
}
return parseCopilotDocument(filePath, snapshot, label)
}
func parseCopilotSettings(
snapshot *managedFileSnapshot,
layer copilotSettingsLayer,
) (map[string]any, error) {
if len(strings.TrimSpace(string(snapshot.contents))) == 0 {
return map[string]any{}, nil
}
label := fmt.Sprintf("%s at %s", layer.label, layer.filePath)
if layer.jsonc {
return decodeJSONCObject(snapshot.contents, label, true)
}
return decodeStrictJSONObject(snapshot.contents, label)
}
func readCopilotSettingsLayer(
layer copilotSettingsLayer,
) (parsedCopilotSettings, error) {
snapshot, err := readManagedFile(
layer.filePath,
layer.label,
maxManagedSourceBytes,
)
if err != nil || snapshot == nil {
return parsedCopilotSettings{layer: layer}, err
}
root, err := parseCopilotSettings(snapshot, layer)
if err != nil {
return parsedCopilotSettings{}, err
}
result := parsedCopilotSettings{layer: layer, snapshot: snapshot}
if value, exists := root["disableAllHooks"]; exists {
disabled, ok := value.(bool)
if !ok {
return parsedCopilotSettings{}, fmt.Errorf(
`%s at %s field "disableAllHooks" must be a boolean`,
layer.label,
layer.filePath,
)
}
result.disabled = &disabled
}
return result, nil
}
func effectiveCopilotDisabledSource(layers []parsedCopilotSettings) string {
disabledBy := ""
for _, layer := range layers {
if layer.disabled == nil {
continue
}
if *layer.disabled {
disabledBy = fmt.Sprintf("%s at %s", layer.layer.label, layer.layer.filePath)
} else {
disabledBy = ""
}
}
return disabledBy
}
func readCopilotSources() (*copilotSources, *copilotPaths, error) {
paths, err := resolveCopilotPaths()
if err != nil {
return nil, nil, err
}
if err := inspectCopilotDirectories(paths.directories); err != nil {
return nil, nil, err
}
user, err := readCopilotDocument(paths.userHookPath, "GitHub Copilot user hooks")
if err != nil {
return nil, nil, err
}
legacy, err := readCopilotDocument(
paths.legacyHookPath,
"GitHub Copilot legacy project hooks",
)
if err != nil {
return nil, nil, err
}
settings := make([]parsedCopilotSettings, 0, len(paths.settingsLayers))
for _, layer := range paths.settingsLayers {
parsed, readErr := readCopilotSettingsLayer(layer)
if readErr != nil {
return nil, nil, readErr
}
settings = append(settings, parsed)
}
if user == nil {
user = createCopilotDocument(paths.userHookPath)
}
disabledBy := effectiveCopilotDisabledSource(settings)
if user.hooksDisabled {
disabledBy = fmt.Sprintf("GitHub Copilot user hooks at %s", paths.userHookPath)
}
preconditions := make([]copilotSourcePrecondition, 0, len(settings))
for _, item := range settings {
preconditions = append(preconditions, copilotSourcePrecondition{
filePath: item.layer.filePath,
label: item.layer.label,
snapshot: item.snapshot,
})
}
return &copilotSources{
user: user, legacy: legacy, disabledBy: disabledBy,
settingsPreconditions: preconditions,
}, paths, nil
}
func requireCopilotHooksEnabled(sources *copilotSources) error {
if sources.disabledBy == "" {
return nil
}
return fmt.Errorf(
"GitHub Copilot hooks are disabled by %s; set disableAllHooks to false before installation",
sources.disabledBy,
)
}
func prepareRenderedCopilotChange(
rendered *copilotRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSnapshotSourceChange(
rendered.document.filePath,
"GitHub Copilot hook source",
rendered.document.snapshot,
rendered.next,
0600,
rendered.remove,
)
}
func writeCopilotChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
preconditions []filePrecondition,
) error {
if !hasFileChanges(changes) {
return writeChanges(changes, label, rename, preconditions...)
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
return writeChanges(changes, label, rename, preconditions...)
}
@@ -0,0 +1,109 @@
package plugins
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
func TestOfficialCopilot1071LoadsAllManagedHooks(t *testing.T) {
entry := os.Getenv("ELYDORA_COPILOT_ENTRY")
runtimeEntry := os.Getenv("ELYDORA_COPILOT_RUNTIME_ENTRY")
nodePath, nodeErr := exec.LookPath("node")
if entry == "" || runtimeEntry == "" || nodeErr != nil {
t.Skip(
"set ELYDORA_COPILOT_ENTRY and ELYDORA_COPILOT_RUNTIME_ENTRY to official package files",
)
}
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
environment := append(os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"COPILOT_HOME="+fixture.copilotHome,
)
version := exec.Command(nodePath, entry, "--version") // #nosec G204 -- test paths are explicit environment inputs.
version.Dir = fixture.projectDir
version.Env = environment
versionOutput, err := version.CombinedOutput()
if err != nil || !strings.Contains(string(versionOutput), "GitHub Copilot CLI 1.0.71.") {
t.Fatalf("official Copilot version = %q, %v", versionOutput, err)
}
source := `
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const runtime = require(process.env.ELYDORA_COPILOT_RUNTIME_ENTRY);
const session = await runtime.hookSessionCreate({
cwd: process.env.ELYDORA_PROJECT,
repoRoot: process.env.ELYDORA_PROJECT,
sessionId: 'elydora-go-official-test',
settingsJson: '{}',
userHooksDir: process.env.ELYDORA_HOOKS,
allowLocalhost: false,
allowHttpAuthHooks: false,
discoverPolicies: false,
});
try {
const snapshot = JSON.parse(await runtime.hookSessionSnapshot(session.handle));
console.log(JSON.stringify({ load: session.load, snapshot }));
} finally {
runtime.hookSessionDispose(session.handle);
}
`
load := exec.Command( // #nosec G204 -- nodePath is resolved through exec.LookPath.
nodePath,
"--input-type=module",
"--eval",
source,
)
load.Dir = fixture.projectDir
load.Env = append(environment,
"ELYDORA_COPILOT_RUNTIME_ENTRY="+runtimeEntry,
"ELYDORA_HOOKS="+filepath.Dir(fixture.configPath),
"ELYDORA_PROJECT="+fixture.projectDir,
)
loaded, err := load.CombinedOutput()
if err != nil {
t.Fatalf("load official Copilot hooks: %s: %v", loaded, err)
}
var result map[string]any
if err := json.Unmarshal(loaded, &result); err != nil {
t.Fatalf("decode official Copilot loader output %q: %v", loaded, err)
}
loadResult := requireCopilotObject(t, result["load"])
if loadResult["hookCount"] != float64(3) ||
len(requireCopilotArray(t, loadResult["errors"])) != 0 ||
len(requireCopilotArray(t, loadResult["warnings"])) != 0 {
t.Fatalf("official Copilot load result = %#v", loadResult)
}
snapshot := requireCopilotObject(t, result["snapshot"])
hooks := requireCopilotArray(t, snapshot["hooks"])
events := make([]string, 0, len(hooks))
for _, value := range hooks {
hook := requireCopilotObject(t, value)
events = append(events, stringValue(hook["eventName"]))
if filepath.Base(stringValue(hook["source"])) != copilotConfigFile {
t.Fatalf("official Copilot hook source = %#v", hook["source"])
}
var spec map[string]any
if err := json.Unmarshal([]byte(stringValue(hook["specJson"])), &spec); err != nil {
t.Fatalf("decode official hook spec: %v", err)
}
config := requireCopilotObject(t, spec["config"])
if config["type"] != "command" || config["timeoutSec"] != copilotHookTimeout ||
!strings.Contains(strings.ToLower(stringValue(config["bash"])), "node") ||
!strings.HasPrefix(stringValue(config["powershell"]), "& ") {
t.Fatalf("official Copilot hook spec = %#v", spec)
}
}
sort.Strings(events)
want := []string{"postToolUse", "postToolUseFailure", "preToolUse"}
if strings.Join(events, ",") != strings.Join(want, ",") {
t.Fatalf("official Copilot events = %#v, want %#v", events, want)
}
}
@@ -0,0 +1,293 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCopilotInstallRejectsInvalidUserSourcesBeforeRuntimeWrites(t *testing.T) {
tests := map[string]string{
"malformed JSON": "{",
"array root": "[]\n",
"missing version": `{"hooks":{}}`,
"unsupported version": `{"version":2,"hooks":{}}`,
"null hooks": `{"version":1,"hooks":null}`,
"null event": `{"version":1,"hooks":{"preToolUse":null}}`,
"null handler": `{"version":1,"hooks":{"preToolUse":[null]}}`,
"invalid disable flag": `{"version":1,"disableAllHooks":"false",` +
`"hooks":{}}`,
}
for name, source := range tests {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotString(source),
})
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted an invalid GitHub Copilot user source")
}
assertCopilotFileEquals(t, fixture.configPath, source)
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotInstallRejectsDisabledUserSource(t *testing.T) {
source := `{"version":1,"disableAllHooks":true,"hooks":{}}`
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotString(source),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "disableAllHooks") {
t.Fatalf("install error = %v", err)
}
assertCopilotFileEquals(t, fixture.configPath, source)
assertCopilotRuntimeAbsent(t, fixture)
}
func TestCopilotInstallRejectsInvalidProjectSourceBeforeWrites(t *testing.T) {
source := `{"version":1,"hooks":{"preToolUse":[null]}}`
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
legacyRaw: copilotString(source),
})
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted an invalid GitHub Copilot project source")
}
assertCopilotFileEquals(t, fixture.legacyPath, source)
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("user hook source exists after rejection: %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
}
func TestCopilotInstallGeneratesManagedGuardAndRejectsUnmanagedPath(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
expected := generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "")
actual, err := os.ReadFile(fixture.guardPath)
if err != nil || string(actual) != expected {
t.Fatalf("generated guard = %q, %v", actual, err)
}
fixture = prepareCopilotFixture(t, copilotFixtureOptions{})
fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "unmanaged-guard.js")
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted an unmanaged guard runtime")
}
assertCopilotRuntimeAbsent(t, fixture)
}
func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) {
t.Run("guard runtime", func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
target := filepath.Join(fixture.homeDir, "guard-target.js")
if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil {
t.Fatalf("write guard target: %v", err)
}
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent runtime directory: %v", err)
}
if err := os.Symlink(target, fixture.guardPath); err != nil {
t.Skipf("create guard symlink: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted a symlink guard runtime")
}
for _, path := range []string{
fixture.runtimeConfig, fixture.privateKey, fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
})
t.Run("user source", func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
target := filepath.Join(fixture.homeDir, "copilot-target.json")
writeCopilotObject(t, target, map[string]any{"version": float64(1), "hooks": map[string]any{}})
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
t.Fatalf("create user hooks directory: %v", err)
}
if err := os.Symlink(target, fixture.configPath); err != nil {
t.Skipf("create source symlink: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted a symlink hook source")
}
assertCopilotRuntimeAbsent(t, fixture)
})
}
func TestCopilotInstallRequiresNodeBeforeWrites(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
t.Setenv("PATH", "")
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "Node.js") {
t.Fatalf("install error = %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
}
func TestCopilotStatusReportsMalformedRuntimeConfig(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{"), 0600); err != nil {
t.Fatalf("write malformed runtime config: %v", err)
}
status, err := fixture.plugin.Status()
if err == nil || status.Installed {
t.Fatalf("Copilot status = %#v, %v", status, err)
}
}
func TestCopilotStatusRejectsSymlinkRuntimeFiles(t *testing.T) {
for _, name := range []string{"config", "guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
path := map[string]string{
"config": fixture.runtimeConfig,
"guard": fixture.guardPath,
"audit": fixture.hookPath,
}[name]
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s runtime: %v", name, err)
}
target := filepath.Join(fixture.homeDir, name+"-runtime-target")
if err := os.WriteFile(target, content, 0600); err != nil {
t.Fatalf("write %s runtime target: %v", name, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove %s runtime: %v", name, err)
}
if err := os.Symlink(target, path); err != nil {
t.Skipf("create %s runtime symlink: %v", name, err)
}
status, err := fixture.plugin.Status()
if err == nil || status.Installed {
t.Fatalf("Copilot status = %#v, %v", status, err)
}
})
}
}
func TestCopilotInstallRollsBackEveryFileOnCommitFailure(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{
"notification": []any{map[string]any{"type": "command", "command": "keep-user"}},
},
}),
})
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, map[string]any{
"notification": []any{map[string]any{"type": "command", "command": "keep-project"}},
}))
userBefore, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read user source: %v", err)
}
projectBefore, err := os.ReadFile(fixture.legacyPath)
if err != nil {
t.Fatalf("read project source: %v", err)
}
commits := 0
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && strings.HasSuffix(source, ".tmp") {
commits++
if commits == 5 {
failed = true
return errors.New("injected Copilot commit failure")
}
}
return os.Rename(source, destination)
}
err = fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Copilot commit failure") {
t.Fatalf("install error = %v", err)
}
assertCopilotFileBytes(t, fixture.configPath, userBefore)
assertCopilotFileBytes(t, fixture.legacyPath, projectBefore)
assertCopilotRuntimeAbsent(t, fixture)
assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir)
}
func TestCopilotInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{},
}),
})
concurrent := []byte(`{"version":1,"hooks":{"notification":[{"type":"command","command":"concurrent"}]}}` + "\n")
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated && strings.HasSuffix(source, ".tmp") {
mutated = true
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
return err
}
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("install error = %v", err)
}
assertCopilotFileBytes(t, fixture.configPath, concurrent)
assertCopilotRuntimeAbsent(t, fixture)
assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir)
}
func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertCopilotFileEquals(t *testing.T, path, expected string) {
t.Helper()
assertCopilotFileBytes(t, path, []byte(expected))
}
func assertCopilotFileBytes(t *testing.T, path string, expected []byte) {
t.Helper()
actual, err := os.ReadFile(path)
if err != nil || string(actual) != string(expected) {
t.Fatalf("file %s = %q, %v; want %q", path, actual, err, expected)
}
}
func assertNoCopilotTransactionArtifacts(t *testing.T, roots ...string) {
t.Helper()
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk transaction root %s: %v", root, err)
}
}
}
+351
View File
@@ -0,0 +1,351 @@
package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"net/url"
"os"
"os/exec"
"strings"
"time"
)
const copilotRegexValidationTimeout = 10 * time.Second
var copilotSupportedEvents = stringSet(
"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",
)
const copilotRegexValidator = `import fs from "node:fs";
const entries = JSON.parse(fs.readFileSync(0, "utf8"));
for (const entry of entries) {
try {
new RegExp("^(?:" + entry.pattern + ")$");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(entry.label + ": " + message);
process.exit(1);
}
}
`
type copilotMatcherEntry struct {
Label string `json:"label"`
Pattern string `json:"pattern"`
}
func copilotFieldLabel(label, field string) string {
return fmt.Sprintf(`%s field %q`, label, field)
}
func requireCopilotString(value any, label string) (string, error) {
text, ok := value.(string)
if !ok || text == "" {
return "", fmt.Errorf("%s must be a non-empty string", label)
}
return text, nil
}
func validateCopilotOptionalString(
handler map[string]any,
field string,
label string,
) error {
value, exists := handler[field]
if exists {
if _, ok := value.(string); !ok {
return fmt.Errorf("%s must be a string", copilotFieldLabel(label, field))
}
}
return nil
}
func validateCopilotTimeouts(handler map[string]any, label string) error {
for _, field := range []string{"timeout", "timeoutSec"} {
value, exists := handler[field]
if !exists {
continue
}
number, ok := value.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
return fmt.Errorf(
"%s must be a positive number",
copilotFieldLabel(label, field),
)
}
}
return nil
}
func validateCopilotMatcher(
handler map[string]any,
label string,
) error {
value, exists := handler["matcher"]
if !exists {
return nil
}
_, err := requireCopilotString(value, copilotFieldLabel(label, "matcher"))
return err
}
func validateCopilotStringMap(value any, label string) error {
object, ok := value.(map[string]any)
if !ok || object == nil {
return fmt.Errorf("%s must be an object", label)
}
for key, item := range object {
if _, err := requireCopilotString(item, label+"."+key); err != nil {
return err
}
}
return nil
}
func validateCopilotCommand(
handler map[string]any,
label string,
) error {
hasCommand := false
for _, field := range []string{"bash", "powershell", "command"} {
if _, exists := handler[field]; exists {
hasCommand = true
}
}
if !hasCommand {
return fmt.Errorf("%s must define bash, powershell, or command", label)
}
for _, field := range []string{"bash", "powershell", "command", "cwd"} {
if err := validateCopilotOptionalString(handler, field, label); err != nil {
return err
}
}
if value, exists := handler["env"]; exists {
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "env")); err != nil {
return err
}
}
if err := validateCopilotTimeouts(handler, label); err != nil {
return err
}
return validateCopilotMatcher(handler, label)
}
func copilotLoopbackHost(hostname string) bool {
return hostname == "localhost" || hostname == "::1" ||
strings.HasPrefix(hostname, "127.")
}
func validateCopilotHTTP(
handler map[string]any,
label string,
) error {
rawURL, err := requireCopilotString(
handler["url"],
copilotFieldLabel(label, "url"),
)
if err != nil {
return err
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || parsed.Hostname() == "" {
return fmt.Errorf("%s is invalid", copilotFieldLabel(label, "url"))
}
localhostAllowed := os.Getenv("COPILOT_HOOK_ALLOW_LOCALHOST") == "1" &&
parsed.Scheme == "http" && copilotLoopbackHost(parsed.Hostname())
if parsed.Scheme != "https" && !localhostAllowed {
return fmt.Errorf("%s must use HTTPS", copilotFieldLabel(label, "url"))
}
if value, exists := handler["headers"]; exists {
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "headers")); err != nil {
return err
}
}
if value, exists := handler["allowedEnvVars"]; exists {
items, ok := value.([]any)
if !ok {
return fmt.Errorf(
"%s must be an array of strings",
copilotFieldLabel(label, "allowedEnvVars"),
)
}
for _, item := range items {
if text, ok := item.(string); !ok || text == "" {
return fmt.Errorf(
"%s must be an array of strings",
copilotFieldLabel(label, "allowedEnvVars"),
)
}
}
}
if err := validateCopilotTimeouts(handler, label); err != nil {
return err
}
return validateCopilotMatcher(handler, label)
}
func validateCopilotPrompt(
handler map[string]any,
event string,
label string,
) error {
if event != "sessionStart" && event != "SessionStart" {
return fmt.Errorf("%s prompt hooks are supported only for sessionStart", label)
}
_, err := requireCopilotString(handler["prompt"], copilotFieldLabel(label, "prompt"))
return err
}
func validateCopilotHandler(
handler map[string]any,
event string,
label string,
) error {
handlerType := "command"
if value, exists := handler["type"]; exists {
var ok bool
handlerType, ok = value.(string)
if !ok {
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
}
}
switch handlerType {
case "command":
return validateCopilotCommand(handler, label)
case "http":
return validateCopilotHTTP(handler, label)
case "prompt":
return validateCopilotPrompt(handler, event, label)
default:
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
}
}
func validateCopilotHooks(value any, label string) (copilotHooks, error) {
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
}
hooks := make(copilotHooks, len(object))
for event, candidate := range object {
if _, supported := copilotSupportedEvents[event]; !supported {
return nil, fmt.Errorf("%s hook event %q is unsupported", label, event)
}
values, ok := candidate.([]any)
if !ok {
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
}
handlers := make([]map[string]any, 0, len(values))
for index, value := range values {
handler, ok := value.(map[string]any)
itemLabel := fmt.Sprintf("%s handler hooks.%s[%d]", label, event, index)
if !ok || handler == nil {
return nil, fmt.Errorf("%s must be an object", itemLabel)
}
if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
return nil, err
}
handlers = append(handlers, cloneJSONObject(handler))
}
hooks[event] = handlers
}
return hooks, nil
}
func copilotMatcherEntries(hooks copilotHooks) []copilotMatcherEntry {
entries := make([]copilotMatcherEntry, 0)
wildcardEvents := stringSet(
"preToolUse", "PreToolUse", "permissionRequest", "PermissionRequest",
)
for event, handlers := range hooks {
for index, handler := range handlers {
matcher, ok := handler["matcher"].(string)
if !ok {
continue
}
if _, wildcardEvent := wildcardEvents[event]; wildcardEvent &&
(matcher == "*" || matcher == "**") {
continue
}
entries = append(entries, copilotMatcherEntry{
Label: fmt.Sprintf("GitHub Copilot hooks.%s[%d] matcher", event, index),
Pattern: matcher,
})
}
}
return entries
}
func validateCopilotJavaScriptMatchers(
sources []copilotHooks,
nodePath string,
) error {
entries := make([]copilotMatcherEntry, 0)
for _, hooks := range sources {
entries = append(entries, copilotMatcherEntries(hooks)...)
}
if len(entries) == 0 {
return nil
}
input, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("encode GitHub Copilot matchers: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), copilotRegexValidationTimeout)
defer cancel()
command := exec.CommandContext( // #nosec G204 -- nodePath is resolved through exec.LookPath.
ctx,
nodePath,
"--input-type=module",
"--eval",
copilotRegexValidator,
)
command.Stdin = bytes.NewReader(input)
output, runErr := command.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf(
"GitHub Copilot matcher validation timed out after %s",
copilotRegexValidationTimeout,
)
}
if runErr != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = runErr.Error()
}
return fmt.Errorf(
"GitHub Copilot matcher must be a valid JavaScript regular expression: %s",
message,
)
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package plugins
import (
"errors"
"os"
"strings"
"testing"
)
func TestCopilotSettingsPrecedenceAllowsLaterFalseAndPreservesJSONC(t *testing.T) {
userSource := "{\n // user policy\n \"disableAllHooks\": true,\n}\n"
claudeSource := "{\n \"disableAllHooks\": true,\n}\n"
repositorySource := "{\n \"disableAllHooks\": false,\n}\n"
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotString(userSource),
claudeLocalSettingsRaw: copilotString(claudeSource),
repositorySettingsRaw: copilotString(repositorySource),
})
installCopilotFixture(t, fixture)
assertCopilotFileEquals(t, fixture.userSettings, userSource)
assertCopilotFileEquals(t, fixture.claudeLocalSettings, claudeSource)
assertCopilotFileEquals(t, fixture.repositorySettings, repositorySource)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed {
t.Fatalf("Copilot status = %#v, %v", status, err)
}
}
func TestCopilotEffectiveDisabledSettingsRejectBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
options copilotFixtureOptions
want string
}{
{
name: "repository",
options: copilotFixtureOptions{
repositorySettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
},
want: "repository settings",
},
{
name: "local repository",
options: copilotFixtureOptions{
claudeLocalSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
localSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
},
want: "local settings",
},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, testCase.options)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("hook source exists after rejection: %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotInvalidSettingsRemainUnchanged(t *testing.T) {
for name, source := range map[string]string{
"malformed": "{ malformed",
"duplicate": `{"disableAllHooks":true,"disableAllHooks":false}`,
"invalid boolean": `{"disableAllHooks":"yes"}`,
} {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotString(source),
})
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted invalid GitHub Copilot settings")
}
assertCopilotFileEquals(t, fixture.userSettings, source)
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotRejectsCompleteInvalidHookSchemaBeforeWrites(t *testing.T) {
tests := map[string]string{
"duplicate key": `{"version":1,"version":1,"hooks":{}}`,
"boolean version": `{"version":true,"hooks":{}}`,
"unknown event": `{"version":1,"hooks":{"unknownEvent":[]}}`,
"empty command": `{"version":1,"hooks":{"preToolUse":[{}]}}`,
"invalid command": `{"version":1,"hooks":{"preToolUse":[{"command":1}]}}`,
"zero timeout": `{"version":1,"hooks":{"preToolUse":[{"command":"x","timeoutSec":0}]}}`,
"prompt event": `{"version":1,"hooks":{"postToolUse":[{"type":"prompt","prompt":"x"}]}}`,
"insecure HTTP": `{"version":1,"hooks":{"postToolUse":[{"type":"http","url":"http://example.com"}]}}`,
"invalid matcher": `{"version":1,"hooks":{"preToolUse":[{"command":"x","matcher":"["}]}}`,
}
for name, source := range tests {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotString(source),
})
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted an invalid GitHub Copilot hook document")
}
assertCopilotFileEquals(t, fixture.configPath, source)
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotMatchersUseJavaScriptRegularExpressionSyntax(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{
"preToolUse": []any{map[string]any{
"type": "command", "command": "user-pre", "matcher": "(?<tool>shell)",
}},
},
}),
})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
pre := requireCopilotArray(t, hooks["preToolUse"])
if requireCopilotObject(t, pre[0])["matcher"] != "(?<tool>shell)" {
t.Fatalf("preserved matcher = %#v", pre[0])
}
}
func TestCopilotUninstallRemainsAvailableWithoutNodeMatcherValidation(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
pre := requireCopilotArray(t, hooks["preToolUse"])
pre = append(pre, map[string]any{
"type": "command", "command": "user-pre", "matcher": "[",
})
hooks["preToolUse"] = pre
writeCopilotObject(t, fixture.configPath, settings)
t.Setenv("PATH", "")
if err := fixture.plugin.Uninstall(copilotTestAgentID); err != nil {
t.Fatalf("uninstall GitHub Copilot hooks: %v", err)
}
remaining := readCopilotObject(t, fixture.configPath)
remainingHooks := requireCopilotObject(t, remaining["hooks"])
if len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 1 {
t.Fatalf("remaining hooks = %#v", remainingHooks)
}
}
+5
View File
@@ -0,0 +1,5 @@
package plugins
func copilotRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
return managedRuntimeFilesExist(contracts, copilotAgentKey, "Copilot", copilotExpectedScripts)
}
+300
View File
@@ -0,0 +1,300 @@
package plugins
import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func TestCopilotRegistryUsesNativeUserHooks(t *testing.T) {
want := AgentRegistryEntry{
Name: "GitHub Copilot CLI",
ConfigDir: "~/.copilot/hooks",
ConfigFile: "elydora-audit.json",
}
if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(got, want) {
t.Fatalf("Copilot registry = %#v, want %#v", got, want)
}
plugin := NewPlugin(copilotAgentKey)
manager, ok := plugin.(GuardRuntimeManager)
if !ok || !manager.ManagesGuardRuntime() {
t.Fatal("Copilot must own its generated guard transactionally")
}
}
func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"disableAllHooks": false,
"hooks": map[string]any{
"sessionStart": []any{map[string]any{"type": "command", "command": "user-session"}},
"preToolUse": []any{map[string]any{"type": "command", "command": "user-pre"}},
},
}),
})
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, map[string]any{
"notification": []any{map[string]any{"type": "command", "command": "user-notification"}},
}))
installCopilotFixture(t, fixture)
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
if settings["disableAllHooks"] != false {
t.Fatalf("disableAllHooks = %#v", settings["disableAllHooks"])
}
hooks := requireCopilotObject(t, settings["hooks"])
if got := requireCopilotArray(t, hooks["sessionStart"]); len(got) != 1 {
t.Fatalf("sessionStart hooks = %#v", got)
}
preHooks := requireCopilotArray(t, hooks["preToolUse"])
postHooks := requireCopilotArray(t, hooks["postToolUse"])
failureHooks := requireCopilotArray(t, hooks["postToolUseFailure"])
if len(preHooks) != 2 || len(postHooks) != 1 || len(failureHooks) != 1 {
t.Fatalf("Copilot hooks = %#v", hooks)
}
if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" {
t.Fatalf("first preToolUse hook = %#v", preHooks[0])
}
assertNativeCopilotHandler(t, managedCopilotTestHandler(
t, settings, "preToolUse", copilotGuardScript,
))
assertNativeCopilotHandler(t, managedCopilotTestHandler(
t, settings, "postToolUse", copilotAuditScript,
))
assertNativeCopilotHandler(t, managedCopilotTestHandler(
t, settings, "postToolUseFailure", copilotAuditScript,
))
legacy := readCopilotObject(t, fixture.legacyPath)
legacyHooks := requireCopilotObject(t, legacy["hooks"])
if len(legacyHooks) != 1 || legacyHooks["notification"] == nil {
t.Fatalf("migrated project hooks = %#v", legacyHooks)
}
runtimeConfig := readCopilotObject(t, fixture.runtimeConfig)
if runtimeConfig["agent_name"] != copilotAgentKey ||
runtimeConfig["agent_id"] != copilotTestAgentID {
t.Fatalf("runtime config = %#v", runtimeConfig)
}
privateKey, err := os.ReadFile(fixture.privateKey)
if err != nil || string(privateKey) != fixture.config.PrivateKey {
t.Fatalf("private key = %q, %v", privateKey, err)
}
if exists, err := regularFileExists(fixture.hookPath, "test audit runtime"); err != nil || !exists {
t.Fatalf("audit runtime exists = %v, %v", exists, err)
}
}
func TestCopilotMigrationRemovesOwnedProjectFile(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil))
installCopilotFixture(t, fixture)
if _, err := os.Stat(fixture.legacyPath); !os.IsNotExist(err) {
t.Fatalf("owned project hook file remains: %v", err)
}
settings := readCopilotObject(t, fixture.configPath)
assertNativeCopilotHandler(t, managedCopilotTestHandler(
t, settings, "preToolUse", copilotGuardScript,
))
}
func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
guardScript := "const fs = require('node:fs');\n" +
"fs.readFileSync(0);\n" +
"process.stderr.write('Agent is frozen by Elydora.');\n" +
"process.exit(2);\n"
if err := os.WriteFile(fixture.guardPath, []byte(guardScript), 0700); err != nil {
t.Fatalf("write blocking guard runtime: %v", err)
}
captureScript := "const fs = require('node:fs');\n" +
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
t.Fatalf("write capture audit runtime: %v", err)
}
settings := readCopilotObject(t, fixture.configPath)
prePayload := `{"sessionId":"session-1","timestamp":1,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"}}` + "\n"
guard := managedCopilotTestHandler(t, settings, "preToolUse", copilotGuardScript)
guardResult := runCopilotHandler(t, guard, prePayload)
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
t.Fatalf("guard result = %#v", guardResult)
}
postPayload := `{"sessionId":"session-1","timestamp":2,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"toolResult":{"resultType":"success","textResultForLlm":"ok"}}` + "\n"
audit := managedCopilotTestHandler(t, settings, "postToolUse", copilotAuditScript)
auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
if auditResult.exitCode != 0 {
t.Fatalf("audit result = %#v", auditResult)
}
captured, err := os.ReadFile(capturePath)
if err != nil || string(captured) != postPayload {
t.Fatalf("captured payload = %q, %v", captured, err)
}
failurePayload := `{"sessionId":"session-1","timestamp":3,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"error":"command failed"}` + "\n"
failure := managedCopilotTestHandler(t, settings, "postToolUseFailure", copilotAuditScript)
failureResult := runCopilotHandler(t, failure, failurePayload, "ELYDORA_CAPTURE="+capturePath)
if failureResult.exitCode != 0 {
t.Fatalf("failure audit result = %#v", failureResult)
}
captured, err = os.ReadFile(capturePath)
if err != nil || string(captured) != failurePayload {
t.Fatalf("captured failure payload = %q, %v", captured, err)
}
}
func TestCopilotEmptyHomeOverrideUsesOfficialDefault(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{emptyOverride: true})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
assertNativeCopilotHandler(t, managedCopilotTestHandler(
t, settings, "preToolUse", copilotGuardScript,
))
status, err := fixture.plugin.Status()
if err != nil || status.ConfigPath != fixture.configPath {
t.Fatalf("Copilot status = %#v, %v", status, err)
}
}
func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
delete(hooks, "postToolUseFailure")
writeCopilotObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("incomplete status = %#v, %v", status, err)
}
installCopilotFixture(t, fixture)
settings = readCopilotObject(t, fixture.configPath)
settings["disableAllHooks"] = true
writeCopilotObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("disabled status = %#v, %v", status, err)
}
settings["disableAllHooks"] = false
writeCopilotObject(t, fixture.configPath, settings)
if err := os.Remove(fixture.hookPath); err != nil {
t.Fatalf("remove audit runtime: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || !status.HookConfigured || status.HookScriptExists || status.Installed {
t.Fatalf("missing runtime status = %#v, %v", status, err)
}
installCopilotFixture(t, fixture)
runtimeConfig := readCopilotObject(t, fixture.runtimeConfig)
runtimeConfig["agent_id"] = "another-agent"
writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig)
status, err = fixture.plugin.Status()
if err == nil || status.HookScriptExists || status.Installed {
t.Fatalf("identity mismatch status = %#v, %v", status, err)
}
}
func TestCopilotStatusRecognizesActiveProjectDelivery(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil))
writeCopilotObject(t, fixture.runtimeConfig, map[string]any{
"org_id": "org-1", "agent_id": copilotTestAgentID, "kid": "kid-1",
"base_url": "https://api.elydora.test", "agent_name": copilotAgentKey,
})
if err := os.WriteFile(fixture.privateKey, []byte(copilotTestPrivateKey), 0600); err != nil {
t.Fatalf("write private key: %v", err)
}
if err := os.WriteFile(
fixture.guardPath,
[]byte(generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "")),
0700,
); err != nil {
t.Fatalf("write guard runtime: %v", err)
}
if err := os.WriteFile(
fixture.hookPath,
[]byte(buildHookScriptWithOutput(copilotAgentKey, copilotTestAgentID, "", false, true)),
0700,
); err != nil {
t.Fatalf("write audit runtime: %v", err)
}
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || status.ConfigPath != fixture.legacyPath {
t.Fatalf("project delivery status = %#v, %v", status, err)
}
}
func TestCopilotUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{
"notification": []any{map[string]any{"type": "command", "command": "keep"}},
},
}),
})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
preHooks := requireCopilotArray(t, hooks["preToolUse"])
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
preHooks = append(preHooks,
buildCopilotHandler(
nodePath,
filepath.Join(filepath.Dir(fixture.agentDir), "agent-10", copilotGuardScript),
),
map[string]any{
"type": "command", "bash": "echo elydora", "powershell": "Write-Output elydora",
"timeoutSec": copilotHookTimeout,
},
)
hooks["preToolUse"] = preHooks
writeCopilotObject(t, fixture.configPath, settings)
uninstallID := copilotTestAgentID
if runtime.GOOS == "windows" {
uninstallID = strings.ToUpper(uninstallID)
}
if err := fixture.plugin.Uninstall(uninstallID); err != nil {
t.Fatalf("uninstall GitHub Copilot hooks: %v", err)
}
remaining := readCopilotObject(t, fixture.configPath)
remainingHooks := requireCopilotObject(t, remaining["hooks"])
if remainingHooks["postToolUse"] != nil || remainingHooks["postToolUseFailure"] != nil ||
len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 2 {
t.Fatalf("remaining hooks = %#v", remainingHooks)
}
if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 {
t.Fatalf("notification hooks = %#v", remainingHooks["notification"])
}
}
func TestCopilotUninstallLeavesAbsentSourcesAbsent(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
if err := fixture.plugin.Uninstall(copilotTestAgentID); err != nil {
t.Fatalf("uninstall absent GitHub Copilot hooks: %v", err)
}
for _, path := range []string{fixture.configPath, fixture.legacyPath} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("absent source created at %s: %v", path, err)
}
}
}
@@ -0,0 +1,297 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
const (
copilotTestAgentID = "agent-1"
copilotTestPrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws"
)
type copilotFixtureOptions struct {
userRaw *string
legacyRaw *string
legacyUserConfigRaw *string
userSettingsRaw *string
claudeSettingsRaw *string
claudeLocalSettingsRaw *string
repositorySettingsRaw *string
localSettingsRaw *string
emptyOverride bool
}
type copilotFixture struct {
plugin *CopilotPlugin
config InstallConfig
homeDir string
projectDir string
copilotHome string
agentDir string
configPath string
legacyPath string
guardPath string
hookPath string
runtimeConfig string
privateKey string
legacyUserConfig string
userSettings string
claudeSettings string
claudeLocalSettings string
repositorySettings string
localSettings string
}
type copilotCommandResult struct {
exitCode int
stdout string
stderr string
}
func copilotString(value string) *string {
return &value
}
func copilotJSON(value any) *string {
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
panic(err)
}
return copilotString(string(encoded) + "\n")
}
func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilotFixture {
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
projectDir := filepath.Join(rootDir, "project with spaces")
copilotHome := filepath.Join(homeDir, "custom copilot")
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile)
legacyPath := filepath.Join(projectDir, ".github", "hooks", "hooks.json")
legacyUserConfig := filepath.Join(copilotHome, "config.json")
userSettings := filepath.Join(copilotHome, "settings.json")
claudeSettings := filepath.Join(projectDir, ".claude", "settings.json")
claudeLocalSettings := filepath.Join(projectDir, ".claude", "settings.local.json")
repositorySettings := filepath.Join(projectDir, ".github", "copilot", "settings.json")
localSettings := filepath.Join(projectDir, ".github", "copilot", "settings.local.json")
guardPath := filepath.Join(agentDir, copilotGuardScript)
hookPath := filepath.Join(agentDir, copilotAuditScript)
for _, directory := range []string{homeDir, projectDir} {
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatalf("create fixture directory %s: %v", directory, err)
}
}
writeOptionalCopilotFile(t, configPath, options.userRaw)
writeOptionalCopilotFile(t, legacyPath, options.legacyRaw)
writeOptionalCopilotFile(t, legacyUserConfig, options.legacyUserConfigRaw)
writeOptionalCopilotFile(t, userSettings, options.userSettingsRaw)
writeOptionalCopilotFile(t, claudeSettings, options.claudeSettingsRaw)
writeOptionalCopilotFile(t, claudeLocalSettings, options.claudeLocalSettingsRaw)
writeOptionalCopilotFile(t, repositorySettings, options.repositorySettingsRaw)
writeOptionalCopilotFile(t, localSettings, options.localSettingsRaw)
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
if options.emptyOverride {
t.Setenv("COPILOT_HOME", " ")
configPath = filepath.Join(homeDir, ".copilot", "hooks", copilotConfigFile)
} else {
t.Setenv("COPILOT_HOME", copilotHome)
}
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatalf("read current directory: %v", err)
}
if err := os.Chdir(projectDir); err != nil {
t.Fatalf("enter fixture project: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(previousDirectory); err != nil {
t.Errorf("restore current directory: %v", err)
}
})
return &copilotFixture{
plugin: &CopilotPlugin{},
homeDir: homeDir,
projectDir: projectDir,
copilotHome: copilotHome,
agentDir: agentDir,
configPath: configPath,
legacyPath: legacyPath,
guardPath: guardPath,
hookPath: hookPath,
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
legacyUserConfig: legacyUserConfig,
userSettings: userSettings,
claudeSettings: claudeSettings,
claudeLocalSettings: claudeLocalSettings,
repositorySettings: repositorySettings,
localSettings: localSettings,
config: InstallConfig{
AgentName: copilotAgentKey, OrgID: "org-1", AgentID: copilotTestAgentID,
PrivateKey: copilotTestPrivateKey, KID: "kid-1", Token: "token-1",
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
},
}
}
func writeOptionalCopilotFile(t *testing.T, path string, source *string) {
t.Helper()
if source == nil {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(*source), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func writeCopilotObject(t *testing.T, path string, value map[string]any) {
t.Helper()
writeOptionalCopilotFile(t, path, copilotJSON(value))
}
func readCopilotObject(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func requireCopilotObject(t *testing.T, value any) map[string]any {
t.Helper()
object, ok := value.(map[string]any)
if !ok {
t.Fatalf("value is not an object: %#v", value)
}
return object
}
func requireCopilotArray(t *testing.T, value any) []any {
t.Helper()
array, ok := value.([]any)
if !ok {
t.Fatalf("value is not an array: %#v", value)
}
return array
}
func managedCopilotTestHandler(
t *testing.T,
settings map[string]any,
event string,
scriptName string,
) map[string]any {
t.Helper()
hooks := requireCopilotObject(t, settings["hooks"])
for _, value := range requireCopilotArray(t, hooks[event]) {
handler := requireCopilotObject(t, value)
if strings.Contains(stringValue(handler["bash"]), scriptName) {
return handler
}
}
t.Fatalf("managed %s handler for %s was not found", event, scriptName)
return nil
}
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func assertNativeCopilotHandler(t *testing.T, handler map[string]any) {
t.Helper()
if len(handler) != 4 || handler["type"] != "command" ||
handler["timeoutSec"] != copilotHookTimeout {
t.Fatalf("Copilot handler = %#v", handler)
}
if !strings.Contains(stringValue(handler["bash"]), "node") {
t.Fatalf("Copilot bash handler = %#v", handler["bash"])
}
powershell := stringValue(handler["powershell"])
if !strings.HasPrefix(powershell, "& ") || !strings.HasSuffix(powershell, "; exit $LASTEXITCODE") {
t.Fatalf("Copilot PowerShell handler = %q", powershell)
}
}
func legacyCopilotConfig(fixture *copilotFixture, extraHooks map[string]any) map[string]any {
hooks := map[string]any{
"preToolUse": []any{map[string]any{
"type": "command", "bash": "node " + fixture.guardPath,
"powershell": "node " + fixture.guardPath, "timeoutSec": copilotLegacyTimeout,
}},
"postToolUse": []any{map[string]any{
"type": "command", "bash": "node " + fixture.hookPath,
"powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout,
}},
"postToolUseFailure": []any{map[string]any{
"type": "command", "bash": "node " + fixture.hookPath,
"powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout,
}},
}
for key, value := range extraHooks {
hooks[key] = value
}
return map[string]any{"version": float64(1), "hooks": hooks}
}
func runCopilotHandler(
t *testing.T,
handler map[string]any,
payload string,
environment ...string,
) copilotCommandResult {
t.Helper()
var process *exec.Cmd
if runtime.GOOS == "windows" {
process = exec.Command(
"powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
stringValue(handler["powershell"]),
)
} else {
process = exec.Command("sh", "-c", stringValue(handler["bash"]))
}
process.Env = append(os.Environ(), environment...)
process.Stdin = strings.NewReader(payload)
var stdout bytes.Buffer
var stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return copilotCommandResult{stdout: stdout.String(), stderr: stderr.String()}
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return copilotCommandResult{
exitCode: exitError.ExitCode(), stdout: stdout.String(), stderr: stderr.String(),
}
}
t.Fatalf("run GitHub Copilot hook: %v", err)
return copilotCommandResult{exitCode: -1}
}
func installCopilotFixture(t *testing.T, fixture *copilotFixture) {
t.Helper()
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install GitHub Copilot hooks: %v", err)
}
}
@@ -0,0 +1,248 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCopilotPrepareRejectsSameContentStaleHookSnapshot(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{"notification": []any{}},
}),
})
sources, _, err := readCopilotSources()
if err != nil {
t.Fatalf("read GitHub Copilot sources: %v", err)
}
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
if err != nil {
t.Fatalf("preflight GitHub Copilot installation: %v", err)
}
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
)
if err != nil {
t.Fatalf("render GitHub Copilot installation: %v", err)
}
original, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read user hooks: %v", err)
}
replacement := fixture.configPath + ".replacement"
if err := os.WriteFile(replacement, original, 0600); err != nil {
t.Fatalf("write replacement hooks: %v", err)
}
if err := os.Rename(replacement, fixture.configPath); err != nil {
t.Fatalf("replace user hooks: %v", err)
}
_, err = prepareCopilotInstallation(fixture.config, sources, rendered)
if err == nil || !strings.Contains(err.Error(), "changed before update") {
t.Fatalf("prepare error = %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
}
func TestCopilotInstallRollsBackWhenEffectiveSettingsChange(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
})
commits := 0
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if err := os.Rename(source, destination); err != nil {
return err
}
if !mutated && strings.HasSuffix(source, ".tmp") {
commits++
if commits == 3 {
mutated = true
writeCopilotObject(t, fixture.userSettings, map[string]any{
"disableAllHooks": true,
})
}
}
return nil
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
t.Fatalf("install error = %v", err)
}
settings := readCopilotObject(t, fixture.userSettings)
if settings["disableAllHooks"] != true {
t.Fatalf("concurrent settings = %#v", settings)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("hook source exists after rollback: %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir)
}
func TestCopilotIdempotentCommitChecksSameContentSettingsIdentity(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
})
installCopilotFixture(t, fixture)
sources, _, err := readCopilotSources()
if err != nil {
t.Fatalf("read GitHub Copilot sources: %v", err)
}
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
if err != nil {
t.Fatalf("preflight GitHub Copilot installation: %v", err)
}
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
)
if err != nil {
t.Fatalf("render GitHub Copilot installation: %v", err)
}
prepared, err := prepareCopilotInstallation(fixture.config, sources, rendered)
if err != nil {
t.Fatalf("prepare GitHub Copilot installation: %v", err)
}
original, err := os.ReadFile(fixture.userSettings)
if err != nil {
t.Fatalf("read user settings: %v", err)
}
replacement := fixture.userSettings + ".replacement"
if err := os.WriteFile(replacement, original, 0600); err != nil {
t.Fatalf("write replacement settings: %v", err)
}
if err := os.Rename(replacement, fixture.userSettings); err != nil {
t.Fatalf("replace user settings: %v", err)
}
err = commitCopilotInstallation(prepared, fixture.plugin.rename)
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
t.Fatalf("commit error = %v", err)
}
}
func TestCopilotInstallPreservesOrphanedRuntimeWithoutIdentity(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent runtime directory: %v", err)
}
if err := os.WriteFile(fixture.hookPath, []byte("orphaned\n"), 0700); err != nil {
t.Fatalf("write orphaned runtime: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "cannot be verified") {
t.Fatalf("install error = %v", err)
}
current, readErr := os.ReadFile(fixture.hookPath)
if readErr != nil || string(current) != "orphaned\n" {
t.Fatalf("orphaned runtime = %q, %v", current, readErr)
}
for _, path := range []string{
fixture.configPath, fixture.guardPath, fixture.runtimeConfig, fixture.privateKey,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected file at %s: %v", path, err)
}
}
}
func TestCopilotInstallRejectsLinkedHomeAndRuntimeDirectories(t *testing.T) {
for _, location := range []string{"home", "runtime"} {
t.Run(location, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
target := filepath.Join(filepath.Dir(fixture.homeDir), location+"-target")
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create symlink target: %v", err)
}
linkPath := fixture.copilotHome
if location == "runtime" {
linkPath = fixture.agentDir
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
t.Fatalf("create runtime root: %v", err)
}
}
if err := os.Symlink(target, linkPath); err != nil {
t.Skipf("create directory symlink: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("install error = %v", err)
}
})
}
}
func TestCopilotStatusRequiresExactRuntimeSourcesAndPrivateKey(t *testing.T) {
for _, name := range []string{"guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
path := fixture.guardPath
if name == "audit" {
path = fixture.hookPath
}
if err := os.WriteFile(path, []byte("// tampered\n"), 0700); err != nil {
t.Fatalf("tamper %s runtime: %v", name, err)
}
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("tampered %s status = %#v, %v", name, status, err)
}
})
}
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "canonical 32-byte") {
t.Fatalf("invalid key status error = %v", err)
}
}
func TestCopilotValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
edit func(*copilotFixture)
want string
}{
{"agent name", func(f *copilotFixture) { f.config.AgentName = "codex" }, "requires agent name copilot"},
{"organization", func(f *copilotFixture) { f.config.OrgID = " " }, "organization ID is required"},
{"key ID", func(f *copilotFixture) { f.config.KID = "\t" }, "key ID is required"},
{"token", func(f *copilotFixture) { f.config.Token = " " }, "token must contain"},
{"private key", func(f *copilotFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
{"base URL", func(f *copilotFixture) {
f.config.BaseURL = "https://api.elydora.com/path?token=secret"
}, "query parameters"},
{"guard path", func(f *copilotFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed agent directory"},
{"audit path", func(f *copilotFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed agent directory"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
testCase.edit(fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
+95 -187
View File
@@ -2,236 +2,144 @@ package plugins
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// CursorPlugin manages the Elydora audit hook for Cursor.
// It writes/merges into ~/.cursor/hooks.json using nested settings.hooks.postToolUse[].
type CursorPlugin struct{}
func (p *CursorPlugin) configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
// CursorPlugin manages Cursor's native global user hooks.
type CursorPlugin struct {
rename renameFunc
}
return filepath.Join(home, ".cursor", "hooks.json"), nil
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *CursorPlugin) ManagesGuardRuntime() bool {
return true
}
func cursorAgentPaths(config InstallConfig) (*managedRuntimePaths, error) {
return resolveManagedRuntimePaths(config, cursorGuardScript, cursorAuditScript)
}
// PreflightInstall validates every source before the CLI creates runtime state.
func (p *CursorPlugin) PreflightInstall(config InstallConfig) error {
if _, err := readCursorDocument(); err != nil {
return err
}
paths, err := cursorAgentPaths(config)
if err != nil {
return err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
); err != nil {
return err
}
_, err = resolveNodeRuntime()
return err
}
func (p *CursorPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID)
document, err := readCursorDocument()
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
paths, err := cursorAgentPaths(config)
if err != nil {
return err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
); err != nil {
return err
}
configPath, err := p.configPath()
nodePath, err := resolveNodeRuntime()
if err != nil {
return err
}
settings, err := readJSONFile(configPath)
hooks := removeManagedCursorHooks(document.hooks, paths.runtimeRoot, "")
hooks["preToolUse"] = append(hooks["preToolUse"], buildCursorHandler(nodePath, paths.guardPath))
hooks["postToolUse"] = append(hooks["postToolUse"], buildCursorHandler(nodePath, paths.auditPath))
hooks["postToolUseFailure"] = append(
hooks["postToolUseFailure"],
buildCursorHandler(nodePath, paths.auditPath),
)
rendered, err := renderCursorDocument(document, hooks, paths.runtimeRoot)
if err != nil {
return fmt.Errorf("render Cursor user hooks: %w", err)
}
changes, err := prepareCursorInstallationChanges(config, paths, rendered)
if err != nil {
return err
}
// Ensure hooks object exists
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- preToolUse (guard — freeze enforcement) ---
preToolUse, _ := hooks["preToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"command": "node " + guardPath,
}
preFiltered = append(preFiltered, guardEntry)
hooks["preToolUse"] = preFiltered
// --- postToolUse (audit logging) ---
postToolUse, _ := hooks["postToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"command": "node " + scriptPath,
}
postFiltered = append(postFiltered, hookEntry)
hooks["postToolUse"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
if err := writeManagedChanges(
changes,
"Install Cursor hooks",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
"Cursor hooks directory",
); err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Cursor at %s\n", configPath)
fmt.Printf(" Cursor hooks: %s\n", document.filePath)
return nil
}
func (p *CursorPlugin) Uninstall(agentID string) error {
configPath, err := p.configPath()
document, err := readCursorDocument()
if err != nil {
return err
}
settings, err := readJSONFile(configPath)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Cursor hooks found.")
return nil
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, agentID)
rendered, err := renderCursorDocument(document, hooks, runtimeRoot)
if err != nil {
return fmt.Errorf("render Cursor user hooks: %w", err)
}
// Remove preToolUse Elydora entries
preToolUse, _ := hooks["preToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "preToolUse")
} else {
hooks["preToolUse"] = preFiltered
}
// Remove postToolUse Elydora entries
postToolUse, _ := hooks["postToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "postToolUse")
} else {
hooks["postToolUse"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
change, err := prepareRenderedCursorChange(rendered)
if err != nil {
return err
}
if agentID != "" {
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Cursor.")
return nil
return writeManagedChanges(
[]*fileChange{change},
"Uninstall Cursor hooks",
p.rename,
"",
"",
filepath.Dir(document.filePath),
"Cursor hooks directory",
)
}
func (p *CursorPlugin) Status() (PluginStatus, error) {
configPath, err := p.configPath()
if err != nil {
return PluginStatus{}, err
}
configPath, pathErr := cursorConfigPath()
entry := SupportedAgents[cursorAgentKey]
status := PluginStatus{
AgentName: "cursor",
DisplayName: "Cursor",
ConfigPath: configPath,
AgentName: cursorAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
}
settings, err := readJSONFile(configPath)
if pathErr != nil {
return status, pathErr
}
document, err := readCursorDocument()
if err != nil {
return status, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return status, err
}
contracts := cursorRuntimeContracts(document.hooks, runtimeRoot)
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasCursorElydoraEntry(hooks["preToolUse"])
postConfigured := hasCursorElydoraEntry(hooks["postToolUse"])
status.HookConfigured = preConfigured && postConfigured
// Extract hook script path from the configured command
scriptPath := extractCursorElydoraScriptPath(hooks["postToolUse"])
if scriptPath != "" {
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
status.HookScriptExists, err = cursorRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
}
}
status.Installed = status.HookConfigured && status.HookScriptExists
status.Installed = status.HookScriptExists
return status, nil
}
// extractCursorElydoraScriptPath extracts the script path from a Cursor hook array's Elydora command entry.
func extractCursorElydoraScriptPath(hookArray interface{}) string {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
return extractPathFromNodeCommand(cmd)
}
}
}
return ""
}
// hasCursorElydoraEntry checks if a Cursor hook array (flat format) contains an Elydora entry.
func hasCursorElydoraEntry(hookArray interface{}) bool {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
return true
}
}
}
return false
}
+317
View File
@@ -0,0 +1,317 @@
package plugins
import (
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
)
const (
cursorAgentKey = "cursor"
cursorConfigFile = "hooks.json"
cursorGuardScript = "guard.js"
cursorAuditScript = "hook.js"
cursorHookTimeout = float64(10)
)
type cursorHooks map[string][]map[string]any
type cursorDocument struct {
exists bool
filePath string
root map[string]any
hooks cursorHooks
raw []byte
}
type cursorRenderedDocument struct {
document *cursorDocument
changed bool
next []byte
remove bool
}
type cursorRuntimeContract = managedRuntimeContract
func cloneCursorHooks(source cursorHooks) cursorHooks {
clone := make(cursorHooks, len(source))
for event, handlers := range source {
clone[event] = append([]map[string]any(nil), handlers...)
}
return clone
}
func buildCursorHandler(nodePath, scriptPath string) map[string]any {
command := posixSource(nodePath, scriptPath)
if runtime.GOOS == "windows" {
command = powerShellSource(nodePath, scriptPath)
}
return map[string]any{
"command": command, "timeout": cursorHookTimeout, "failClosed": true,
}
}
func parseCursorLegacyCommand(command string) (string, bool) {
separator := strings.IndexByte(command, ' ')
if separator < 0 {
return "", false
}
executable := command[:separator]
if !strings.EqualFold(executable, "node") && !strings.EqualFold(executable, "node.exe") {
return "", false
}
script := command[separator+1:]
if len(script) >= 2 && script[0] == '"' && script[len(script)-1] == '"' {
script = script[1 : len(script)-1]
}
return script, script != "" && !strings.ContainsAny(script, "\r\n")
}
func cursorManagedScriptPath(handler map[string]any) (string, bool) {
if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true {
command, ok := handler["command"].(string)
if !ok {
return "", false
}
var nodePath, scriptPath string
var parsed bool
if runtime.GOOS == "windows" {
nodePath, scriptPath, parsed = parsePowerShellSource(command)
} else {
nodePath, scriptPath, parsed = parsePOSIXCommand(command)
}
if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) &&
isNodeExecutable(nodePath) {
return scriptPath, true
}
}
if len(handler) != 1 {
return "", false
}
command, ok := handler["command"].(string)
if !ok {
return "", false
}
return parseCursorLegacyCommand(command)
}
func cursorManagedAgentID(
handler map[string]any,
scriptName string,
runtimeRoot string,
) (string, bool) {
scriptPath, managed := cursorManagedScriptPath(handler)
if !managed || !filepath.IsAbs(scriptPath) || filepath.Base(scriptPath) != scriptName {
return "", false
}
agentDirectory := filepath.Dir(scriptPath)
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false
}
agentID := filepath.Base(agentDirectory)
return agentID, agentID != "" && agentID != "." && agentID != ".."
}
func readCursorHooks(value any, label string) (cursorHooks, error) {
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
}
hooks := make(cursorHooks, len(object))
for event, handlerValue := range object {
values, ok := handlerValue.([]any)
if !ok {
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
}
handlers := make([]map[string]any, 0, len(values))
for index, value := range values {
handler, ok := value.(map[string]any)
if !ok || handler == nil {
return nil, fmt.Errorf(`%s handler hooks.%s[%d] must be an object`, label, event, index)
}
handlers = append(handlers, handler)
}
hooks[event] = handlers
}
return hooks, nil
}
func cursorContainsManagedHook(hooks cursorHooks, runtimeRoot string) bool {
for _, contract := range []struct{ event, script string }{
{"preToolUse", cursorGuardScript},
{"postToolUse", cursorAuditScript},
{"postToolUseFailure", cursorAuditScript},
} {
for _, handler := range hooks[contract.event] {
if _, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot); managed {
return true
}
}
}
return false
}
func parseCursorDocument(filePath string, raw []byte, runtimeRoot string) (*cursorDocument, error) {
label := fmt.Sprintf("Cursor user hooks at %s", filePath)
if !json.Valid(raw) {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("parse %s: %w", label, err)
}
}
root, err := decodeJSONCObject(raw, label, false)
if err != nil {
return nil, err
}
hooks := cursorHooks{}
if value, exists := root["hooks"]; exists {
hooks, err = readCursorHooks(value, label)
if err != nil {
return nil, err
}
}
version, hasVersion := root["version"]
current := hasVersion && version == float64(1)
if !current && (hasVersion || !cursorContainsManagedHook(hooks, runtimeRoot)) {
return nil, fmt.Errorf("%s must declare version 1", label)
}
return &cursorDocument{
exists: true, filePath: filePath, root: root,
hooks: hooks, raw: append([]byte(nil), raw...),
}, nil
}
func createCursorDocument(filePath string) *cursorDocument {
return &cursorDocument{filePath: filePath, root: map[string]any{}, hooks: cursorHooks{}}
}
func removeManagedCursorHooks(hooks cursorHooks, runtimeRoot, agentID string) cursorHooks {
result := cloneCursorHooks(hooks)
for _, contract := range []struct{ event, script string }{
{"preToolUse", cursorGuardScript},
{"postToolUse", cursorAuditScript},
{"postToolUseFailure", cursorAuditScript},
} {
kept := make([]map[string]any, 0, len(result[contract.event]))
for _, handler := range result[contract.event] {
managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot)
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
continue
}
kept = append(kept, handler)
}
if len(kept) == 0 {
delete(result, contract.event)
} else {
result[contract.event] = kept
}
}
return result
}
func entirelyManagedCursorDocument(document *cursorDocument, runtimeRoot string) bool {
if !document.exists || len(document.hooks) == 0 {
return false
}
for key := range document.root {
if key != "version" && key != "hooks" {
return false
}
}
count := 0
for event, handlers := range document.hooks {
script := map[string]string{
"preToolUse": cursorGuardScript,
"postToolUse": cursorAuditScript,
"postToolUseFailure": cursorAuditScript,
}[event]
if script == "" || len(handlers) == 0 {
return false
}
count += len(handlers)
for _, handler := range handlers {
if _, managed := cursorManagedAgentID(handler, script, runtimeRoot); !managed {
return false
}
}
}
return count > 0
}
func renderCursorDocument(
document *cursorDocument,
hooks cursorHooks,
runtimeRoot string,
) (*cursorRenderedDocument, error) {
if !document.exists && len(hooks) == 0 {
return &cursorRenderedDocument{document: document}, nil
}
if document.exists && reflect.DeepEqual(hooks, document.hooks) {
return &cursorRenderedDocument{document: document}, nil
}
if len(hooks) == 0 && entirelyManagedCursorDocument(document, runtimeRoot) {
return &cursorRenderedDocument{document: document, changed: true, remove: true}, nil
}
root := make(map[string]any, len(document.root)+1)
for key, value := range document.root {
root[key] = value
}
root["version"] = float64(1)
if len(hooks) == 0 {
delete(root, "hooks")
} else {
root["hooks"] = hooks
}
next, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, err
}
next = append(next, '\n')
return &cursorRenderedDocument{
document: document, changed: !document.exists || string(next) != string(document.raw), next: next,
}, nil
}
func managedCursorIDs(
handlers []map[string]any,
scriptName, runtimeRoot string,
) map[string]string {
result := map[string]string{}
for _, handler := range handlers {
agentID, managed := cursorManagedAgentID(handler, scriptName, runtimeRoot)
if !managed {
continue
}
result[managedReferenceKey(agentID)] = agentID
}
return result
}
func cursorRuntimeContracts(hooks cursorHooks, runtimeRoot string) []cursorRuntimeContract {
guards := managedCursorIDs(hooks["preToolUse"], cursorGuardScript, runtimeRoot)
audits := managedCursorIDs(hooks["postToolUse"], cursorAuditScript, runtimeRoot)
failures := managedCursorIDs(hooks["postToolUseFailure"], cursorAuditScript, runtimeRoot)
keys := make([]string, 0, len(guards))
for key := range guards {
_, hasAudit := audits[key]
_, hasFailureAudit := failures[key]
if hasAudit && hasFailureAudit {
keys = append(keys, key)
}
}
sort.Strings(keys)
contracts := make([]cursorRuntimeContract, 0, len(keys))
for _, key := range keys {
agentID := guards[key]
directory := filepath.Join(runtimeRoot, agentID)
contracts = append(contracts, cursorRuntimeContract{
agentID: agentID, guardPath: filepath.Join(directory, cursorGuardScript),
auditPath: filepath.Join(directory, cursorAuditScript),
})
}
return contracts
}
+80
View File
@@ -0,0 +1,80 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
func cursorConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".cursor", cursorConfigFile), nil
}
func readCursorDocument() (*cursorDocument, error) {
filePath, err := cursorConfigPath()
if err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
snapshot, err := readManagedFile(filePath, "Cursor user hooks", maxManagedSourceBytes)
if err != nil {
return nil, err
}
if snapshot == nil {
return createCursorDocument(filePath), nil
}
return parseCursorDocument(filePath, snapshot.contents, runtimeRoot)
}
func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSourceChange(
rendered.document.filePath,
"Cursor user hooks",
rendered.document.raw,
rendered.document.exists,
rendered.next,
0600,
rendered.remove,
)
}
func prepareCursorInstallationChanges(
config InstallConfig,
paths *managedRuntimePaths,
rendered *cursorRenderedDocument,
) ([]*fileChange, error) {
if config.BaseURL == "" {
config.BaseURL = "https://api.elydora.com"
}
changes, err := managedRuntimeFileChanges(
config,
paths,
cursorAgentKey,
generateGuardScript(
cursorAgentKey, config.AgentID, `{"permission":"allow"}`+"\n", true, "cursor",
),
buildHookScriptWithOutput(cursorAgentKey, config.AgentID, "{}\n", true, true),
)
if err != nil {
return nil, err
}
documentChange, err := prepareRenderedCursorChange(rendered)
if err != nil {
return nil, err
}
return append(changes, documentChange), nil
}
func cursorRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
return managedRuntimeFilesPresent(contracts, cursorAgentKey)
}
+291
View File
@@ -0,0 +1,291 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestCursorInstallRejectsSymlinkRuntimeTargets(t *testing.T) {
for _, name := range []string{"config", "private key", "guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
path := map[string]string{
"config": fixture.runtimeConfig, "private key": fixture.privateKey,
"guard": fixture.guardPath, "audit": fixture.hookPath,
}[name]
target := filepath.Join(fixture.homeDir, strings.ReplaceAll(name, " ", "-")+"-target")
if err := os.WriteFile(target, []byte("target\n"), 0600); err != nil {
t.Fatalf("write %s target: %v", name, err)
}
if err := os.Symlink(target, path); err != nil {
t.Skipf("create %s symlink: %v", name, err)
}
if err := fixture.plugin.Install(fixture.config); err == nil ||
!strings.Contains(err.Error(), "physical file") {
t.Fatalf("symlink %s install error = %v", name, err)
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Cursor config exists after rejection: %v", err)
}
content, err := os.ReadFile(target)
if err != nil || string(content) != "target\n" {
t.Fatalf("%s target = %q, %v", name, content, err)
}
})
}
}
func TestCursorInstallRejectsSymlinkUserConfig(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
target := filepath.Join(fixture.homeDir, "cursor-hooks-target.json")
source := `{"version":1,"hooks":{}}` + "\n"
if err := os.WriteFile(target, []byte(source), 0600); err != nil {
t.Fatalf("write Cursor config target: %v", err)
}
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
t.Fatalf("create Cursor config directory: %v", err)
}
if err := os.Symlink(target, fixture.configPath); err != nil {
t.Skipf("create Cursor config symlink: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err == nil ||
!strings.Contains(err.Error(), "physical file") {
t.Fatalf("symlink config install error = %v", err)
}
content, err := os.ReadFile(target)
if err != nil || string(content) != source {
t.Fatalf("Cursor config target = %q, %v", content, err)
}
}
func TestCursorInstallRejectsSymlinkUserConfigDirectory(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
target := t.TempDir()
if err := os.Symlink(target, filepath.Dir(fixture.configPath)); err != nil {
t.Skipf("create Cursor config directory symlink: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err == nil ||
!strings.Contains(err.Error(), "physical directory") {
t.Fatalf("symlink config directory install error = %v", err)
}
assertCursorRuntimeAbsent(t, fixture)
entries, err := os.ReadDir(target)
if err != nil || len(entries) != 0 {
t.Fatalf("Cursor config target entries = %#v, %v", entries, err)
}
}
func TestCursorInstallRejectsRuntimeIdentityBeforeWrites(t *testing.T) {
fixture := prepareCursorFixture(t, nil, true)
writeCursorObject(t, fixture.runtimeConfig, map[string]any{
"agent_name": "codex", "agent_id": cursorTestAgentID,
})
guardBefore, err := os.ReadFile(fixture.guardPath)
if err != nil {
t.Fatalf("read guard before install: %v", err)
}
err = fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("runtime identity install error = %v", err)
}
guardAfter, readErr := os.ReadFile(fixture.guardPath)
if readErr != nil || string(guardAfter) != string(guardBefore) {
t.Fatalf("guard changed to %q, %v", guardAfter, readErr)
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Cursor config exists after identity rejection: %v", err)
}
}
func TestCursorInstallRejectsOrphanRuntimeBeforeWrites(t *testing.T) {
fixture := prepareCursorFixture(t, nil, true)
guardBefore, err := os.ReadFile(fixture.guardPath)
if err != nil {
t.Fatalf("read orphan guard: %v", err)
}
err = fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
t.Fatalf("orphan runtime install error = %v", err)
}
guardAfter, readErr := os.ReadFile(fixture.guardPath)
if readErr != nil || string(guardAfter) != string(guardBefore) {
t.Fatalf("orphan guard changed to %q, %v", guardAfter, readErr)
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Cursor config exists after orphan rejection: %v", err)
}
}
func TestCursorStatusRejectsSymlinkRuntimeFiles(t *testing.T) {
for _, name := range []string{"config", "private key", "guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
path := map[string]string{
"config": fixture.runtimeConfig, "private key": fixture.privateKey,
"guard": fixture.guardPath, "audit": fixture.hookPath,
}[name]
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s runtime: %v", name, err)
}
target := filepath.Join(fixture.homeDir, "status-"+strings.ReplaceAll(name, " ", "-"))
if err := os.WriteFile(target, content, 0600); err != nil {
t.Fatalf("write %s target: %v", name, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove %s runtime: %v", name, err)
}
if err := os.Symlink(target, path); err != nil {
t.Skipf("create %s runtime symlink: %v", name, err)
}
status, err := fixture.plugin.Status()
if err == nil || status.Installed {
t.Fatalf("Cursor status = %#v, %v", status, err)
}
})
}
}
func TestCursorInstallRequiresNodeBeforeWrites(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
t.Setenv("PATH", "")
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "Node.js") {
t.Fatalf("install error = %v", err)
}
assertCursorRuntimeAbsent(t, fixture)
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Cursor config exists after Node.js rejection: %v", err)
}
}
func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
if err := os.Remove(fixture.agentDir); err != nil {
t.Fatalf("remove empty agent directory: %v", err)
}
installCursorFixture(t, fixture)
for _, path := range []string{
fixture.runtimeConfig,
fixture.privateKey,
fixture.guardPath,
fixture.hookPath,
} {
if exists, err := managedPhysicalFileExists(path, "Cursor runtime", maxManagedSourceBytes); err != nil || !exists {
t.Fatalf("runtime file %s exists = %v, %v", path, exists, err)
}
}
}
func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) {
fixture := prepareCursorFixture(t, cursorJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{"sessionStart": []any{map[string]any{"command": "keep"}}},
}), false)
before, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read Cursor config: %v", err)
}
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && sameManagedPath(destination, fixture.configPath) {
failed = true
return errors.New("injected Cursor config commit failure")
}
return os.Rename(source, destination)
}
err = fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Cursor config commit failure") {
t.Fatalf("install error = %v", err)
}
assertCursorFileBytes(t, fixture.configPath, before)
assertCursorRuntimeAbsent(t, fixture)
assertNoCursorTransactionArtifacts(t, fixture.homeDir)
}
func TestCursorInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) {
fixture := prepareCursorFixture(t, cursorJSON(map[string]any{
"version": float64(1), "hooks": map[string]any{},
}), false)
concurrent := []byte(`{"version":1,"hooks":{"sessionStart":[{"command":"concurrent"}]}}` + "\n")
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated && strings.HasSuffix(source, ".tmp") {
mutated = true
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
return err
}
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("install error = %v", err)
}
assertCursorFileBytes(t, fixture.configPath, concurrent)
assertCursorRuntimeAbsent(t, fixture)
assertNoCursorTransactionArtifacts(t, fixture.homeDir)
}
func TestCursorAtomicConfigIsPrivateAndLeavesNoStagingFiles(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
if runtime.GOOS != "windows" {
info, err := os.Stat(fixture.configPath)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("Cursor config mode = %v, %v", info.Mode().Perm(), err)
}
}
assertNoCursorTransactionArtifacts(t, fixture.homeDir)
}
func assertCursorRuntimeAbsent(t *testing.T, fixture *cursorFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertCursorFileBytes(t *testing.T, path string, expected []byte) {
t.Helper()
actual, err := os.ReadFile(path)
if err != nil || string(actual) != string(expected) {
t.Fatalf("file %s = %q, %v; want %q", path, actual, err, expected)
}
}
func assertNoCursorTransactionArtifacts(t *testing.T, roots ...string) {
t.Helper()
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk transaction root %s: %v", root, err)
}
}
}
+376
View File
@@ -0,0 +1,376 @@
package plugins
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
func TestCursorRegistryUsesNativeUserHooks(t *testing.T) {
want := AgentRegistryEntry{Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}
if got := SupportedAgents["cursor"]; !reflect.DeepEqual(got, want) {
t.Fatalf("Cursor registry = %#v, want %#v", got, want)
}
}
func TestCursorInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
writeCursorObject(t, fixture.configPath, map[string]any{
"description": "user-owned",
"hooks": map[string]any{
"sessionStart": []any{map[string]any{"command": "user-session"}},
"preToolUse": []any{
map[string]any{"command": "user-pre"},
map[string]any{"command": "node " + fixture.guardPath},
},
"postToolUse": []any{map[string]any{"command": "node " + fixture.hookPath}},
},
})
installCursorFixture(t, fixture)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
if settings["version"] != float64(1) || settings["description"] != "user-owned" {
t.Fatalf("Cursor settings = %#v", settings)
}
hooks := cursorObject(t, settings["hooks"])
if len(cursorArray(t, hooks["sessionStart"])) != 1 ||
len(cursorArray(t, hooks["preToolUse"])) != 2 ||
len(cursorArray(t, hooks["postToolUse"])) != 1 ||
len(cursorArray(t, hooks["postToolUseFailure"])) != 1 {
t.Fatalf("Cursor hooks = %#v", hooks)
}
if cursorObject(t, cursorArray(t, hooks["preToolUse"])[0])["command"] != "user-pre" {
t.Fatalf("first preToolUse handler = %#v", cursorArray(t, hooks["preToolUse"])[0])
}
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "preToolUse", "guard.js"))
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "postToolUse", "hook.js"))
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "postToolUseFailure", "hook.js"))
runtimeConfig := readCursorObject(t, fixture.runtimeConfig)
if runtimeConfig["agent_name"] != "cursor" || runtimeConfig["agent_id"] != cursorTestAgentID {
t.Fatalf("runtime config = %#v", runtimeConfig)
}
}
func TestCursorCommandsBlockAndForwardOfficialPayloadByteForByte(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
guard := managedCursorHandler(t, settings, "preToolUse", "guard.js")
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "active", "cached_at": time.Now().UnixMilli(),
})
active := runCursorHandler(t, guard, "{}\n")
var allow map[string]any
if err := json.Unmarshal([]byte(active.stdout), &allow); err != nil || allow["permission"] != "allow" {
t.Fatalf("active guard output = %q, %v", active.stdout, err)
}
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "frozen", "cached_at": time.Now().UnixMilli(),
})
prePayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"Get-ChildItem"},"tool_use_id":"call-1","cwd":"project"}` + "\n"
frozen := runCursorHandler(t, guard, prePayload)
if frozen.exitCode != 2 || !strings.Contains(frozen.stderr, "frozen by Elydora") {
t.Fatalf("frozen guard result = %#v", frozen)
}
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "revoked", "cached_at": time.Now().UnixMilli(),
})
revoked := runCursorHandler(t, guard, prePayload)
if revoked.exitCode != 2 || !strings.Contains(revoked.stderr, "revoked in Elydora") {
t.Fatalf("revoked guard result = %#v", revoked)
}
capturePath := filepath.Join(t.TempDir(), "captured.json")
captureScript := "const fs = require('node:fs');\n" +
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n" +
"process.stdout.write('{}\\n');\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
t.Fatalf("write capture runtime: %v", err)
}
postPayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUse","tool_name":"Shell","tool_input":{"command":"Get-ChildItem"},"tool_output":"{\"exitCode\":0,\"stdout\":\"ok\"}","tool_use_id":"call-1","cwd":"project","duration":42}` + "\n"
audit := managedCursorHandler(t, settings, "postToolUse", "hook.js")
result := runCursorHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
captured, err := os.ReadFile(capturePath)
if err != nil || result.exitCode != 0 || string(captured) != postPayload {
t.Fatalf("audit result = %#v, captured = %q, %v", result, captured, err)
}
failurePayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUseFailure","tool_name":"Shell","tool_input":{"command":"exit 1"},"tool_use_id":"call-2","cwd":"project","error_message":"command failed","failure_type":"error","duration":21,"is_interrupt":false}` + "\n"
failureAudit := managedCursorHandler(t, settings, "postToolUseFailure", "hook.js")
failureResult := runCursorHandler(t, failureAudit, failurePayload, "ELYDORA_CAPTURE="+capturePath)
captured, err = os.ReadFile(capturePath)
if err != nil || failureResult.exitCode != 0 || string(captured) != failurePayload {
t.Fatalf("failure audit result = %#v, captured = %q, %v", failureResult, captured, err)
}
}
func TestCursorAuditPreservesNativeFailurePayload(t *testing.T) {
received := make(chan map[string]any, 1)
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
defer request.Body.Close()
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
t.Errorf("decode operation: %v", err)
}
received <- operation
response.WriteHeader(http.StatusCreated)
}))
defer server.Close()
fixture := prepareCursorFixture(t, nil, false)
fixture.config.BaseURL = server.URL
fixture.config.PrivateKey = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
installCursorFixture(t, fixture)
payload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUseFailure","tool_name":"Shell","tool_input":{"command":"exit 1"},"tool_use_id":"call-1","cwd":"project","error_message":"command failed","failure_type":"error","duration":42,"is_interrupt":false}` + "\n"
handler := managedCursorHandler(
t,
readCursorObject(t, fixture.configPath),
"postToolUseFailure",
cursorAuditScript,
)
result := runCursorHandler(t, handler, payload)
if result.exitCode != 0 || result.stdout != "{}\n" || result.stderr != "" {
t.Fatalf("Cursor audit result = %#v", result)
}
operation := <-received
var expected map[string]any
if err := json.Unmarshal([]byte(payload), &expected); err != nil {
t.Fatalf("decode expected payload: %v", err)
}
if !reflect.DeepEqual(operation["payload"], expected) {
t.Fatalf("operation payload = %#v, want %#v", operation["payload"], expected)
}
subject := cursorObject(t, operation["subject"])
if subject["session_id"] != "conversation-1" {
t.Fatalf("operation subject = %#v", subject)
}
}
func TestCursorGeneratedRuntimesFailClosed(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
if err := os.Remove(fixture.runtimeConfig); err != nil {
t.Fatalf("remove runtime config: %v", err)
}
guard := runCursorHandler(
t,
managedCursorHandler(t, settings, "preToolUse", cursorGuardScript),
"{}\n",
)
if guard.exitCode != 1 || guard.stdout != "" || !strings.Contains(guard.stderr, "Failed to read agent config") {
t.Fatalf("guard failure = %#v", guard)
}
auditFixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, auditFixture)
auditSettings := readCursorObject(t, auditFixture.configPath)
if err := os.Remove(auditFixture.privateKey); err != nil {
t.Fatalf("remove private key: %v", err)
}
audit := runCursorHandler(
t,
managedCursorHandler(t, auditSettings, "postToolUse", cursorAuditScript),
`{"conversation_id":"conversation-1","tool_name":"Shell","tool_input":{}}`+"\n",
)
if audit.exitCode != 1 || audit.stdout != "" || !strings.Contains(audit.stderr, "Failed to read agent config/key") {
t.Fatalf("audit failure = %#v", audit)
}
}
func TestCursorStatusRequiresExactPairIdentityAndRuntimeFiles(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
settings := readCursorObject(t, fixture.configPath)
managedCursorHandler(t, settings, "preToolUse", "guard.js")["failClosed"] = false
writeCursorObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("modified handler status = %#v, %v", status, err)
}
installCursorFixture(t, fixture)
settings = readCursorObject(t, fixture.configPath)
delete(cursorObject(t, settings["hooks"]), "postToolUseFailure")
writeCursorObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("missing failure audit status = %#v, %v", status, err)
}
installCursorFixture(t, fixture)
if err := os.Remove(fixture.hookPath); err != nil {
t.Fatalf("remove audit runtime: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || !status.HookConfigured || status.HookScriptExists || status.Installed {
t.Fatalf("missing runtime status = %#v, %v", status, err)
}
installCursorFixture(t, fixture)
runtimeConfig := readCursorObject(t, fixture.runtimeConfig)
runtimeConfig["agent_id"] = "another-agent"
writeCursorObject(t, fixture.runtimeConfig, runtimeConfig)
status, err = fixture.plugin.Status()
if err != nil || status.HookScriptExists || status.Installed {
t.Fatalf("identity mismatch status = %#v, %v", status, err)
}
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("write malformed runtime config: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
t.Fatalf("malformed runtime status error = %v", err)
}
}
func TestCursorUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing.T) {
fixture := prepareCursorFixture(t, cursorJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{"sessionStart": []any{map[string]any{"command": "keep"}}},
}), false)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
hooks := cursorObject(t, settings["hooks"])
pre := managedCursorHandler(t, settings, "preToolUse", "guard.js")
post := managedCursorHandler(t, settings, "postToolUse", "hook.js")
otherPre := map[string]any{}
otherPost := map[string]any{}
otherFailure := map[string]any{}
for key, value := range pre {
otherPre[key] = value
}
for key, value := range post {
otherPost[key] = value
otherFailure[key] = value
}
otherPre["command"] = strings.ReplaceAll(cursorStringValue(otherPre["command"]), "agent-1", "agent-10")
otherPost["command"] = strings.ReplaceAll(cursorStringValue(otherPost["command"]), "agent-1", "agent-10")
hooks["preToolUse"] = append(cursorArray(t, hooks["preToolUse"]), otherPre, map[string]any{
"command": "echo elydora agent-1 guard.js", "timeout": float64(10), "failClosed": true,
})
hooks["postToolUse"] = append(cursorArray(t, hooks["postToolUse"]), otherPost)
otherFailure["command"] = strings.ReplaceAll(
cursorStringValue(otherFailure["command"]),
"agent-1",
"agent-10",
)
hooks["postToolUseFailure"] = append(
cursorArray(t, hooks["postToolUseFailure"]),
otherFailure,
)
writeCursorObject(t, fixture.configPath, settings)
uninstallID := cursorTestAgentID
if runtime.GOOS == "windows" {
uninstallID = strings.ToUpper(uninstallID)
}
if err := fixture.plugin.Uninstall(uninstallID); err != nil {
t.Fatalf("uninstall Cursor hooks: %v", err)
}
remaining := readCursorObject(t, fixture.configPath)
remainingHooks := cursorObject(t, remaining["hooks"])
if len(cursorArray(t, remainingHooks["preToolUse"])) != 2 ||
len(cursorArray(t, remainingHooks["postToolUse"])) != 1 ||
len(cursorArray(t, remainingHooks["postToolUseFailure"])) != 1 ||
len(cursorArray(t, remainingHooks["sessionStart"])) != 1 {
t.Fatalf("remaining hooks = %#v", remainingHooks)
}
}
func TestCursorRejectsInvalidConfigBeforeRuntimeWrites(t *testing.T) {
cases := []string{
"{ malformed", "[]\n", `{"hooks":{}}`, `{"version":2,"hooks":{}}`,
`{"version":1,"hooks":null}`, `{"version":1,"hooks":{"preToolUse":null}}`,
`{"version":1,"hooks":{"preToolUse":[null]}}`,
`{"version":1,"version":1,"hooks":{}}`, `{"version":1,"hooks":{},}`,
`{"version":1,"hooks":{/* comments are outside Cursor's JSON contract */}}`,
}
for index, source := range cases {
t.Run(strings.ReplaceAll(source, "/", "_"), func(t *testing.T) {
fixture := prepareCursorFixture(t, cursorString(source), true)
originalGuard, readErr := os.ReadFile(fixture.guardPath)
if readErr != nil {
t.Fatalf("read original guard: %v", readErr)
}
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatalf("invalid config %d was accepted: %s", index, source)
}
raw, err := os.ReadFile(fixture.configPath)
if err != nil || string(raw) != source {
t.Fatalf("config changed to %q, %v", raw, err)
}
guard, err := os.ReadFile(fixture.guardPath)
if err != nil || string(guard) != string(originalGuard) {
t.Fatalf("guard changed, %v", err)
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("runtime write exists at %s: %v", path, err)
}
}
})
}
}
func TestCursorCreatesMissingGuardAndRejectsUnmanagedRuntimePaths(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
if exists, err := regularFileExists(fixture.guardPath, "Cursor guard"); err != nil || !exists {
t.Fatalf("guard exists = %v, %v", exists, err)
}
for _, field := range []string{"guard", "audit"} {
t.Run(field, func(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
unmanaged := filepath.Join(fixture.homeDir, "unmanaged-"+field+".js")
if field == "guard" {
fixture.config.GuardScriptPath = unmanaged
} else {
fixture.config.HookScript = unmanaged
}
if err := fixture.plugin.Install(fixture.config); err == nil ||
!strings.Contains(err.Error(), "managed agent directory") {
t.Fatalf("unmanaged %s error = %v", field, err)
}
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
t.Fatalf("Cursor config exists after rejected path: %v", err)
}
})
}
}
func TestCursorUninstallRemovesOwnedConfigAndPreservesUserEmptyConfig(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
t.Fatalf("uninstall Cursor hooks: %v", err)
}
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
t.Fatalf("owned Cursor config remains: %v", err)
}
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
t.Fatalf("uninstall absent Cursor hooks: %v", err)
}
userSource := `{"version":1,"hooks":{}}` + "\n"
writeOptionalCursorFile(t, fixture.configPath, cursorString(userSource))
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
t.Fatalf("uninstall user Cursor config: %v", err)
}
raw, err := os.ReadFile(fixture.configPath)
if err != nil || string(raw) != userSource {
t.Fatalf("user config = %q, %v", raw, err)
}
}
@@ -0,0 +1,202 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
const cursorTestAgentID = "agent-1"
type cursorFixture struct {
plugin *CursorPlugin
config InstallConfig
homeDir string
agentDir string
configPath string
guardPath string
hookPath string
runtimeConfig string
privateKey string
}
type cursorCommandResult struct {
exitCode int
stdout string
stderr string
}
func cursorString(value string) *string {
return &value
}
func cursorJSON(value any) *string {
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
panic(err)
}
return cursorString(string(encoded) + "\n")
}
func prepareCursorFixture(t *testing.T, existingRaw *string, createGuard bool) *cursorFixture {
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
preparePrivateTransactionTestDirectory(t, homeDir)
agentDir := filepath.Join(homeDir, ".elydora", cursorTestAgentID)
configPath := filepath.Join(homeDir, ".cursor", "hooks.json")
guardPath := filepath.Join(agentDir, "guard.js")
hookPath := filepath.Join(agentDir, "hook.js")
if err := os.MkdirAll(agentDir, 0700); err != nil {
t.Fatalf("create Cursor fixture: %v", err)
}
if createGuard {
if err := os.WriteFile(
guardPath,
[]byte(GenerateGuardScript("cursor", cursorTestAgentID)),
0700,
); err != nil {
t.Fatalf("write guard runtime: %v", err)
}
}
writeOptionalCursorFile(t, configPath, existingRaw)
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
return &cursorFixture{
plugin: &CursorPlugin{},
homeDir: homeDir,
agentDir: agentDir,
configPath: configPath,
guardPath: guardPath,
hookPath: hookPath,
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
config: InstallConfig{
AgentName: "cursor", OrgID: "org-1", AgentID: cursorTestAgentID,
PrivateKey: "test-key", KID: "kid-1", Token: "token-1",
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
},
}
}
func writeOptionalCursorFile(t *testing.T, path string, source *string) {
t.Helper()
if source == nil {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(*source), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func writeCursorObject(t *testing.T, path string, value map[string]any) {
t.Helper()
writeOptionalCursorFile(t, path, cursorJSON(value))
}
func readCursorObject(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func cursorObject(t *testing.T, value any) map[string]any {
t.Helper()
object, ok := value.(map[string]any)
if !ok {
t.Fatalf("value is not an object: %#v", value)
}
return object
}
func cursorArray(t *testing.T, value any) []any {
t.Helper()
array, ok := value.([]any)
if !ok {
t.Fatalf("value is not an array: %#v", value)
}
return array
}
func managedCursorHandler(t *testing.T, settings map[string]any, event, script string) map[string]any {
t.Helper()
hooks := cursorObject(t, settings["hooks"])
for _, value := range cursorArray(t, hooks[event]) {
handler := cursorObject(t, value)
if strings.Contains(cursorStringValue(handler["command"]), script) {
return handler
}
}
t.Fatalf("managed Cursor handler for %s was not found", event)
return nil
}
func cursorStringValue(value any) string {
result, _ := value.(string)
return result
}
func assertNativeCursorHandler(t *testing.T, handler map[string]any) {
t.Helper()
if len(handler) != 3 || handler["timeout"] != float64(10) || handler["failClosed"] != true {
t.Fatalf("Cursor handler = %#v", handler)
}
command := cursorStringValue(handler["command"])
if !strings.Contains(strings.ToLower(command), "node") {
t.Fatalf("Cursor command = %q", command)
}
if runtime.GOOS == "windows" &&
(!strings.HasPrefix(command, "& '") || !strings.HasSuffix(command, "; exit $LASTEXITCODE")) {
t.Fatalf("Cursor PowerShell command = %q", command)
}
if runtime.GOOS != "windows" && !strings.HasPrefix(command, "'") {
t.Fatalf("Cursor POSIX command = %q", command)
}
}
func runCursorHandler(t *testing.T, handler map[string]any, payload string, environment ...string) cursorCommandResult {
t.Helper()
var process *exec.Cmd
if runtime.GOOS == "windows" {
process = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", cursorStringValue(handler["command"]))
} else {
process = exec.Command("sh", "-c", cursorStringValue(handler["command"]))
}
process.Env = append(os.Environ(), environment...)
process.Stdin = strings.NewReader(payload)
var stdout, stderr bytes.Buffer
process.Stdout, process.Stderr = &stdout, &stderr
err := process.Run()
if err == nil {
return cursorCommandResult{stdout: stdout.String(), stderr: stderr.String()}
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return cursorCommandResult{exitCode: exitError.ExitCode(), stdout: stdout.String(), stderr: stderr.String()}
}
t.Fatalf("run Cursor hook: %v", err)
return cursorCommandResult{exitCode: -1}
}
func installCursorFixture(t *testing.T, fixture *cursorFixture) {
t.Helper()
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Cursor hooks: %v", err)
}
}
+35
View File
@@ -0,0 +1,35 @@
package plugins
import (
"regexp"
"strings"
)
var dotenvLinePattern = regexp.MustCompile(
"(?m)^\\s*(export\\s+)?([\\w.-]+)(\\s*=\\s*?|:\\s+?)" +
"(\\s*'(\\\\'|[^'])*'|\\s*\"(\\\\\"|[^\"])*\"|" +
"\\s*`(\\\\`|[^`])*`|[^#\\r\\n]+)?\\s*(#.*)?$",
)
func parseDotenv(source []byte) map[string]string {
values := map[string]string{}
normalized := strings.ReplaceAll(string(source), "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r", "\n")
for _, match := range dotenvLinePattern.FindAllStringSubmatch(normalized, -1) {
key := match[2]
value := strings.TrimSpace(match[4])
quote := byte(0)
if value != "" {
quote = value[0]
}
if len(value) >= 2 && (quote == '\'' || quote == '"' || quote == '`') && value[len(value)-1] == quote {
value = value[1 : len(value)-1]
}
if quote == '"' {
value = strings.ReplaceAll(value, `\n`, "\n")
value = strings.ReplaceAll(value, `\r`, "\r")
}
values[key] = value
}
return values
}
+149
View File
@@ -0,0 +1,149 @@
package plugins
import "fmt"
// DroidPlugin manages Factory Droid native user hooks.
type DroidPlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *DroidPlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates every source before the CLI creates runtime state.
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
sources, err := readDroidSources()
if err != nil {
return err
}
_, _, err = preflightDroidInstallation(config, sources)
return err
}
func renderDroidInstallation(
sources *droidSources,
guardPath, auditPath, nodePath, runtimeRoot string,
) ([]*droidRenderedDocument, error) {
target := activeDroidDocument(sources)
groups := map[string]map[string]any{
"PreToolUse": buildDroidGroup(nodePath, guardPath),
"PostToolUse": buildDroidGroup(nodePath, auditPath),
}
documents := droidInstallationDocuments(sources)
rendered := make([]*droidRenderedDocument, 0, len(documents))
for _, document := range documents {
item, err := renderDroidDocument(
document,
"",
runtimeRoot,
droidAdditionsFor(document, target, groups),
)
if err != nil {
return nil, err
}
rendered = append(rendered, item)
}
return rendered, nil
}
func (p *DroidPlugin) Install(config InstallConfig) error {
sources, err := readDroidSources()
if err != nil {
return err
}
paths, nodePath, err := preflightDroidInstallation(config, sources)
if err != nil {
return err
}
rendered, err := renderDroidInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
paths.runtimeRoot,
)
if err != nil {
return err
}
prepared, err := prepareDroidInstallation(config, sources, rendered)
if err != nil {
return err
}
if err := commitDroidInstallation(prepared, p.rename); err != nil {
return err
}
fmt.Printf("Factory Droid hooks: %s\n", activeDroidDocument(sources).filePath)
fmt.Println("Factory Droid: run /hooks to review the Elydora hook changes.")
return nil
}
func renderDroidUninstall(
sources *droidSources,
agentID, runtimeRoot string,
) ([]*droidRenderedDocument, error) {
documents := droidSourceDocuments(sources)
rendered := make([]*droidRenderedDocument, 0, len(documents))
for _, document := range documents {
item, err := renderDroidDocument(
document,
agentID,
runtimeRoot,
map[string]map[string]any{},
)
if err != nil {
return nil, err
}
rendered = append(rendered, item)
}
return rendered, nil
}
func (p *DroidPlugin) Uninstall(agentID string) error {
sources, err := readDroidSources()
if err != nil {
return err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return err
}
rendered, err := renderDroidUninstall(sources, agentID, runtimeRoot)
if err != nil {
return err
}
prepared, err := prepareDroidUninstall(rendered)
if err != nil {
return err
}
return commitDroidUninstall(prepared, p.rename)
}
func (p *DroidPlugin) Status() (PluginStatus, error) {
entry := SupportedAgents[droidAgentKey]
status := PluginStatus{AgentName: droidAgentKey, DisplayName: entry.Name}
sources, err := readDroidSources()
if err != nil {
return status, err
}
status.ConfigPath = displayDroidConfigPath(sources)
if droidHookBlocked(sources) != nil {
return status, nil
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return status, err
}
contracts := droidRuntimeContracts(effectiveDroidHooks(sources), runtimeRoot)
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookScriptExists, err = droidRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
+404
View File
@@ -0,0 +1,404 @@
package plugins
import (
"bytes"
"fmt"
"path/filepath"
"reflect"
"sort"
)
type droidDocument struct {
kind string
filePath string
exists bool
raw []byte
snapshot *managedFileSnapshot
root map[string]any
hooks droidHookSettings
basePath []any
hasHooksContainer bool
hooksDisabled *bool
showHookOutput *bool
ownedFile bool
}
type droidSources struct {
root *droidDocument
legacy *droidDocument
settings *droidDocument
localSettings *droidDocument
policy *droidPolicyState
}
type droidRenderedDocument struct {
document *droidDocument
changed bool
next []byte
remove bool
}
type droidHookBlock struct {
field string
filePath string
label string
}
func parseDroidDocument(
exists bool,
filePath, kind string,
raw []byte,
snapshot *managedFileSnapshot,
) (*droidDocument, error) {
label := droidDocumentLabel(kind, filePath)
root, err := decodeJSONCObject(raw, label, true)
if err != nil {
return nil, err
}
document := &droidDocument{
kind: kind, filePath: filePath, exists: exists,
raw: append([]byte(nil), raw...), snapshot: snapshot, root: root,
ownedFile: bytes.HasPrefix(raw, []byte(droidOwnedFileMarker)),
}
if kind == "settings" || kind == "local-settings" {
document.basePath = []any{"hooks"}
document.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", label)
if err != nil {
return nil, err
}
document.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", label)
if err != nil {
return nil, err
}
hooksValue, hasHooks := root["hooks"]
document.hasHooksContainer = hasHooks
if !hasHooks {
document.hooks = droidHookSettings{}
return document, nil
}
document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
return document, err
}
if hooksValue, hasHooks := root["hooks"]; hasHooks {
document.basePath = []any{"hooks"}
document.hasHooksContainer = true
document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
return document, err
}
document.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", label)
if err != nil {
return nil, err
}
document.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", label)
if err != nil {
return nil, err
}
direct := make(map[string]any, len(root))
for key, value := range root {
if key != "hooksDisabled" && key != "showHookOutput" {
direct[key] = value
}
}
document.hooks, err = readDroidHookSettings(direct, label)
return document, err
}
func droidOptionalBoolean(
root map[string]any,
field, label string,
) (*bool, error) {
value, exists := root[field]
if !exists {
return nil, nil
}
boolean, ok := value.(bool)
if !ok {
return nil, fmt.Errorf(`%s field %q must be a boolean`, label, field)
}
return &boolean, nil
}
func droidDocumentLabel(kind, filePath string) string {
switch kind {
case "settings":
return fmt.Sprintf("Factory Droid settings at %s", filePath)
case "local-settings":
return fmt.Sprintf("Factory Droid local settings at %s", filePath)
case "legacy":
return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath)
default:
return fmt.Sprintf("Factory Droid hooks at %s", filePath)
}
}
func createDroidDocument(filePath, kind string, raw []byte) (*droidDocument, error) {
return parseDroidDocument(false, filePath, kind, raw, nil)
}
func createOwnedDroidDocument(filePath string) (*droidDocument, error) {
return createDroidDocument(
filePath,
"hooks",
[]byte(droidOwnedFileMarker+"\n{\n \"hooks\": {}\n}\n"),
)
}
func activeDroidDocument(sources *droidSources) *droidDocument {
switch {
case sources.root.exists:
return sources.root
case sources.legacy.exists:
return sources.legacy
case sources.localSettings.hasHooksContainer:
return sources.localSettings
case sources.settings.hasHooksContainer:
return sources.settings
default:
return sources.root
}
}
func effectiveDroidHooks(sources *droidSources) droidHookSettings {
return activeDroidDocument(sources).hooks
}
func droidHookBlocked(sources *droidSources) *droidHookBlock {
if sources.policy != nil && sources.policy.allowManagedHooksOnlyBy != nil {
origin := sources.policy.allowManagedHooksOnlyBy
return &droidHookBlock{"allowManagedHooksOnly", origin.filePath, origin.label}
}
if sources.policy != nil && sources.policy.hooksDisabled != nil {
if *sources.policy.hooksDisabled {
origin := sources.policy.hooksDisabledBy
return &droidHookBlock{"hooksDisabled", origin.filePath, origin.label}
}
return nil
}
selected := sources.settings
if sources.localSettings.hooksDisabled != nil {
selected = sources.localSettings
}
if selected.hooksDisabled != nil && *selected.hooksDisabled {
return &droidHookBlock{
"hooksDisabled", selected.filePath,
droidDocumentLabel(selected.kind, selected.filePath),
}
}
active := activeDroidDocument(sources)
if active.hooksDisabled != nil && *active.hooksDisabled {
return &droidHookBlock{
"hooksDisabled", active.filePath,
droidDocumentLabel(active.kind, active.filePath),
}
}
return nil
}
func droidSourceDocuments(sources *droidSources) []*droidDocument {
return []*droidDocument{
sources.root, sources.legacy, sources.settings, sources.localSettings,
}
}
func droidInstallationDocuments(sources *droidSources) []*droidDocument {
target := activeDroidDocument(sources)
candidates := []*droidDocument{target}
if sources.root.exists {
candidates = append(candidates, sources.root)
}
if sources.legacy.exists {
candidates = append(candidates, sources.legacy)
}
if sources.settings.hasHooksContainer {
candidates = append(candidates, sources.settings)
}
if sources.localSettings.hasHooksContainer {
candidates = append(candidates, sources.localSettings)
}
return uniqueDroidDocuments(candidates...)
}
func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
result := make([]*droidDocument, 0, len(documents))
seen := map[string]bool{}
for _, document := range documents {
if document == nil {
continue
}
normalized := filepath.Clean(document.filePath)
if seen[normalized] {
continue
}
seen[normalized] = true
result = append(result, document)
}
return result
}
func droidAdditionsFor(
document, target *droidDocument,
groups map[string]map[string]any,
) map[string]map[string]any {
if sameManagedPath(document.filePath, target.filePath) {
return groups
}
return map[string]map[string]any{}
}
func renderDroidDocument(
document *droidDocument,
agentID, runtimeRoot string,
additions map[string]map[string]any,
) (*droidRenderedDocument, error) {
if len(additions) > 0 && droidHasExactInstallation(document, additions, runtimeRoot) {
return &droidRenderedDocument{document: document, next: document.raw}, nil
}
editor, err := newJSONCEditor(
document.raw,
droidDocumentLabel(document.kind, document.filePath),
true,
)
if err != nil {
return nil, err
}
removals := managedDroidRemovals(document.hooks, agentID, runtimeRoot)
sort.SliceStable(removals, func(left, right int) bool {
if removals[left].event == removals[right].event {
return removals[left].groupIndex > removals[right].groupIndex
}
return removals[left].event < removals[right].event
})
for _, event := range droidToolEvents {
removedEvent := false
for _, removal := range removals {
if removal.event != event {
continue
}
removedEvent = true
groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex)
if removal.removeGroup {
if err := editor.remove(groupPath); err != nil {
return nil, err
}
continue
}
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes)))
for _, handlerIndex := range removal.handlerIndexes {
if err := editor.remove(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil {
return nil, err
}
}
}
if removedEvent {
current, parseErr := droidHooksFromRaw(document, editor.pack())
if parseErr != nil {
return nil, parseErr
}
groups, exists := current[event].([]any)
if exists && len(groups) == 0 {
if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil {
return nil, err
}
}
}
}
for _, event := range droidToolEvents {
group, exists := additions[event]
if !exists {
continue
}
current, parseErr := droidHooksFromRaw(document, editor.pack())
if parseErr != nil {
return nil, parseErr
}
eventPath := appendDroidPath(document.basePath, event)
if _, exists := current[event]; exists {
err = editor.appendArray(eventPath, group)
} else {
err = editor.addProperty(document.basePath, event, []any{group})
}
if err != nil {
return nil, err
}
}
next := editor.pack()
nextDocument, err := parseDroidDocument(
document.exists, document.filePath, document.kind, next, document.snapshot,
)
if err != nil {
return nil, err
}
remove := len(additions) == 0 && document.exists && document.ownedFile &&
droidHookDocumentEmpty(nextDocument)
return &droidRenderedDocument{
document: document,
changed: remove || !bytes.Equal(next, document.raw),
next: next,
remove: remove,
}, nil
}
func droidHasExactInstallation(
document *droidDocument,
additions map[string]map[string]any,
runtimeRoot string,
) bool {
if len(additions) != len(droidToolEvents) {
return false
}
removals := managedDroidRemovals(document.hooks, "", runtimeRoot)
if len(removals) != len(droidToolEvents) {
return false
}
for _, event := range droidToolEvents {
matches := make([]droidManagedRemoval, 0, 1)
for _, removal := range removals {
if removal.event == event {
matches = append(matches, removal)
}
}
if len(matches) != 1 || !matches[0].removeGroup {
return false
}
groups := document.hooks[event].([]any)
if !reflect.DeepEqual(groups[matches[0].groupIndex], additions[event]) {
return false
}
}
return true
}
func droidHooksFromRaw(
document *droidDocument,
raw []byte,
) (droidHookSettings, error) {
current, err := parseDroidDocument(
document.exists, document.filePath, document.kind, raw, document.snapshot,
)
if err != nil {
return nil, err
}
return current.hooks, nil
}
func droidHookDocumentEmpty(document *droidDocument) bool {
if document.kind == "settings" || document.kind == "local-settings" || len(document.hooks) > 0 {
return false
}
for field := range document.root {
if field != "hooks" {
return false
}
}
return true
}
func appendDroidPath(base []any, parts ...any) []any {
path := make([]any, 0, len(base)+len(parts))
path = append(path, base...)
return append(path, parts...)
}
func displayDroidConfigPath(sources *droidSources) string {
return activeDroidDocument(sources).filePath
}
+290
View File
@@ -0,0 +1,290 @@
package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const (
droidAgentKey = "droid"
droidGuardScript = "guard.js"
droidAuditScript = "hook.js"
droidHookTimeout = float64(10)
droidOwnedFileMarker = "// Managed by Elydora"
)
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"}
type droidHookSettings map[string]any
type droidManagedRemoval struct {
event string
groupIndex int
handlerIndexes []int
removeGroup bool
}
type droidRuntimeContract = managedRuntimeContract
type droidRegexEntry struct {
Label string `json:"label"`
Pattern string `json:"pattern"`
}
func readDroidHookSettings(value any, label string) (droidHookSettings, error) {
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf("%s must contain a JSON object", label)
}
hooks := make(droidHookSettings, len(object))
for event, item := range object {
groups, ok := item.([]any)
if !ok {
return nil, fmt.Errorf(`%s field %q must be an array`, label, event)
}
for groupIndex, group := range groups {
if err := validateDroidGroup(group, label, event, groupIndex); err != nil {
return nil, err
}
}
hooks[event] = item
}
return hooks, nil
}
func validateDroidGroup(value any, label, event string, groupIndex int) error {
location := fmt.Sprintf(`%s field %q[%d]`, label, event, groupIndex)
group, ok := value.(map[string]any)
if !ok || group == nil {
return fmt.Errorf("%s must be an object", location)
}
if matcher, exists := group["matcher"]; exists {
if _, ok := matcher.(string); !ok {
return fmt.Errorf("%s matcher must be a string", location)
}
}
if expression, exists := group["commandRegex"]; exists {
if _, ok := expression.(string); !ok {
return fmt.Errorf("%s commandRegex must be a string", location)
}
}
handlers, ok := group["hooks"].([]any)
if !ok {
return fmt.Errorf("%s must contain a hooks array", location)
}
for handlerIndex, handler := range handlers {
if err := validateDroidHandler(handler, location, handlerIndex); err != nil {
return err
}
}
return nil
}
func validateDroidHandler(value any, groupLabel string, handlerIndex int) error {
location := fmt.Sprintf("%s.hooks[%d]", groupLabel, handlerIndex)
handler, ok := value.(map[string]any)
if !ok || handler == nil {
return fmt.Errorf("%s must be an object", location)
}
if handler["type"] != "command" {
return fmt.Errorf(`%s type must be "command"`, location)
}
command, ok := handler["command"].(string)
if !ok || strings.TrimSpace(command) == "" {
return fmt.Errorf("%s command must be a non-empty string", location)
}
if timeout, exists := handler["timeout"]; exists {
number, ok := timeout.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
return fmt.Errorf("%s timeout must be a positive finite number", location)
}
}
return nil
}
func validateDroidRegexes(settings ...droidHookSettings) error {
entries := make([]droidRegexEntry, 0)
for _, source := range settings {
for event, value := range source {
groups, _ := value.([]any)
for index, groupValue := range groups {
group := groupValue.(map[string]any)
if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" {
entries = append(entries, droidRegexEntry{
Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] matcher`, event, index),
Pattern: matcher,
})
}
if expression, ok := group["commandRegex"].(string); ok {
entries = append(entries, droidRegexEntry{
Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] commandRegex`, event, index),
Pattern: expression,
})
}
}
}
}
if len(entries) == 0 {
return nil
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return fmt.Errorf("Node.js runtime is required to validate Factory Droid hook expressions: %w", err)
}
payload, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("encode Factory Droid regular expressions: %w", err)
}
validator := `import fs from 'node:fs';
const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
for (const entry of entries) {
try { new RegExp(entry.pattern); }
catch (error) {
process.stderr.write(entry.label + ': ' + (error instanceof Error ? error.message : String(error)));
process.exit(1);
}
}`
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// #nosec G204 -- nodePath is resolved by exec.LookPath and every argument is fixed.
command := exec.CommandContext(ctx, nodePath, "--input-type=module", "--eval", validator)
command.Stdin = bytes.NewReader(payload)
output, runErr := command.CombinedOutput()
if ctx.Err() != nil {
return fmt.Errorf("Factory Droid regular expression validation timed out: %w", ctx.Err())
}
if runErr != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = runErr.Error()
}
return fmt.Errorf("Factory Droid matcher must be a valid JavaScript regular expression: %s", message)
}
return nil
}
func buildDroidGroup(nodePath, scriptPath string) map[string]any {
return map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": buildShellCommand(nodePath, scriptPath), "timeout": droidHookTimeout,
}},
}
}
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
executable, script, ok := parseShellCommand(command)
if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] {
return executable, script, ok
}
return parseQuotedWindowsCommand(command)
}
func managedDroidAgentID(
handler map[string]any,
scriptName, runtimeRoot string,
includeLegacy ...bool,
) (string, bool) {
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout {
return "", false
}
command, ok := handler["command"].(string)
if !ok {
return "", false
}
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
!isNodeExecutable(executable) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
return "", false
}
agentDirectory := filepath.Dir(scriptPath)
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false
}
agentID := filepath.Base(agentDirectory)
return agentID, agentID != "" && agentID != "." && agentID != ".."
}
func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval {
removals := make([]droidManagedRemoval, 0)
for _, contract := range []struct{ event, script string }{
{"PreToolUse", droidGuardScript}, {"PostToolUse", droidAuditScript},
} {
groups, _ := settings[contract.event].([]any)
for groupIndex, value := range groups {
group := value.(map[string]any)
handlers := group["hooks"].([]any)
indexes := make([]int, 0)
for handlerIndex, handlerValue := range handlers {
managedID, managed := managedDroidAgentID(
handlerValue.(map[string]any), contract.script, runtimeRoot, true,
)
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
indexes = append(indexes, handlerIndex)
}
}
if len(indexes) == 0 {
continue
}
_, hasMatcher := group["matcher"]
_, hasHooks := group["hooks"]
exactGroup := len(group) == 2 && hasMatcher && hasHooks &&
group["matcher"] == "*" && len(indexes) == len(handlers)
removals = append(removals, droidManagedRemoval{
contract.event, groupIndex, indexes, exactGroup,
})
}
}
return removals
}
func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract {
guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot)
audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot)
guardIDs := make([]string, 0, len(guards))
for agentID := range guards {
guardIDs = append(guardIDs, agentID)
}
sort.Strings(guardIDs)
contracts := make([]droidRuntimeContract, 0, len(guardIDs))
for _, guardID := range guardIDs {
for auditID := range audits {
if !sameManagedAgentID(guardID, auditID) {
continue
}
root := filepath.Join(runtimeRoot, guardID)
contracts = append(contracts, droidRuntimeContract{
guardID,
filepath.Join(root, droidGuardScript),
filepath.Join(root, droidAuditScript),
})
break
}
}
return contracts
}
func managedDroidIDs(settings droidHookSettings, event, script, runtimeRoot string) map[string]bool {
ids := map[string]bool{}
groups, _ := settings[event].([]any)
for _, groupValue := range groups {
group := groupValue.(map[string]any)
handlers := group["hooks"].([]any)
if len(group) != 2 || group["matcher"] != "*" || len(handlers) != 1 {
continue
}
if agentID, ok := managedDroidAgentID(handlers[0].(map[string]any), script, runtimeRoot); ok {
ids[agentID] = true
}
}
return ids
}
+267
View File
@@ -0,0 +1,267 @@
package plugins
import (
"fmt"
)
type preparedDroidInstallation struct {
changes []*fileChange
preconditions []filePrecondition
paths *managedRuntimePaths
}
type preparedDroidUninstall struct {
changes []*fileChange
preconditions []filePrecondition
}
func droidSourceLabel(document *droidDocument) string {
switch document.kind {
case "settings":
return "Factory Droid user settings"
case "local-settings":
return "Factory Droid local settings"
case "legacy":
return "Factory Droid legacy hooks"
default:
return "Factory Droid user hooks"
}
}
func preflightDroidInstallation(
config InstallConfig,
sources *droidSources,
) (*managedRuntimePaths, string, error) {
if sources == nil || sources.root == nil || sources.policy == nil {
return nil, "", fmt.Errorf("Factory Droid installation sources are required")
}
if err := requireDroidHooksEnabled(sources); err != nil {
return nil, "", err
}
hooks := make([]droidHookSettings, 0, len(droidSourceDocuments(sources)))
for _, document := range droidSourceDocuments(sources) {
hooks = append(hooks, document.hooks)
}
if err := validateDroidRegexes(hooks...); err != nil {
return nil, "", err
}
if err := validateManagedInstallConfig(config, droidAgentKey, "Factory Droid"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, droidGuardScript, droidAuditScript)
if err != nil {
return nil, "", err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, droidAgentKey, "Factory Droid",
); err != nil {
return nil, "", err
}
nodePath, err := resolveAbsoluteNodeRuntime("Factory Droid")
if err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func prepareRenderedDroidChange(
rendered *droidRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSnapshotSourceChange(
rendered.document.filePath,
droidSourceLabel(rendered.document),
rendered.document.snapshot,
rendered.next,
0600,
rendered.remove,
)
}
func validateDroidRenderedSet(
sources *droidSources,
rendered []*droidRenderedDocument,
) error {
expected := droidInstallationDocuments(sources)
if len(rendered) != len(expected) {
return fmt.Errorf("Factory Droid rendered source set is incomplete")
}
for _, document := range expected {
matches := 0
for _, item := range rendered {
if item != nil && item.document != nil &&
sameManagedPath(item.document.filePath, document.filePath) {
matches++
}
}
if matches != 1 {
return fmt.Errorf("Factory Droid rendered source set contains unexpected paths")
}
}
return nil
}
func droidSourcePreconditions(
sources *droidSources,
changedPaths []string,
) []filePrecondition {
preconditions := make([]filePrecondition, 0, len(droidSourceDocuments(sources)))
for _, document := range droidSourceDocuments(sources) {
changed := false
for _, path := range changedPaths {
if sameManagedPath(document.filePath, path) {
changed = true
break
}
}
if !changed {
preconditions = append(preconditions, filePrecondition{
filePath: document.filePath,
label: droidSourceLabel(document),
snapshot: document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return preconditions
}
func prepareDroidInstallation(
config InstallConfig,
sources *droidSources,
rendered []*droidRenderedDocument,
) (*preparedDroidInstallation, error) {
paths, _, err := preflightDroidInstallation(config, sources)
if err != nil {
return nil, err
}
if err := validateDroidRenderedSet(sources, rendered); err != nil {
return nil, err
}
guardScript, auditScript := droidExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, droidAgentKey, string(guardScript), string(auditScript),
)
if err != nil {
return nil, err
}
changedPaths := make([]string, 0, len(rendered))
for _, document := range rendered {
change, changeErr := prepareRenderedDroidChange(document)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
if change != nil {
changedPaths = append(changedPaths, change.filePath)
}
}
preconditions := droidSourcePreconditions(sources, changedPaths)
preconditions = append(preconditions, sources.policy.preconditions...)
return &preparedDroidInstallation{
changes: changes,
preconditions: preconditions,
paths: paths,
}, nil
}
func writeDroidChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot, agentDirectory string,
preconditions []filePrecondition,
) error {
if !hasFileChanges(changes) {
return writeChanges(changes, label, rename, preconditions...)
}
if err := assertFilePreconditions(preconditions, label); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
return writeChanges(changes, label, rename, preconditions...)
}
func commitDroidInstallation(
prepared *preparedDroidInstallation,
rename renameFunc,
) error {
if prepared == nil || prepared.paths == nil {
return fmt.Errorf("prepared Factory Droid installation is required")
}
return writeDroidChanges(
prepared.changes,
"Install Factory Droid hooks",
rename,
prepared.paths.runtimeRoot,
prepared.paths.agentDirectory,
prepared.preconditions,
)
}
func prepareDroidUninstall(
rendered []*droidRenderedDocument,
) (*preparedDroidUninstall, error) {
changes := make([]*fileChange, 0, len(rendered))
changedPaths := make([]string, 0, len(rendered))
for _, item := range rendered {
change, err := prepareRenderedDroidChange(item)
if err != nil {
return nil, err
}
changes = append(changes, change)
if change != nil {
changedPaths = append(changedPaths, change.filePath)
}
}
preconditions := make([]filePrecondition, 0, len(rendered))
for _, item := range rendered {
changed := false
for _, path := range changedPaths {
if sameManagedPath(item.document.filePath, path) {
changed = true
break
}
}
if !changed {
preconditions = append(preconditions, filePrecondition{
filePath: item.document.filePath,
label: droidSourceLabel(item.document),
snapshot: item.document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return &preparedDroidUninstall{changes, preconditions}, nil
}
func commitDroidUninstall(
prepared *preparedDroidUninstall,
rename renameFunc,
) error {
if prepared == nil {
return fmt.Errorf("prepared Factory Droid uninstall is required")
}
return writeDroidChanges(
prepared.changes,
"Uninstall Factory Droid hooks",
rename,
"",
"",
prepared.preconditions,
)
}
func droidExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
return []byte(generateGuardScript(droidAgentKey, agentID, "", false, "")),
[]byte(buildHookScriptWithOutput(droidAgentKey, agentID, "", false, true))
}
+139
View File
@@ -0,0 +1,139 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
type droidFactoryConfigPaths struct {
directory string
root string
legacyDirectory string
legacy string
settings string
localSettings string
}
func droidFactoryPaths() (*droidFactoryConfigPaths, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
}
directory := filepath.Join(home, ".factory")
legacyDirectory := filepath.Join(directory, "hooks")
return &droidFactoryConfigPaths{
directory: directory,
root: filepath.Join(directory, "hooks.json"),
legacyDirectory: legacyDirectory,
legacy: filepath.Join(legacyDirectory, "hooks.json"),
settings: filepath.Join(directory, "settings.json"),
localSettings: filepath.Join(directory, "settings.local.json"),
}, nil
}
func readDroidDocument(
filePath, kind, label string,
) (*droidDocument, error) {
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
if err != nil || snapshot == nil {
return nil, err
}
return parseDroidDocument(
true,
filePath,
kind,
snapshot.contents,
snapshot,
)
}
func readDroidSources() (*droidSources, error) {
paths, err := droidFactoryPaths()
if err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
paths.directory,
"Factory Droid user configuration directory",
); err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
paths.legacyDirectory,
"Factory Droid legacy hooks directory",
); err != nil {
return nil, err
}
root, err := readDroidDocument(paths.root, "hooks", "Factory Droid user hooks")
if err != nil {
return nil, err
}
if root == nil {
root, err = createOwnedDroidDocument(paths.root)
if err != nil {
return nil, err
}
}
legacy, err := readDroidDocument(paths.legacy, "legacy", "Factory Droid legacy hooks")
if err != nil {
return nil, err
}
if legacy == nil {
legacy, err = createDroidDocument(paths.legacy, "legacy", []byte("{}\n"))
if err != nil {
return nil, err
}
}
settings, err := readDroidDocument(paths.settings, "settings", "Factory Droid user settings")
if err != nil {
return nil, err
}
if settings == nil {
settings, err = createDroidDocument(paths.settings, "settings", []byte("{}\n"))
if err != nil {
return nil, err
}
}
localSettings, err := readDroidDocument(
paths.localSettings,
"local-settings",
"Factory Droid local settings",
)
if err != nil {
return nil, err
}
if localSettings == nil {
localSettings, err = createDroidDocument(
paths.localSettings,
"local-settings",
[]byte("{}\n"),
)
if err != nil {
return nil, err
}
}
policy, err := readDroidPolicy()
if err != nil {
return nil, err
}
return &droidSources{
root: root,
legacy: legacy,
settings: settings,
localSettings: localSettings,
policy: policy,
}, nil
}
func requireDroidHooksEnabled(sources *droidSources) error {
if blocked := droidHookBlocked(sources); blocked != nil {
return fmt.Errorf(
"Factory Droid user hooks are disabled by %s in %s at %s",
blocked.field,
blocked.label,
blocked.filePath,
)
}
return nil
}
+267
View File
@@ -0,0 +1,267 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
type droidPolicyLocation struct {
filePath string
label string
}
type droidPolicyLayer struct {
filePath string
label string
snapshot *managedFileSnapshot
hooksDisabled *bool
allowManagedHooksOnly *bool
showHookOutput *bool
}
type droidPolicyOrigin struct {
filePath string
label string
}
type droidPolicyState struct {
allowManagedHooksOnlyBy *droidPolicyOrigin
hooksDisabled *bool
hooksDisabledBy *droidPolicyOrigin
preconditions []filePrecondition
}
func defaultDroidManagedSettingsPath() string {
switch runtime.GOOS {
case "darwin":
return "/Library/Application Support/Factory/settings.json"
case "windows":
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
programFiles = `C:\Program Files`
}
return filepath.Join(programFiles, "Factory", "settings.json")
default:
return "/etc/factory/settings.json"
}
}
var droidManagedSettingsPath = defaultDroidManagedSettingsPath
func readDroidPolicyLayer(location droidPolicyLocation) (*droidPolicyLayer, error) {
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(location.filePath),
location.label+" directory",
); err != nil {
return nil, err
}
snapshot, err := readManagedFile(
location.filePath,
location.label,
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
layer := &droidPolicyLayer{
filePath: location.filePath,
label: location.label,
snapshot: snapshot,
}
if snapshot == nil {
return layer, nil
}
root, err := decodeJSONCObject(
snapshot.contents,
fmt.Sprintf("%s at %s", location.label, location.filePath),
true,
)
if err != nil {
return nil, err
}
layer.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", location.label)
if err != nil {
return nil, err
}
layer.allowManagedHooksOnly, err = droidOptionalBoolean(
root,
"allowManagedHooksOnly",
location.label,
)
if err != nil {
return nil, err
}
layer.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", location.label)
if err != nil {
return nil, err
}
return layer, nil
}
func droidGitRoot(start string) (string, bool, error) {
current, err := filepath.Abs(start)
if err != nil {
return "", false, fmt.Errorf("resolve Factory Droid project directory: %w", err)
}
for {
marker := filepath.Join(current, ".git")
info, inspectErr := os.Lstat(marker)
switch {
case inspectErr == nil:
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
return "", false, fmt.Errorf(
"Factory Droid project marker is not physical: %s",
marker,
)
}
return current, true, nil
case !os.IsNotExist(inspectErr):
return "", false, fmt.Errorf(
"inspect Factory Droid project marker at %s: %w",
marker,
inspectErr,
)
}
parent := filepath.Dir(current)
if parent == current {
return "", false, nil
}
current = parent
}
}
func droidProjectDirectories(root, current string) []string {
directories := []string{root}
relative, err := filepath.Rel(root, current)
if err != nil || relative == "." || relative == "" || filepath.IsAbs(relative) {
return directories
}
if relative == ".." || len(relative) > 3 && relative[:3] == ".."+string(os.PathSeparator) {
return directories
}
directory := root
for _, segment := range splitDroidPath(relative) {
directory = filepath.Join(directory, segment)
directories = append(directories, directory)
}
return directories
}
func splitDroidPath(value string) []string {
parts := make([]string, 0)
for value != "." && value != "" {
directory, file := filepath.Split(value)
if file != "" {
parts = append([]string{file}, parts...)
}
value = filepath.Clean(directory)
if value == string(os.PathSeparator) {
break
}
}
return parts
}
func droidProjectPolicyLocations() ([][2]droidPolicyLocation, error) {
current, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err)
}
current, err = filepath.Abs(current)
if err != nil {
return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err)
}
root, found, err := droidGitRoot(current)
if err != nil {
return nil, err
}
if !found {
root = current
}
directories := droidProjectDirectories(root, current)
locations := make([][2]droidPolicyLocation, 0, len(directories))
for index, directory := range directories {
scope := "project"
if index > 0 {
scope = "folder " + directory
}
factory := filepath.Join(directory, ".factory")
locations = append(locations, [2]droidPolicyLocation{
{filepath.Join(factory, "settings.json"), "Factory Droid " + scope + " settings"},
{filepath.Join(factory, "settings.local.json"), "Factory Droid " + scope + " local settings"},
})
}
return locations, nil
}
func droidScopeHooksDisabled(
settings, local *droidPolicyLayer,
) *droidPolicyLayer {
if local.hooksDisabled != nil {
return local
}
if settings.hooksDisabled != nil {
return settings
}
return nil
}
func readDroidPolicy() (*droidPolicyState, error) {
managed, err := readDroidPolicyLayer(droidPolicyLocation{
droidManagedSettingsPath(),
"Factory Droid system-managed settings",
})
if err != nil {
return nil, err
}
locations, err := droidProjectPolicyLocations()
if err != nil {
return nil, err
}
projectLayers := make([][2]*droidPolicyLayer, 0, len(locations))
for _, scope := range locations {
settings, readErr := readDroidPolicyLayer(scope[0])
if readErr != nil {
return nil, readErr
}
local, readErr := readDroidPolicyLayer(scope[1])
if readErr != nil {
return nil, readErr
}
projectLayers = append(projectLayers, [2]*droidPolicyLayer{settings, local})
}
state := &droidPolicyState{}
if managed.allowManagedHooksOnly != nil && *managed.allowManagedHooksOnly {
state.allowManagedHooksOnlyBy = &droidPolicyOrigin{managed.filePath, managed.label}
}
selected := managed
if selected.hooksDisabled == nil {
selected = nil
for _, scope := range projectLayers {
if layer := droidScopeHooksDisabled(scope[0], scope[1]); layer != nil {
selected = layer
break
}
}
}
if selected != nil {
state.hooksDisabled = selected.hooksDisabled
state.hooksDisabledBy = &droidPolicyOrigin{selected.filePath, selected.label}
}
layers := []*droidPolicyLayer{managed}
for _, scope := range projectLayers {
layers = append(layers, scope[0], scope[1])
}
state.preconditions = make([]filePrecondition, 0, len(layers))
for _, layer := range layers {
state.preconditions = append(state.preconditions, filePrecondition{
filePath: layer.filePath,
label: layer.label,
snapshot: layer.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
return state, nil
}
+124
View File
@@ -0,0 +1,124 @@
package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDroidUserSettingsDisableBeforeRuntimeCreation(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "hooksDisabled") {
t.Fatalf("disabled hooks error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidUserLocalSettingsOverrideBaseSettings(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
localSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
if _, err := os.Stat(fixture.runtimeConfig); err != nil {
t.Fatalf("runtime config is missing: %v", err)
}
}
func TestDroidLegacyDirectFlagsRemainSafetyCompatible(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
legacy: droidJSON(map[string]any{"hooksDisabled": true, "PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooksDisabled": false}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "legacy hooks") {
t.Fatalf("legacy safety error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
}
func TestDroidProjectPolicyHasExtensionOnlyPrecedence(t *testing.T) {
t.Run("project blocks user allow", func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": false}),
projectSettings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project settings") {
t.Fatalf("project policy error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
})
t.Run("project allows through user block", func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
})
}
func TestDroidProjectLocalSettingsOverrideMatchingBase(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
projectLocalSettings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project local settings") {
t.Fatalf("project local policy error = %v", err)
}
}
func TestDroidProjectValuePrecedesDeeperFolderValue(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
child := filepath.Join(fixture.workspaceDir, "packages", "console")
if err := os.MkdirAll(child, 0755); err != nil {
t.Fatalf("create child workspace: %v", err)
}
writeDroidTestObject(
t,
filepath.Join(child, ".factory", "settings.json"),
map[string]any{"hooksDisabled": true},
)
if err := os.Chdir(child); err != nil {
t.Fatalf("enter child workspace: %v", err)
}
installDroidFixture(t, fixture)
}
func TestDroidSystemManagedPolicyBlocksUserHooks(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
writeDroidTestObject(
t,
fixture.systemSettingsPath,
map[string]any{"allowManagedHooksOnly": true},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "allowManagedHooksOnly") {
t.Fatalf("managed policy error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
}
func TestDroidMalformedReadOnlyProjectPolicyIsPreserved(t *testing.T) {
malformed := "{ malformed"
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidString(malformed),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project settings") {
t.Fatalf("malformed project policy error = %v", err)
}
if readDroidTestFile(t, fixture.projectSettingsPath) != malformed {
t.Fatal("malformed read-only policy changed")
}
requireMissingDroidFile(t, fixture.runtimeConfig)
requireMissingDroidFile(t, fixture.configPath)
}
+317
View File
@@ -0,0 +1,317 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestDroidStatusSurfacesMalformedRuntimeIdentity(t *testing.T) {
tests := []struct {
name string
mutate func(*droidFixture)
pattern string
}{
{
"malformed config",
func(fixture *droidFixture) {
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
},
"parse Elydora runtime config",
},
{
"unsupported config field",
func(fixture *droidFixture) {
config := readDroidTestObject(t, fixture.runtimeConfig)
config["future"] = true
writeDroidTestObject(t, fixture.runtimeConfig, config)
},
"unsupported field",
},
{
"noncanonical key",
func(fixture *droidFixture) {
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
},
"canonical 32-byte",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
test.mutate(fixture)
_, err := fixture.plugin.Status()
if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("status error = %v, want %q", err, test.pattern)
}
})
}
}
func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
tests := []struct {
name string
root *string
legacy *string
settings *string
local *string
pattern string
}{
{"malformed root", droidString("{ malformed"), nil, nil, nil, "parse Factory Droid hooks"},
{"non-object root", droidJSON([]any{}), nil, nil, nil, "JSON object"},
{"duplicate event", droidString(`{"hooks":{"PreToolUse":[],"PreToolUse":[]}}`), nil, nil, nil, "duplicate"},
{"null event", droidString(`{"hooks":{"PreToolUse":null}}`), nil, nil, nil, "must be an array"},
{"null group", droidString(`{"hooks":{"PreToolUse":[null]}}`), nil, nil, nil, "must be an object"},
{
"invalid matcher",
droidString(`{"hooks":{"PreToolUse":[{"matcher":"[","hooks":[]}]}}`),
nil, nil, nil, "regular expression",
},
{
"empty handler command",
droidString(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`),
nil, nil, nil, "non-empty",
},
{
"nonpositive timeout",
droidString(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"user","timeout":0}]}]}}`),
nil, nil, nil, "positive",
},
{"malformed legacy", nil, droidString("{ malformed"), nil, nil, "legacy hooks"},
{"null settings hooks", nil, nil, droidString(`{"hooks":null}`), nil, "JSON object"},
{"invalid local flag", nil, nil, nil, droidString(`{"hooksDisabled":"yes"}`), "boolean"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
root: test.root,
legacy: test.legacy,
settings: test.settings,
localSettings: test.local,
})
originals := existingDroidSources(t, fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("install error = %v, want substring %q", err, test.pattern)
}
for path, original := range originals {
if readDroidTestFile(t, path) != original {
t.Fatalf("failed install changed %s", path)
}
}
for _, path := range []string{
fixture.guardPath,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
} {
requireMissingDroidFile(t, path)
}
})
}
}
func TestDroidUnknownEventsAndExtensionFieldsRemainValid(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{
"hooks": map[string]any{
"FutureFactoryEvent": []any{map[string]any{
"matcher": ".*",
"commandRegex": "^factory",
"owner": "user",
"hooks": []any{map[string]any{
"type": "command",
"command": "user-command",
"timeout": 1,
"future": true,
}},
}},
},
})})
installDroidFixture(t, fixture)
hooks := droidCurrentHooks(t, fixture.configPath)
future := requireDroidObject(t, requireDroidArray(t, hooks["FutureFactoryEvent"])[0])
if future["owner"] != "user" {
t.Fatalf("future event = %#v", future)
}
}
func TestDroidInstallValidatesConfigurationBeforeWrites(t *testing.T) {
tests := []struct {
name string
mutate func(*droidFixture)
pattern string
}{
{"missing organization", func(f *droidFixture) { f.config.OrgID = "" }, "organization ID is required"},
{"blank key ID", func(f *droidFixture) { f.config.KID = " " }, "key ID is required"},
{"invalid private key", func(f *droidFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
{"invalid base URL", func(f *droidFixture) { f.config.BaseURL = "relative" }, "absolute HTTP or HTTPS"},
{"invalid agent ID", func(f *droidFixture) { f.config.AgentID = "../agent" }, "single non-empty path segment"},
{
"guard outside managed directory",
func(f *droidFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "guard.js") },
"managed agent directory",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
test.mutate(fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("install error = %v, want substring %q", err, test.pattern)
}
requireMissingDroidFile(t, fixture.configPath)
requireMissingDroidFile(t, fixture.runtimeConfig)
})
}
}
func TestDroidOrphanedRuntimeIsPreservedWithoutVerifiableIdentity(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create orphaned runtime: %v", err)
}
if err := os.WriteFile(fixture.guardPath, []byte("orphaned guard\n"), 0700); err != nil {
t.Fatalf("write orphaned guard: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
t.Fatalf("orphaned runtime error = %v", err)
}
if readDroidTestFile(t, fixture.guardPath) != "orphaned guard\n" {
t.Fatal("orphaned guard changed")
}
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidUninstallPreservesUsersAndExactOwnership(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{
"hooks": map[string]any{"Notification": []any{}},
})})
installDroidFixture(t, fixture)
root := readDroidTestObject(t, fixture.configPath)
hooks := requireDroidObject(t, root["hooks"])
group := droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath)
command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string)
group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{
"type": "command", "command": "user-command",
})
group["owner"] = "user"
hooks["PreToolUse"] = append(requireDroidArray(t, hooks["PreToolUse"]),
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command",
"command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1),
"timeout": 10,
}},
},
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command",
"command": strings.Replace(command, droidTestAgentID, "agent-10", 1),
"timeout": 10,
}},
},
)
writeDroidTestObject(t, fixture.configPath, root)
agentID := droidTestAgentID
if runtime.GOOS == "windows" {
agentID = strings.ToUpper(agentID)
}
if err := fixture.plugin.Uninstall(agentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
remaining := readDroidTestFile(t, fixture.configPath)
for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} {
if !strings.Contains(remaining, marker) {
t.Fatalf("uninstall removed %q", marker)
}
}
currentHooks := droidCurrentHooks(t, fixture.configPath)
if _, exists := currentHooks["PostToolUse"]; exists {
t.Fatalf("managed PostToolUse remains: %#v", currentHooks)
}
}
func TestDroidUninstallRemovesOwnedEmptyFileAndKeepsAbsence(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
requireMissingDroidFile(t, fixture.configPath)
empty := prepareDroidFixture(t, droidFixtureOptions{})
if err := empty.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall absent hooks: %v", err)
}
requireMissingDroidFile(t, empty.configPath)
}
func TestDroidInstallRejectsLinkedPathsBeforeWrites(t *testing.T) {
for _, kind := range []string{"factory", "hook", "runtime"} {
t.Run(kind, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
target := filepath.Join(t.TempDir(), kind+"-target")
if err := os.MkdirAll(target, 0755); err != nil {
t.Fatalf("create symlink target: %v", err)
}
var linkErr error
switch kind {
case "factory":
linkErr = os.MkdirAll(fixture.homeDir, 0755)
if linkErr == nil {
linkErr = os.Symlink(target, fixture.factoryDir)
}
case "hook":
if err := os.MkdirAll(fixture.factoryDir, 0755); err != nil {
t.Fatalf("create Factory directory: %v", err)
}
targetFile := filepath.Join(target, "hooks.json")
writeDroidTestObject(t, targetFile, map[string]any{"hooks": map[string]any{}})
linkErr = os.Symlink(targetFile, fixture.configPath)
case "runtime":
linkErr = os.MkdirAll(fixture.homeDir, 0755)
if linkErr == nil {
linkErr = os.Symlink(target, filepath.Join(fixture.homeDir, ".elydora"))
}
}
if linkErr != nil {
t.Skipf("symbolic links unavailable: %v", linkErr)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical") {
t.Fatalf("linked-path error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
})
}
}
func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string {
t.Helper()
result := map[string]string{}
for _, path := range []string{
fixture.configPath,
fixture.legacyPath,
fixture.settingsPath,
fixture.localSettingsPath,
} {
if raw, err := os.ReadFile(path); err == nil {
result[path] = string(raw)
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read source %s: %v", path, err)
}
}
return result
}
+5
View File
@@ -0,0 +1,5 @@
package plugins
func droidRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
return managedRuntimeFilesExist(contracts, droidAgentKey, "Factory Droid", droidExpectedScripts)
}
+368
View File
@@ -0,0 +1,368 @@
package plugins
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) {
entry := SupportedAgents[droidAgentKey]
if entry.Name != "Factory Droid" || entry.ConfigDir != "~/.factory" || entry.ConfigFile != "hooks.json" {
t.Fatalf("Factory Droid registry entry = %#v", entry)
}
plugin, ok := NewPlugin(droidAgentKey).(*DroidPlugin)
if !ok || !plugin.ManagesGuardRuntime() {
t.Fatal("Factory Droid plugin is not registered as a guard runtime manager")
}
}
func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("create output pipe: %v", err)
}
original := os.Stdout
os.Stdout = writer
installErr := fixture.plugin.Install(fixture.config)
os.Stdout = original
closeErr := writer.Close()
output, readErr := io.ReadAll(reader)
_ = reader.Close()
if installErr != nil || closeErr != nil || readErr != nil {
t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr)
}
if !strings.Contains(string(output), fixture.configPath) || !strings.Contains(string(output), "run /hooks") {
t.Fatalf("install output = %s", output)
}
source := readDroidTestFile(t, fixture.configPath)
if !strings.HasPrefix(source, droidOwnedFileMarker+"\n") {
t.Fatalf("managed source header is missing: %s", source)
}
hooks := droidCurrentHooks(t, fixture.configPath)
guardGroup := droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath)
auditGroup := droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath)
requireDroidNativeGroup(t, guardGroup)
requireDroidNativeGroup(t, auditGroup)
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
if runtime.GOOS == "windows" &&
(!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, powerShellExitSuffix)) {
t.Fatalf("Windows Factory command = %q", guardCommand)
}
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
expectedConfig := map[string]any{
"org_id": "org-1",
"agent_id": droidTestAgentID,
"kid": "kid-1",
"base_url": "http://127.0.0.1:9",
"agent_name": droidAgentKey,
"token": "token-1",
}
if !reflect.DeepEqual(runtimeConfig, expectedConfig) {
t.Fatalf("runtime config = %#v", runtimeConfig)
}
if readDroidTestFile(t, fixture.privateKey) != droidTestPrivateKey {
t.Fatal("private key content changed")
}
if !strings.Contains(readDroidTestFile(t, fixture.guardPath), `const AGENT_NAME = "droid"`) {
t.Fatal("guard runtime identity is missing")
}
if !strings.Contains(readDroidTestFile(t, fixture.hookPath), "const NATIVE_PAYLOAD = true") {
t.Fatal("audit runtime is missing native payload mode")
}
paths := []string{
fixture.configPath,
fixture.guardPath,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
}
before := snapshotDroidFiles(t, paths...)
installDroidFixture(t, fixture)
requireDroidSnapshot(t, before)
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidInstallMigratesLegacyWindowsCommands(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows migration contract")
}
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{"hooks": map[string]any{}})})
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
group := func(script string) map[string]any {
return map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command",
"command": fmt.Sprintf(`"%s" "%s"`, nodePath, script),
"timeout": 10,
}},
}
}
writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
"PreToolUse": []any{group(fixture.guardPath)},
"PostToolUse": []any{group(fixture.hookPath)},
}})
installDroidFixture(t, fixture)
hooks := droidCurrentHooks(t, fixture.configPath)
for _, event := range droidToolEvents {
groups := requireDroidArray(t, hooks[event])
if len(groups) != 1 {
t.Fatalf("%s groups = %#v", event, groups)
}
command := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, groups[0])["hooks"])[0])["command"].(string)
if !strings.HasPrefix(command, "& '") {
t.Fatalf("migrated command = %q", command)
}
}
}
func TestDroidRootHookFileHasWholeSourcePrecedence(t *testing.T) {
root := `{
// active root source
"hooks": {
"PreToolUse": [
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
]
}
}
`
settings := `{
// inactive settings source
"theme": "dark",
"hooks": {
"PostToolUse": [
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
]
}
}
`
fixture := prepareDroidFixture(t, droidFixtureOptions{
root: droidString(root),
settings: droidString(settings),
})
installDroidFixture(t, fixture)
if !strings.Contains(readDroidTestFile(t, fixture.configPath), "active root source") {
t.Fatal("root JSONC comment was lost")
}
hooks := droidCurrentHooks(t, fixture.configPath)
pre := requireDroidArray(t, hooks["PreToolUse"])
userCommand := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, pre[0])["hooks"])[0])["command"]
if userCommand != "root-user" {
t.Fatalf("root user command = %#v", userCommand)
}
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
if readDroidTestFile(t, fixture.settingsPath) != settings {
t.Fatal("inactive settings source changed")
}
}
func TestDroidSettingsSourcePreservesFormatting(t *testing.T) {
settings := "{\r\n\t\"theme\": \"dark\",\r\n\t\"hooks\": {}\r\n}\r\n"
fixture := prepareDroidFixture(t, droidFixtureOptions{settings: droidString(settings)})
installDroidFixture(t, fixture)
requireMissingDroidFile(t, fixture.configPath)
raw := readDroidTestFile(t, fixture.settingsPath)
if !strings.Contains(raw, "\r\n\t\t\"PreToolUse\"") ||
!strings.Contains(raw, "\r\n\t\t\"PostToolUse\"") {
t.Fatalf("settings formatting changed:\n%s", raw)
}
hooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"])
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
}
func TestDroidLocalSettingsContainerHasWholeSourcePrecedence(t *testing.T) {
settings := droidJSON(map[string]any{"hooks": map[string]any{"Notification": []any{}}})
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: settings,
localSettings: droidJSON(map[string]any{"hooks": map[string]any{"SessionStart": []any{}}}),
})
baseBefore := readDroidTestFile(t, fixture.settingsPath)
installDroidFixture(t, fixture)
if readDroidTestFile(t, fixture.settingsPath) != baseBefore {
t.Fatal("base settings changed while local settings were active")
}
hooks := requireDroidObject(t, readDroidTestObject(t, fixture.localSettingsPath)["hooks"])
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
}
func TestDroidLegacyHookFileStaysActiveUntilFactoryMigratesIt(t *testing.T) {
legacy := droidJSON(map[string]any{"PreToolUse": []any{map[string]any{
"matcher": "Read",
"hooks": []any{map[string]any{"type": "command", "command": "legacy-user"}},
}}})
settings := droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}})
fixture := prepareDroidFixture(t, droidFixtureOptions{legacy: legacy, settings: settings})
settingsBefore := readDroidTestFile(t, fixture.settingsPath)
installDroidFixture(t, fixture)
requireMissingDroidFile(t, fixture.configPath)
legacyHooks := readDroidTestObject(t, fixture.legacyPath)
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PostToolUse"], fixture.hookPath))
if readDroidTestFile(t, fixture.settingsPath) != settingsBefore {
t.Fatal("inactive settings source changed")
}
}
func TestDroidInstallCleansExactManagedHooksFromInactiveSources(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
writeDroidTestObject(t, fixture.settingsPath, map[string]any{
"hooks": droidCurrentHooks(t, fixture.configPath),
"owner": "user",
})
installDroidFixture(t, fixture)
settings := readDroidTestObject(t, fixture.settingsPath)
if settings["owner"] != "user" {
t.Fatalf("inactive settings owner = %#v", settings["owner"])
}
hooks := requireDroidObject(t, settings["hooks"])
if _, exists := hooks["PreToolUse"]; exists {
t.Fatalf("inactive PreToolUse remains: %#v", hooks)
}
if _, exists := hooks["PostToolUse"]; exists {
t.Fatalf("inactive PostToolUse remains: %#v", hooks)
}
}
type droidAPIRequest struct {
method string
path string
body []byte
}
func TestDroidGuardBlocksAndAuditPreservesNativePayload(t *testing.T) {
requests := make([]droidAPIRequest, 0)
var requestLock sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
t.Errorf("read API request: %v", err)
}
requestLock.Lock()
requests = append(requests, droidAPIRequest{request.Method, request.URL.Path, body})
requestLock.Unlock()
writer.Header().Set("Content-Type", "application/json")
if request.Method == http.MethodPost {
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"operation":{"accepted":true}}`))
return
}
_, _ = writer.Write([]byte(`{"agent":{"status":"active"}}`))
}))
defer server.Close()
fixture := prepareDroidFixture(t, droidFixtureOptions{baseURL: server.URL})
installDroidFixture(t, fixture)
hooks := droidCurrentHooks(t, fixture.configPath)
guard := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)
audit := droidManagedHandler(t, hooks["PostToolUse"], fixture.hookPath)
writeDroidTestObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "frozen",
"cached_at": time.Now().UnixMilli(),
})
prePayload := map[string]any{
"session_id": "session-1",
"transcript_path": filepath.Join(fixture.homeDir, "transcript.jsonl"),
"cwd": fixture.workspaceDir,
"permission_mode": "auto-high",
"hook_event_name": "PreToolUse",
"tool_name": "Execute",
"tool_input": map[string]any{"command": "echo test"},
}
preRaw, _ := json.Marshal(prePayload)
guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, string(preRaw))
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, `Agent "droid" is frozen`) {
t.Fatalf("guard result = %#v", guardResult)
}
postPayload := make(map[string]any, len(prePayload)+1)
for key, value := range prePayload {
postPayload[key] = value
}
postPayload["hook_event_name"] = "PostToolUse"
postPayload["tool_response"] = map[string]any{"output": "test", "success": true}
postRaw, _ := json.Marshal(postPayload)
auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, string(postRaw))
if auditResult.exitCode != 0 {
t.Fatalf("audit result = %#v", auditResult)
}
requestLock.Lock()
defer requestLock.Unlock()
var operation map[string]any
for _, request := range requests {
if request.method == http.MethodPost {
if err := json.Unmarshal(request.body, &operation); err != nil {
t.Fatalf("decode audit request: %v", err)
}
break
}
}
if operation == nil || !reflect.DeepEqual(operation["payload"], postPayload) {
t.Fatalf("native operation = %#v", operation)
}
if !reflect.DeepEqual(operation["subject"], map[string]any{"session_id": "session-1"}) ||
!reflect.DeepEqual(operation["action"], map[string]any{"tool": "Execute"}) {
t.Fatalf("native operation identity = %#v", operation)
}
}
func TestDroidStatusRequiresExactHooksAndRuntimeSources(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
if err := os.WriteFile(fixture.hookPath, []byte("tampered\n"), 0700); err != nil {
t.Fatalf("tamper audit runtime: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
t.Fatalf("tampered status = %#v, %v", status, err)
}
}
func TestDroidStatusUsesOneWholeActiveSource(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooks": map[string]any{}}),
})
installDroidFixture(t, fixture)
settingsHooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"])
writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
"PreToolUse": settingsHooks["PreToolUse"],
}})
status, err := fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("split-source status = %#v, %v", status, err)
}
}
func TestDroidStatusAndUninstallDoNotRequireNodeResolution(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
t.Setenv("PATH", "")
status, err := fixture.plugin.Status()
if err != nil || !status.Installed {
t.Fatalf("status without PATH = %#v, %v", status, err)
}
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall without PATH: %v", err)
}
requireMissingDroidFile(t, fixture.configPath)
}
@@ -0,0 +1,355 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
const (
droidTestAgentID = "agent-1"
droidTestPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0"
)
type droidFixtureOptions struct {
root *string
legacy *string
settings *string
localSettings *string
projectSettings *string
projectLocalSettings *string
baseURL string
agentID string
}
type droidFixture struct {
plugin *DroidPlugin
config InstallConfig
homeDir string
workspaceDir string
factoryDir string
configPath string
legacyPath string
settingsPath string
localSettingsPath string
projectSettingsPath string
projectLocalSettingsPath string
systemSettingsPath string
agentDir string
guardPath string
hookPath string
runtimeConfig string
privateKey string
}
type droidCommandResult struct {
exitCode int
stdout string
stderr string
}
func droidString(value string) *string {
return &value
}
func droidJSON(value any) *string {
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
panic(err)
}
return droidString(string(encoded) + "\n")
}
func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixture {
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%")
preparePrivateTransactionTestDirectory(t, homeDir)
workspaceDir := filepath.Join(rootDir, "workspace with spaces")
factoryDir := filepath.Join(homeDir, ".factory")
configPath := filepath.Join(factoryDir, "hooks.json")
legacyPath := filepath.Join(factoryDir, "hooks", "hooks.json")
settingsPath := filepath.Join(factoryDir, "settings.json")
localSettingsPath := filepath.Join(factoryDir, "settings.local.json")
projectSettingsPath := filepath.Join(workspaceDir, ".factory", "settings.json")
projectLocalSettingsPath := filepath.Join(workspaceDir, ".factory", "settings.local.json")
systemSettingsPath := filepath.Join(rootDir, "managed factory", "settings.json")
agentID := options.agentID
if agentID == "" {
agentID = droidTestAgentID
}
agentDir := filepath.Join(homeDir, ".elydora", agentID)
guardPath := filepath.Join(agentDir, droidGuardScript)
hookPath := filepath.Join(agentDir, droidAuditScript)
if err := os.MkdirAll(filepath.Join(workspaceDir, ".git"), 0755); err != nil {
t.Fatalf("create fixture workspace: %v", err)
}
writeOptionalDroidFile(t, configPath, options.root)
writeOptionalDroidFile(t, legacyPath, options.legacy)
writeOptionalDroidFile(t, settingsPath, options.settings)
writeOptionalDroidFile(t, localSettingsPath, options.localSettings)
writeOptionalDroidFile(t, projectSettingsPath, options.projectSettings)
writeOptionalDroidFile(t, projectLocalSettingsPath, options.projectLocalSettings)
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
oldDirectory, err := os.Getwd()
if err != nil {
t.Fatalf("read working directory: %v", err)
}
if err := os.Chdir(workspaceDir); err != nil {
t.Fatalf("enter fixture workspace: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(oldDirectory); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
previousManagedPath := droidManagedSettingsPath
droidManagedSettingsPath = func() string { return systemSettingsPath }
t.Cleanup(func() { droidManagedSettingsPath = previousManagedPath })
baseURL := options.baseURL
if baseURL == "" {
baseURL = "http://127.0.0.1:9"
}
return &droidFixture{
plugin: &DroidPlugin{},
homeDir: homeDir,
workspaceDir: workspaceDir,
factoryDir: factoryDir,
configPath: configPath,
legacyPath: legacyPath,
settingsPath: settingsPath,
localSettingsPath: localSettingsPath,
projectSettingsPath: projectSettingsPath,
projectLocalSettingsPath: projectLocalSettingsPath,
systemSettingsPath: systemSettingsPath,
agentDir: agentDir,
guardPath: guardPath,
hookPath: hookPath,
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
config: InstallConfig{
AgentName: droidAgentKey,
OrgID: "org-1",
AgentID: agentID,
PrivateKey: droidTestPrivateKey,
KID: "kid-1",
Token: "token-1",
BaseURL: baseURL,
GuardScriptPath: guardPath,
},
}
}
func writeOptionalDroidFile(t *testing.T, path string, source *string) {
t.Helper()
if source == nil {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("create directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(*source), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func writeDroidTestObject(t *testing.T, path string, value any) {
t.Helper()
writeOptionalDroidFile(t, path, droidJSON(value))
}
func installDroidFixture(t *testing.T, fixture *droidFixture) {
t.Helper()
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Factory Droid hooks: %v", err)
}
}
func readDroidTestFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(raw)
}
func requireMissingDroidFile(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be missing, got %v", path, err)
}
}
func readDroidTestObject(t *testing.T, path string) map[string]any {
t.Helper()
standard, err := standardizeJSONC(
[]byte(readDroidTestFile(t, path)),
"Factory Droid test source",
true,
)
if err != nil {
t.Fatalf("standardize %s: %v", path, err)
}
var object map[string]any
if err := json.Unmarshal(standard, &object); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return object
}
func droidCurrentHooks(t *testing.T, path string) map[string]any {
t.Helper()
return requireDroidObject(t, readDroidTestObject(t, path)["hooks"])
}
func requireDroidObject(t *testing.T, value any) map[string]any {
t.Helper()
object, ok := value.(map[string]any)
if !ok {
t.Fatalf("value is not an object: %#v", value)
}
return object
}
func requireDroidArray(t *testing.T, value any) []any {
t.Helper()
array, ok := value.([]any)
if !ok {
t.Fatalf("value is not an array: %#v", value)
}
return array
}
func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[string]any {
t.Helper()
for _, groupValue := range requireDroidArray(t, groupsValue) {
group := requireDroidObject(t, groupValue)
for _, handlerValue := range requireDroidArray(t, group["hooks"]) {
handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string)
_, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameManagedPath(configuredPath, scriptPath) {
return group
}
}
}
t.Fatalf("managed group for %s not found", scriptPath)
return nil
}
func droidManagedHandler(t *testing.T, groupsValue any, scriptPath string) map[string]any {
t.Helper()
group := droidManagedGroup(t, groupsValue, scriptPath)
for _, handlerValue := range requireDroidArray(t, group["hooks"]) {
handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string)
_, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameManagedPath(configuredPath, scriptPath) {
return handler
}
}
t.Fatalf("managed handler for %s not found", scriptPath)
return nil
}
func requireDroidNativeGroup(t *testing.T, group map[string]any) {
t.Helper()
if len(group) != 2 || group["matcher"] != "*" {
t.Fatalf("managed group = %#v", group)
}
handlers := requireDroidArray(t, group["hooks"])
if len(handlers) != 1 {
t.Fatalf("managed handlers = %#v", handlers)
}
handler := requireDroidObject(t, handlers[0])
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != float64(10) {
t.Fatalf("managed handler = %#v", handler)
}
}
func runDroidCommand(t *testing.T, command, homeDir, payload string) droidCommandResult {
t.Helper()
var process *exec.Cmd
if runtime.GOOS == "windows" {
process = exec.Command(
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
command,
)
} else {
process = exec.Command("sh", "-c", command)
}
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
process.Stdin = bytes.NewBufferString(payload)
var stdout bytes.Buffer
var stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
result := droidCommandResult{stdout: stdout.String(), stderr: stderr.String()}
if err == nil {
return result
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
result.exitCode = exitError.ExitCode()
return result
}
t.Fatalf("run Factory Droid hook command: %v", err)
return droidCommandResult{}
}
func snapshotDroidFiles(t *testing.T, paths ...string) map[string]string {
t.Helper()
result := make(map[string]string, len(paths))
for _, path := range paths {
result[path] = readDroidTestFile(t, path)
}
return result
}
func requireDroidSnapshot(t *testing.T, expected map[string]string) {
t.Helper()
for path, source := range expected {
if current := readDroidTestFile(t, path); current != source {
t.Fatalf("%s changed", path)
}
}
}
func requireNoDroidStagingFiles(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("staging file remains: %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk staging files: %v", err)
}
}
func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) {
value, err := parseJSONC(source, label, allowTrailingCommas)
if err != nil {
return nil, err
}
value.Standardize()
return value.Pack(), nil
}
@@ -0,0 +1,270 @@
package plugins
import (
"errors"
"os"
"strings"
"testing"
)
func prepareDroidTestInstallation(
t *testing.T,
fixture *droidFixture,
config InstallConfig,
) *preparedDroidInstallation {
t.Helper()
sources, err := readDroidSources()
if err != nil {
t.Fatalf("read Factory Droid sources: %v", err)
}
paths, nodePath, err := preflightDroidInstallation(config, sources)
if err != nil {
t.Fatalf("preflight Factory Droid installation: %v", err)
}
rendered, err := renderDroidInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
paths.runtimeRoot,
)
if err != nil {
t.Fatalf("render Factory Droid installation: %v", err)
}
prepared, err := prepareDroidInstallation(config, sources, rendered)
if err != nil {
t.Fatalf("prepare Factory Droid installation: %v", err)
}
return prepared
}
func droidManagedPaths(fixture *droidFixture) []string {
return []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.configPath,
}
}
func TestDroidInstallRollsBackRuntimeAfterLateHookCommitFailure(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
failed := false
rename := func(source, destination string) error {
if !failed && sameManagedPath(destination, fixture.configPath) {
failed = true
return errors.New("injected Droid hook commit failure")
}
return os.Rename(source, destination)
}
err := commitDroidInstallation(prepared, rename)
if err == nil || !strings.Contains(err.Error(), "injected Droid hook commit failure") || !failed {
t.Fatalf("transaction error = %v, failed = %v", err, failed)
}
for _, path := range droidManagedPaths(fixture) {
requireMissingDroidFile(t, path)
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
updated := fixture.config
updated.OrgID = "org-updated"
prepared := prepareDroidTestInstallation(t, fixture, updated)
concurrent := "{\"hooks\":{\"PreToolUse\":[]},\"owner\":\"concurrent\"}\n"
if err := os.WriteFile(fixture.configPath, []byte(concurrent), 0600); err != nil {
t.Fatalf("replace active hook source: %v", err)
}
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "changed during Install Factory Droid") {
t.Fatalf("concurrent source error = %v", err)
}
for path, source := range before {
if sameManagedPath(path, fixture.configPath) {
continue
}
if readDroidTestFile(t, path) != source {
t.Fatalf("runtime changed after concurrent source replacement: %s", path)
}
}
if readDroidTestFile(t, fixture.configPath) != concurrent {
t.Fatal("concurrent source was overwritten")
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentInactiveLocalSourceCreationIsDetected(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
updated := fixture.config
updated.OrgID = "org-updated"
prepared := prepareDroidTestInstallation(t, fixture, updated)
concurrent := "{\"hooksDisabled\":true}\n"
writeOptionalDroidFile(t, fixture.localSettingsPath, droidString(concurrent))
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "local settings changed during Install") {
t.Fatalf("concurrent local source error = %v", err)
}
requireDroidSnapshot(t, before)
if readDroidTestFile(t, fixture.localSettingsPath) != concurrent {
t.Fatal("concurrent local settings source was overwritten")
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentProjectPolicyChangeIsDetected(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
updated := fixture.config
updated.OrgID = "org-updated"
prepared := prepareDroidTestInstallation(t, fixture, updated)
concurrent := "{\"hooksDisabled\":true}\n"
if err := os.WriteFile(fixture.projectSettingsPath, []byte(concurrent), 0600); err != nil {
t.Fatalf("change project policy: %v", err)
}
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "project settings changed during Install") {
t.Fatalf("concurrent project policy error = %v", err)
}
requireDroidSnapshot(t, before)
if readDroidTestFile(t, fixture.projectSettingsPath) != concurrent {
t.Fatal("concurrent project policy was overwritten")
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentPolicyChangePreventsRuntimeDirectoryCreation(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
if err := os.WriteFile(
fixture.projectSettingsPath,
[]byte("{\"hooksDisabled\":true}\n"),
0600,
); err != nil {
t.Fatalf("change project policy: %v", err)
}
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "project settings changed during Install") {
t.Fatalf("concurrent project policy error = %v", err)
}
requireMissingDroidFile(t, fixture.agentDir)
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
sources, err := readDroidSources()
if err != nil {
t.Fatalf("read Factory Droid sources: %v", err)
}
original := readDroidTestFile(t, fixture.configPath)
if err := os.Remove(fixture.configPath); err != nil {
t.Fatalf("remove hook source: %v", err)
}
if err := os.WriteFile(fixture.configPath, []byte(original), 0600); err != nil {
t.Fatalf("replace hook source: %v", err)
}
updated := fixture.config
updated.OrgID = "org-updated"
paths, nodePath, err := preflightDroidInstallation(updated, sources)
if err != nil {
t.Fatalf("preflight stale sources: %v", err)
}
rendered, err := renderDroidInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
paths.runtimeRoot,
)
if err != nil {
t.Fatalf("render stale sources: %v", err)
}
prepared, err := prepareDroidInstallation(updated, sources, rendered)
if err != nil {
t.Fatalf("prepare stale sources: %v", err)
}
err = commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "changed during Install") {
t.Fatalf("same-content replacement error = %v", err)
}
if readDroidTestFile(t, fixture.configPath) != original {
t.Fatal("same-content replacement changed")
}
for path, source := range before {
if !sameManagedPath(path, fixture.configPath) && readDroidTestFile(t, path) != source {
t.Fatalf("runtime changed after inode replacement: %s", path)
}
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
installed := droidCurrentHooks(t, fixture.configPath)
writeDroidTestObject(t, fixture.settingsPath, map[string]any{
"hooks": installed,
"owner": "user",
})
paths := append(droidManagedPaths(fixture), fixture.settingsPath)
before := snapshotDroidFiles(t, paths...)
sources, err := readDroidSources()
if err != nil {
t.Fatalf("read uninstall sources: %v", err)
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
t.Fatalf("resolve runtime root: %v", err)
}
rendered, err := renderDroidUninstall(sources, droidTestAgentID, runtimeRoot)
if err != nil {
t.Fatalf("render uninstall: %v", err)
}
prepared, err := prepareDroidUninstall(rendered)
if err != nil {
t.Fatalf("prepare uninstall: %v", err)
}
failed := false
rename := func(source, destination string) error {
if !failed && sameManagedPath(destination, fixture.settingsPath) {
failed = true
return errors.New("injected Droid uninstall failure")
}
return os.Rename(source, destination)
}
err = commitDroidUninstall(prepared, rename)
if err == nil || !strings.Contains(err.Error(), "injected Droid uninstall failure") || !failed {
t.Fatalf("uninstall rollback error = %v, failed = %v", err, failed)
}
requireDroidSnapshot(t, before)
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidPreflightBlocksDisabledHooksBeforeRuntimeCreation(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
})
preflighter, ok := any(fixture.plugin).(InstallPreflighter)
if !ok {
t.Fatal("Factory Droid plugin does not implement InstallPreflighter")
}
err := preflighter.PreflightInstall(fixture.config)
if err == nil || !strings.Contains(err.Error(), "hooksDisabled") {
t.Fatalf("preflight error = %v", err)
}
requireMissingDroidFile(t, fixture.agentDir)
}
+115 -165
View File
@@ -2,213 +2,163 @@ package plugins
import (
"fmt"
"os"
"path/filepath"
)
// GeminiPlugin manages the Elydora audit hook for Gemini CLI.
// It merges an AfterTool hook into ~/.gemini/settings.json.
type GeminiPlugin struct{}
// GeminiPlugin manages Gemini CLI global user hooks.
type GeminiPlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
func (p *GeminiPlugin) ManagesGuardRuntime() bool {
return true
}
func requireGeminiHooksEnabled(document *geminiDocument) error {
if !document.hookControls.enabled {
return fmt.Errorf(
"Gemini CLI hooks are disabled by hooksConfig.enabled: %s",
document.filePath,
)
}
disabled, err := disabledManagedGeminiEntries(document.hookControls)
if err != nil {
return err
}
if len(disabled) > 0 {
return fmt.Errorf(
"Gemini CLI hooks are disabled by hooksConfig.disabled: %v",
disabled,
)
}
return nil
}
// PreflightInstall validates settings and runtime identity before any write.
func (p *GeminiPlugin) PreflightInstall(config InstallConfig) error {
document, err := readGeminiDocument()
if err != nil {
return err
}
if err := requireGeminiHooksEnabled(document); err != nil {
return err
}
_, _, err = preflightGeminiInstallation(config, document)
return err
}
func (p *GeminiPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID)
document, err := readGeminiDocument()
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
if err := requireGeminiHooksEnabled(document); err != nil {
return err
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
paths, nodePath, err := preflightGeminiInstallation(config, document)
if err != nil {
return err
}
}
configDir, err := expandHome("~/.gemini")
guardGroup, err := buildGeminiGroup(
nodePath,
paths.guardPath,
geminiGuardHookName,
)
if err != nil {
return err
}
configPath := filepath.Join(configDir, "settings.json")
settings, err := readJSONFile(configPath)
auditGroup, err := buildGeminiGroup(
nodePath,
paths.auditPath,
geminiAuditHookName,
)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- BeforeTool (guard — freeze enforcement) ---
beforeTool, _ := hooks["BeforeTool"].([]interface{})
var preFiltered []interface{}
for _, entry := range beforeTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + guardPath,
},
rendered, err := renderGeminiDocument(
document,
"",
map[string]map[string]any{
"BeforeTool": guardGroup,
"AfterTool": auditGroup,
},
)
if err != nil {
return fmt.Errorf("render Gemini CLI user settings: %w", err)
}
preFiltered = append(preFiltered, guardEntry)
hooks["BeforeTool"] = preFiltered
// --- AfterTool (audit logging) ---
afterTool, _ := hooks["AfterTool"].([]interface{})
var postFiltered []interface{}
for _, entry := range afterTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + scriptPath,
},
},
}
postFiltered = append(postFiltered, hookEntry)
hooks["AfterTool"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
changes, err := prepareGeminiInstallationChanges(config, paths, rendered)
if err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Gemini CLI at %s\n", configPath)
if err := writeManagedChanges(
changes,
"Install Gemini CLI hooks",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
"Gemini CLI configuration directory",
); err != nil {
return err
}
fmt.Printf("Gemini CLI hooks installed at %s.\n", document.filePath)
fmt.Println("Gemini CLI verification: run /hooks list.")
return nil
}
func (p *GeminiPlugin) Uninstall(agentID string) error {
configDir, err := expandHome("~/.gemini")
document, err := readGeminiDocument()
if err != nil {
return err
}
configPath := filepath.Join(configDir, "settings.json")
settings, err := readJSONFile(configPath)
rendered, err := renderGeminiDocument(document, agentID, nil)
if err != nil {
return fmt.Errorf("render Gemini CLI user settings: %w", err)
}
change, err := prepareRenderedGeminiChange(rendered)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Gemini CLI hooks found.")
return nil
}
// Remove BeforeTool Elydora entries
beforeTool, _ := hooks["BeforeTool"].([]interface{})
var preFiltered []interface{}
for _, entry := range beforeTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "BeforeTool")
} else {
hooks["BeforeTool"] = preFiltered
}
// Remove AfterTool Elydora entries
afterTool, _ := hooks["AfterTool"].([]interface{})
var postFiltered []interface{}
for _, entry := range afterTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "AfterTool")
} else {
hooks["AfterTool"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
if agentID != "" {
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Gemini CLI.")
return nil
return writeManagedChanges(
[]*fileChange{change},
"Uninstall Gemini CLI hooks",
p.rename,
"",
"",
filepath.Dir(document.filePath),
"Gemini CLI configuration directory",
)
}
func (p *GeminiPlugin) Status() (PluginStatus, error) {
configDir, err := expandHome("~/.gemini")
if err != nil {
return PluginStatus{}, err
}
configPath := filepath.Join(configDir, "settings.json")
configPath, pathErr := geminiSettingsPath()
entry := SupportedAgents[geminiAgentKey]
status := PluginStatus{
AgentName: "gemini",
DisplayName: "Gemini CLI",
ConfigPath: configPath,
AgentName: geminiAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
}
settings, err := readJSONFile(configPath)
if pathErr != nil {
return status, pathErr
}
document, err := readGeminiDocument()
if err != nil {
return status, err
}
if !managedGeminiHooksEnabled(document.hookControls) {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasElydoraEntry(hooks["BeforeTool"])
postConfigured := hasElydoraEntry(hooks["AfterTool"])
status.HookConfigured = preConfigured && postConfigured
// Extract hook script path from the configured command
scriptPath := extractElydoraScriptPath(hooks["AfterTool"])
if scriptPath != "" {
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
contracts, err := geminiRuntimeContracts(document.hooks)
if err != nil {
return status, err
}
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookScriptExists, err = geminiRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
status.Installed = status.HookConfigured && status.HookScriptExists
status.Installed = status.HookScriptExists
return status, nil
}
+111
View File
@@ -0,0 +1,111 @@
package plugins
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func geminiRunCLI(
t *testing.T,
binary string,
fixture *geminiFixture,
args ...string,
) (string, string, int) {
t.Helper()
process := exec.Command(binary, args...)
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"GEMINI_CLI_HOME="+fixture.geminiHome,
)
var stdout, stderr strings.Builder
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return stdout.String(), stderr.String(), 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return stdout.String(), stderr.String(), exitError.ExitCode()
}
t.Fatalf("run Elydora CLI: %v", err)
return stdout.String(), stderr.String(), -1
}
func TestGeminiCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
repository := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
binary := filepath.Join(t.TempDir(), "elydora")
if runtime.GOOS == "windows" {
binary += ".exe"
}
build := exec.Command("go", "build", "-o", binary, "./cmd/elydora")
build.Dir = repository
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
}
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
privateKeyFile := filepath.Join(t.TempDir(), "private.key")
tokenFile := filepath.Join(t.TempDir(), "api.token")
if err := os.WriteFile(privateKeyFile, []byte(geminiPrivateKey+"\n"), 0600); err != nil {
t.Fatalf("write private key input: %v", err)
}
if err := os.WriteFile(tokenFile, []byte("token-1\n"), 0600); err != nil {
t.Fatalf("write token input: %v", err)
}
stdout, stderr, exit := geminiRunCLI(
t,
binary,
fixture,
"install",
"--agent", geminiAgentKey,
"--org-id", "org-1",
"--agent-id", geminiTestAgentID,
"--private-key-file", privateKeyFile,
"--token-file", tokenFile,
"--kid", "kid-1",
"--base-url", "http://127.0.0.1:9",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "/hooks list") {
t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr)
}
requireStrictGeminiPair(t, readGeminiTestObject(t, fixture.settingsPath))
stdout, stderr, exit = geminiRunCLI(
t,
binary,
fixture,
"status",
"--agent", geminiAgentKey,
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr)
}
stdout, stderr, exit = geminiRunCLI(
t,
binary,
fixture,
"uninstall",
"--agent", geminiAgentKey,
"--agent-id", geminiTestAgentID,
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
t.Fatalf("CLI uninstall = %d, %q, %q", exit, stdout, stderr)
}
if _, err := os.Lstat(fixture.settingsPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed settings remain after CLI uninstall: %v", err)
}
if _, err := os.Lstat(fixture.agentDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime remains after CLI uninstall: %v", err)
}
}
+73
View File
@@ -0,0 +1,73 @@
package plugins
import (
"fmt"
"path/filepath"
"runtime"
"strings"
)
type geminiRuntimeReference = managedScriptReference
func buildGeminiCommand(runtimePath, scriptPath string) (string, error) {
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
return "", fmt.Errorf("Gemini CLI hook commands require absolute runtime and script paths")
}
if runtime.GOOS != "windows" {
return posixSource(runtimePath, scriptPath), nil
}
return "& " + quotePowerShellArgument(windowsPowerShellPath()) + powerShellEncodedFlags +
encodePowerShellSource(powerShellSource(runtimePath, scriptPath)), nil
}
func parseGeminiWindowsCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
powerShell, next, ok := readPowerShellArgument(command, 2)
if !ok || !isPowerShellExecutable(powerShell) ||
!strings.HasPrefix(command[next:], powerShellEncodedFlags) {
return "", "", false
}
source, ok := decodePowerShellSource(command[next+len(powerShellEncodedFlags):])
if !ok {
return "", "", false
}
return parsePowerShellSource(source)
}
func parseGeminiCommand(command string) (string, string, bool) {
if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok {
return runtimePath, scriptPath, true
}
return parseGeminiWindowsCommand(command)
}
func parseLegacyGeminiCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "node ") || strings.ContainsAny(command, "\r\n") {
return "", "", false
}
scriptPath := strings.TrimPrefix(command, "node ")
if scriptPath == "" || strings.TrimSpace(scriptPath) != scriptPath {
return "", "", false
}
return "node", scriptPath, true
}
func geminiRuntimeReferenceForCommand(
command string,
scriptName string,
includeLegacy bool,
) (*geminiRuntimeReference, error) {
runtimePath, scriptPath, current := parseGeminiCommand(command)
legacy := false
if !current && includeLegacy {
runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command)
}
validRuntime := (current && filepath.IsAbs(runtimePath) && isNodeExecutable(runtimePath)) ||
(legacy && runtimePath == "node")
if !validRuntime {
return nil, nil
}
return resolveManagedScript(scriptPath, scriptName)
}
+229
View File
@@ -0,0 +1,229 @@
package plugins
import (
"bytes"
"fmt"
"reflect"
"sort"
)
type geminiDocument struct {
filePath string
exists bool
raw []byte
root map[string]any
hooks geminiHooks
hookControls geminiHookControls
hasHooksContainer bool
ownedFile bool
}
type geminiRenderedDocument struct {
document *geminiDocument
changed bool
next []byte
remove bool
}
func geminiDocumentLabel(filePath string) string {
return fmt.Sprintf("Gemini CLI user settings at %s", filePath)
}
func parseGeminiDocument(
exists bool,
filePath string,
raw []byte,
) (*geminiDocument, error) {
label := geminiDocumentLabel(filePath)
root, err := decodeJSONCObject(raw, label, false)
if err != nil {
return nil, err
}
hooks, err := readGeminiHooks(root)
if err != nil {
return nil, err
}
controls, err := readGeminiHookControls(root)
if err != nil {
return nil, err
}
_, hasHooks := root["hooks"]
return &geminiDocument{
filePath: filePath, exists: exists, raw: append([]byte(nil), raw...),
root: root, hooks: hooks, hookControls: controls,
hasHooksContainer: hasHooks,
ownedFile: bytes.HasPrefix(raw, []byte(geminiOwnedFileMarker)),
}, nil
}
func createGeminiDocument(filePath string) (*geminiDocument, error) {
return parseGeminiDocument(
false,
filePath,
[]byte(geminiOwnedFileMarker+"\n{}\n"),
)
}
func geminiAlreadyInstalled(
document *geminiDocument,
additions map[string]map[string]any,
) (bool, error) {
if len(additions) != len(geminiManagedEvents) {
return false, nil
}
removals, err := managedGeminiRemovals(document.hooks, "")
if err != nil {
return false, err
}
for _, event := range geminiManagedEvents {
expected, exists := additions[event]
if !exists {
return false, nil
}
removalCount := 0
removeGroup := false
for _, removal := range removals {
if removal.event == event {
removalCount++
removeGroup = removal.removeGroup
}
}
exactCount := 0
for _, group := range document.hooks[event] {
if reflect.DeepEqual(group, expected) {
exactCount++
}
}
if removalCount != 1 || !removeGroup || exactCount != 1 {
return false, nil
}
}
return true, nil
}
func renderGeminiDocument(
document *geminiDocument,
agentID string,
additions map[string]map[string]any,
) (*geminiRenderedDocument, error) {
if agentID == "" && len(additions) > 0 {
installed, err := geminiAlreadyInstalled(document, additions)
if err != nil {
return nil, err
}
if installed {
return &geminiRenderedDocument{document: document}, nil
}
}
label := geminiDocumentLabel(document.filePath)
editor, err := newJSONCEditor(document.raw, label, false)
if err != nil {
return nil, err
}
removals, err := managedGeminiRemovals(document.hooks, agentID)
if err != nil {
return nil, err
}
for _, event := range geminiManagedEvents {
eventRemovals := make([]geminiManagedRemoval, 0)
for _, removal := range removals {
if removal.event == event {
eventRemovals = append(eventRemovals, removal)
}
}
sort.Slice(eventRemovals, func(left, right int) bool {
return eventRemovals[left].groupIndex > eventRemovals[right].groupIndex
})
for _, removal := range eventRemovals {
groupPath := []any{"hooks", event, removal.groupIndex}
if removal.removeGroup {
if err := editor.remove(groupPath); err != nil {
return nil, err
}
continue
}
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes)))
for _, handlerIndex := range removal.handlerIndexes {
if err := editor.remove(
appendGeminiPath(groupPath, "hooks", handlerIndex),
); err != nil {
return nil, err
}
}
}
if len(eventRemovals) > 0 {
current, parseErr := parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if parseErr != nil {
return nil, parseErr
}
if groups, exists := current.hooks[event]; exists && len(groups) == 0 {
if err := editor.remove([]any{"hooks", event}); err != nil {
return nil, err
}
}
}
}
current, err := parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if err != nil {
return nil, err
}
if current.hasHooksContainer && len(current.hooks) == 0 {
if err := editor.remove([]any{"hooks"}); err != nil {
return nil, err
}
}
for _, event := range geminiManagedEvents {
group, exists := additions[event]
if !exists {
continue
}
current, err = parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if err != nil {
return nil, err
}
_, eventExists := current.hooks[event]
switch {
case !current.hasHooksContainer:
err = editor.addProperty(nil, "hooks", map[string]any{event: []any{group}})
case eventExists:
err = editor.appendArray([]any{"hooks", event}, group)
default:
err = editor.addProperty([]any{"hooks"}, event, []any{group})
}
if err != nil {
return nil, err
}
}
next := editor.pack()
nextDocument, err := parseGeminiDocument(
document.exists,
document.filePath,
next,
)
if err != nil {
return nil, err
}
remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0
return &geminiRenderedDocument{
document: document, changed: remove || !bytes.Equal(next, document.raw),
next: next, remove: remove,
}, nil
}
func appendGeminiPath(base []any, parts ...any) []any {
path := make([]any, 0, len(base)+len(parts))
path = append(path, base...)
return append(path, parts...)
}
+231
View File
@@ -0,0 +1,231 @@
package plugins
import (
"sort"
)
const (
geminiAgentKey = "gemini"
geminiConfigFile = "settings.json"
geminiGuardScript = "guard.js"
geminiAuditScript = "hook.js"
geminiGuardHookName = "elydora-guard"
geminiAuditHookName = "elydora-audit"
geminiHookTimeout = float64(10_000)
geminiOwnedFileMarker = "// Managed by Elydora"
)
var geminiManagedEvents = [...]string{"BeforeTool", "AfterTool"}
type geminiManagedRemoval struct {
event string
groupIndex int
handlerIndexes []int
removeGroup bool
}
type geminiRuntimeContract = managedRuntimeContract
func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) {
command, err := buildGeminiCommand(runtimePath, scriptPath)
if err != nil {
return nil, err
}
return map[string]any{
"hooks": []any{map[string]any{
"type": "command", "name": name, "command": command,
"timeout": geminiHookTimeout,
}},
}, nil
}
func exactManagedGeminiGroup(group map[string]any) bool {
_, hasHooks := group["hooks"]
return len(group) == 1 && hasHooks
}
func currentManagedGeminiReference(
handler map[string]any,
scriptName string,
hookName string,
) (*geminiRuntimeReference, error) {
if len(handler) != 4 || handler["type"] != "command" ||
handler["name"] != hookName || handler["timeout"] != geminiHookTimeout {
return nil, nil
}
command, ok := handler["command"].(string)
if !ok {
return nil, nil
}
return geminiRuntimeReferenceForCommand(command, scriptName, false)
}
func legacyManagedGeminiReference(
handler map[string]any,
scriptName string,
) (*geminiRuntimeReference, error) {
if len(handler) != 2 || handler["type"] != "command" {
return nil, nil
}
command, ok := handler["command"].(string)
if !ok {
return nil, nil
}
return geminiRuntimeReferenceForCommand(command, scriptName, true)
}
func managedGeminiReference(
handler map[string]any,
scriptName string,
hookName string,
includeLegacy bool,
) (*geminiRuntimeReference, error) {
reference, err := currentManagedGeminiReference(handler, scriptName, hookName)
if err != nil || reference != nil || !includeLegacy {
return reference, err
}
return legacyManagedGeminiReference(handler, scriptName)
}
func managedGeminiHooksEnabled(controls geminiHookControls) bool {
if !controls.enabled {
return false
}
for _, entry := range controls.disabled {
if entry == geminiGuardHookName || entry == geminiAuditHookName {
return false
}
}
return true
}
func disabledManagedGeminiEntries(
controls geminiHookControls,
) ([]string, error) {
disabled := make([]string, 0)
for _, entry := range controls.disabled {
managed := entry == geminiGuardHookName || entry == geminiAuditHookName
for _, script := range []string{geminiGuardScript, geminiAuditScript} {
reference, err := geminiRuntimeReferenceForCommand(entry, script, true)
if err != nil {
return nil, err
}
managed = managed || reference != nil
}
if managed {
disabled = append(disabled, entry)
}
}
return disabled, nil
}
func managedGeminiRemovals(
hooks geminiHooks,
agentID string,
) ([]geminiManagedRemoval, error) {
removals := make([]geminiManagedRemoval, 0)
for _, contract := range []struct{ event, script, name string }{
{"BeforeTool", geminiGuardScript, geminiGuardHookName},
{"AfterTool", geminiAuditScript, geminiAuditHookName},
} {
for groupIndex, groupValue := range hooks[contract.event] {
group := groupValue.(map[string]any)
handlers := group["hooks"].([]any)
indexes := make([]int, 0)
for handlerIndex, handlerValue := range handlers {
handler := handlerValue.(map[string]any)
reference, err := managedGeminiReference(
handler,
contract.script,
contract.name,
true,
)
if err != nil {
return nil, err
}
if reference != nil &&
(agentID == "" || sameManagedAgentID(reference.agentID, agentID)) {
indexes = append(indexes, handlerIndex)
}
}
if len(indexes) > 0 {
removals = append(removals, geminiManagedRemoval{
contract.event,
groupIndex,
indexes,
exactManagedGeminiGroup(group) && len(indexes) == len(handlers),
})
}
}
}
return removals, nil
}
func geminiReferencesForEvent(
groups []any,
scriptName string,
hookName string,
) (map[string][]geminiRuntimeReference, error) {
result := map[string][]geminiRuntimeReference{}
for _, groupValue := range groups {
group := groupValue.(map[string]any)
if !exactManagedGeminiGroup(group) {
continue
}
for _, handlerValue := range group["hooks"].([]any) {
reference, err := currentManagedGeminiReference(
handlerValue.(map[string]any),
scriptName,
hookName,
)
if err != nil {
return nil, err
}
if reference != nil {
key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference)
}
}
}
return result, nil
}
func geminiRuntimeContracts(
hooks geminiHooks,
) ([]geminiRuntimeContract, error) {
guards, err := geminiReferencesForEvent(
hooks["BeforeTool"],
geminiGuardScript,
geminiGuardHookName,
)
if err != nil {
return nil, err
}
audits, err := geminiReferencesForEvent(
hooks["AfterTool"],
geminiAuditScript,
geminiAuditHookName,
)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(guards))
for key := range guards {
keys = append(keys, key)
}
sort.Strings(keys)
contracts := make([]geminiRuntimeContract, 0, len(keys))
for _, key := range keys {
guard := guards[key]
audit := audits[key]
if len(guard) != 1 || len(audit) != 1 {
continue
}
contracts = append(contracts, geminiRuntimeContract{
agentID: guard[0].agentID,
guardPath: guard[0].scriptPath,
auditPath: audit[0].scriptPath,
})
}
return contracts, nil
}
@@ -0,0 +1,51 @@
package plugins
import "fmt"
func preflightGeminiInstallation(
config InstallConfig,
document *geminiDocument,
) (*managedRuntimePaths, string, error) {
if document == nil || document.filePath == "" {
return nil, "", fmt.Errorf("Gemini CLI installation requires a user settings document")
}
if err := validateManagedInstallConfig(config, geminiAgentKey, "Gemini CLI"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, geminiGuardScript, geminiAuditScript)
if err != nil {
return nil, "", err
}
if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, geminiAgentKey, "Gemini CLI",
); err != nil {
return nil, "", err
}
nodePath, err := resolveAbsoluteNodeRuntime("Gemini CLI")
if err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func prepareGeminiInstallationChanges(
config InstallConfig,
paths *managedRuntimePaths,
rendered *geminiRenderedDocument,
) ([]*fileChange, error) {
changes, err := managedRuntimeFileChanges(
config,
paths,
geminiAgentKey,
generateGuardScript(geminiAgentKey, config.AgentID, "{}\n", false, ""),
buildHookScriptWithOutput(geminiAgentKey, config.AgentID, "{}\n", false, true),
)
if err != nil {
return nil, err
}
documentChange, err := prepareRenderedGeminiChange(rendered)
if err != nil {
return nil, err
}
return append(changes, documentChange), nil
}
+69
View File
@@ -0,0 +1,69 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
func geminiConfigurationDirectory() (string, error) {
configured := os.Getenv("GEMINI_CLI_HOME")
if configured == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
configured = home
}
return filepath.Join(configured, ".gemini"), nil
}
func geminiSettingsPath() (string, error) {
directory, err := geminiConfigurationDirectory()
if err != nil {
return "", err
}
return filepath.Join(directory, geminiConfigFile), nil
}
func readGeminiDocument() (*geminiDocument, error) {
filePath, err := geminiSettingsPath()
if err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(filePath),
"Gemini CLI configuration directory",
); err != nil {
return nil, err
}
snapshot, err := readManagedFile(
filePath,
"Gemini CLI user settings",
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
if snapshot == nil {
return createGeminiDocument(filePath)
}
return parseGeminiDocument(true, filePath, snapshot.contents)
}
func prepareRenderedGeminiChange(
rendered *geminiRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSourceChange(
rendered.document.filePath,
"Gemini CLI user settings",
rendered.document.raw,
rendered.document.exists,
rendered.next,
0600,
rendered.remove,
)
}
@@ -0,0 +1,70 @@
package plugins
import (
"os"
"os/exec"
"strings"
"testing"
)
func runOfficialGemini(
t *testing.T,
fixture *geminiFixture,
entry string,
args ...string,
) (string, error) {
t.Helper()
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
process := exec.Command(nodePath, append([]string{entry}, args...)...)
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"GEMINI_CLI_HOME="+fixture.geminiHome,
"GEMINI_API_KEY=official-loader-test-key",
"GEMINI_TELEMETRY_ENABLED=false",
"OTEL_SDK_DISABLED=true",
)
output, runErr := process.CombinedOutput()
return string(output), runErr
}
func TestGeminiOfficialCLIReadsManagedPair(t *testing.T) {
entry := os.Getenv("ELYDORA_GEMINI_ENTRY")
if entry == "" {
t.Skip("ELYDORA_GEMINI_ENTRY is unset")
}
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
version, err := runOfficialGemini(t, fixture, entry, "--version")
if err != nil || !strings.Contains(version, "0.51.0") {
t.Fatalf("official Gemini version = %q, %v", version, err)
}
output, err := runOfficialGemini(
t,
fixture,
entry,
"--skip-trust",
"--list-extensions",
)
if err != nil {
t.Fatalf("load official Gemini settings: %v\n%s", err, output)
}
lower := strings.ToLower(output)
for _, failure := range []string{
"invalid settings",
"settings validation",
"failed to parse",
"hook configuration error",
} {
if strings.Contains(lower, failure) {
t.Fatalf("official Gemini rejected settings: %s", output)
}
}
}
+224
View File
@@ -0,0 +1,224 @@
package plugins
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func installGeminiRuntimeFixture(
t *testing.T,
status string,
) (*geminiFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
fixture.config.BaseURL = server.URL
fixture.config.Token = "ely_test_token"
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Gemini hooks: %v", err)
}
return fixture, api, server
}
func TestGeminiGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) {
fixture, api, server := installGeminiRuntimeFixture(t, "active")
defer server.Close()
settings := readGeminiTestObject(t, fixture.settingsPath)
guard := geminiTestManagedHandler(
t,
settings,
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)
audit := geminiTestManagedHandler(
t,
settings,
"AfterTool",
geminiAuditScript,
geminiAuditHookName,
)
guardPayload := geminiOfficialPayload(fixture, "BeforeTool")
if exit, stdout, stderr := runGeminiCommand(
t,
guard["command"].(string),
fixture,
guardPayload,
); exit != 0 || stdout != "{}\n" || stderr != "" {
t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr)
}
firstPayload := geminiOfficialPayload(fixture, "AfterTool")
firstPayload["mcp_context"] = map[string]any{
"server_name": "filesystem",
"tool_name": "write_file",
}
firstPayload["original_request_name"] = "write_file"
firstPayload["future_provider_field"] = map[string]any{"preserved": true}
secondPayload := geminiOfficialPayload(fixture, "AfterTool")
secondPayload["tool_response"] = map[string]any{
"output": "second",
"error": nil,
}
for _, payload := range []map[string]any{firstPayload, secondPayload} {
if exit, stdout, stderr := runGeminiCommand(
t,
audit["command"].(string),
fixture,
payload,
); exit != 0 || stdout != "{}\n" || stderr != "" {
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
}
}
getAuth, postAuth, operations := api.snapshot()
if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" {
t.Fatalf("authorization headers = %q, %q", getAuth, postAuth)
}
if len(operations) != 2 ||
!reflect.DeepEqual(operations[0]["payload"], firstPayload) ||
!reflect.DeepEqual(operations[1]["payload"], secondPayload) {
t.Fatalf("Gemini native operations = %#v", operations)
}
if operations[0]["prev_chain_hash"] !=
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ||
operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] {
t.Fatalf("Gemini operation chain = %#v", operations)
}
}
func TestGeminiGuardUsesOfficialBlockingExitCode(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installGeminiRuntimeFixture(t, status)
defer server.Close()
guard := geminiTestManagedHandler(
t,
readGeminiTestObject(t, fixture.settingsPath),
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)
exit, stdout, stderr := runGeminiCommand(
t,
guard["command"].(string),
fixture,
geminiOfficialPayload(fixture, "BeforeTool"),
)
if exit != 2 || stdout != "" ||
!strings.Contains(strings.ToLower(stderr), status) {
t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr)
}
})
}
}
func TestGeminiRuntimesKeepFailOpenErrorsObservable(t *testing.T) {
fixture, api, server := installGeminiRuntimeFixture(t, "active")
settings := readGeminiTestObject(t, fixture.settingsPath)
guardCommand := geminiTestManagedHandler(
t,
settings,
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)["command"].(string)
auditCommand := geminiTestManagedHandler(
t,
settings,
"AfterTool",
geminiAuditScript,
geminiAuditHookName,
)["command"].(string)
invalidExit, invalidOutput, invalidError := runGeminiRawCommand(
t,
guardCommand,
fixture,
[]byte("{ malformed"),
)
if invalidExit != 0 || invalidOutput != "{}\n" ||
!strings.Contains(invalidError, "invalid JSON") {
t.Fatalf(
"invalid guard result = %d, %q, %q",
invalidExit,
invalidOutput,
invalidError,
)
}
api.mu.Lock()
api.postStatus = http.StatusServiceUnavailable
api.mu.Unlock()
auditExit, auditOutput, auditError := runGeminiCommand(
t,
auditCommand,
fixture,
geminiOfficialPayload(fixture, "AfterTool"),
)
server.Close()
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if auditExit != 0 || auditOutput != "{}\n" || auditError != "" ||
err != nil || !strings.Contains(string(log), "HTTP 503") {
t.Fatalf(
"audit failure = %d, %q, %q, log=%q, err=%v",
auditExit,
auditOutput,
auditError,
log,
err,
)
}
}
func TestGeminiRuntimeArtifactsUsePrivateModesOnPOSIX(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX mode bits are not authoritative on Windows")
}
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
for _, item := range []struct {
path string
mode os.FileMode
}{
{fixture.runtimeConfig, 0600},
{fixture.privateKey, 0600},
{fixture.guardPath, 0700},
{fixture.hookPath, 0700},
{fixture.settingsPath, 0600},
} {
info, err := os.Stat(item.path)
if err != nil || info.Mode().Perm() != item.mode {
t.Fatalf("mode for %s = %v, %v", item.path, info, err)
}
}
}
func TestGeminiRuntimeOutputIsValidJSON(t *testing.T) {
fixture, _, server := installGeminiRuntimeFixture(t, "active")
defer server.Close()
guard := geminiTestManagedHandler(
t,
readGeminiTestObject(t, fixture.settingsPath),
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)
_, stdout, _ := runGeminiCommand(
t,
guard["command"].(string),
fixture,
geminiOfficialPayload(fixture, "BeforeTool"),
)
var response map[string]any
if err := json.Unmarshal([]byte(stdout), &response); err != nil || len(response) != 0 {
t.Fatalf("Gemini response = %q, %v", stdout, err)
}
}
+208
View File
@@ -0,0 +1,208 @@
package plugins
import (
"fmt"
"math"
)
var geminiKnownEvents = map[string]bool{
"BeforeTool": true, "AfterTool": true, "BeforeAgent": true,
"Notification": true, "AfterAgent": true, "SessionStart": true,
"SessionEnd": true, "PreCompress": true, "BeforeModel": true,
"AfterModel": true, "BeforeToolSelection": true,
}
type geminiHooks map[string][]any
type geminiHookControls struct {
enabled bool
disabled []string
}
func optionalGeminiString(value map[string]any, field, label string) error {
if item, exists := value[field]; exists {
if _, ok := item.(string); !ok {
return fmt.Errorf(`%s field %q must be a string`, label, field)
}
}
return nil
}
func validateGeminiHandler(
value any,
event string,
groupIndex int,
handlerIndex int,
) error {
label := fmt.Sprintf(
"Gemini CLI settings handler hooks.%s[%d].hooks[%d]",
event,
groupIndex,
handlerIndex,
)
handler, ok := value.(map[string]any)
if !ok || handler == nil {
return fmt.Errorf("%s must be an object", label)
}
if handler["type"] != "command" {
return fmt.Errorf(
`%s has unsupported type %q`,
label,
fmt.Sprint(handler["type"]),
)
}
for _, field := range []string{"name", "description", "source"} {
if err := optionalGeminiString(handler, field, label); err != nil {
return err
}
}
if timeout, exists := handler["timeout"]; exists {
number, ok := timeout.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return fmt.Errorf(
"%s timeout must be a non-negative finite number",
label,
)
}
}
if environment, exists := handler["env"]; exists {
entries, ok := environment.(map[string]any)
if !ok {
return fmt.Errorf("%s env must map names to strings", label)
}
for _, entry := range entries {
if _, ok := entry.(string); !ok {
return fmt.Errorf("%s env must map names to strings", label)
}
}
}
command, ok := handler["command"].(string)
if !ok || command == "" {
return fmt.Errorf("%s requires a non-empty command", label)
}
return nil
}
func validateGeminiGroup(value any, event string, groupIndex int) error {
label := fmt.Sprintf(
"Gemini CLI settings group hooks.%s[%d]",
event,
groupIndex,
)
group, ok := value.(map[string]any)
if !ok || group == nil {
return fmt.Errorf("%s must be an object", label)
}
if matcher, exists := group["matcher"]; exists {
if _, ok := matcher.(string); !ok {
return fmt.Errorf("%s matcher must be a string", label)
}
}
if sequential, exists := group["sequential"]; exists {
if _, ok := sequential.(bool); !ok {
return fmt.Errorf("%s sequential must be a boolean", label)
}
}
handlers, ok := group["hooks"].([]any)
if !ok {
return fmt.Errorf("%s must contain a hooks array", label)
}
for handlerIndex, handler := range handlers {
if err := validateGeminiHandler(
handler,
event,
groupIndex,
handlerIndex,
); err != nil {
return err
}
}
return nil
}
func readGeminiHooks(root map[string]any) (geminiHooks, error) {
value, exists := root["hooks"]
if !exists {
return geminiHooks{}, nil
}
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`Gemini CLI settings field "hooks" must be an object`)
}
hooks := make(geminiHooks, len(object))
for event, groupsValue := range object {
groups, ok := groupsValue.([]any)
if !ok {
return nil, fmt.Errorf(
`Gemini CLI settings field "hooks.%s" must be an array`,
event,
)
}
if geminiKnownEvents[event] {
for groupIndex, group := range groups {
if err := validateGeminiGroup(group, event, groupIndex); err != nil {
return nil, err
}
}
}
hooks[event] = append([]any(nil), groups...)
}
return hooks, nil
}
func readGeminiHookControls(root map[string]any) (geminiHookControls, error) {
value, exists := root["hooksConfig"]
if !exists {
return geminiHookControls{enabled: true}, nil
}
object, ok := value.(map[string]any)
if !ok || object == nil {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig" must be an object`,
)
}
for field := range object {
if field != "enabled" && field != "disabled" && field != "notifications" {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig" contains unsupported field %q`,
field,
)
}
}
enabled := true
if value, exists := object["enabled"]; exists {
flag, ok := value.(bool)
if !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.enabled" must be a boolean`,
)
}
enabled = flag
}
if value, exists := object["notifications"]; exists {
if _, ok := value.(bool); !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.notifications" must be a boolean`,
)
}
}
disabled := []string{}
if value, exists := object["disabled"]; exists {
items, ok := value.([]any)
if !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.disabled" must be an array of strings`,
)
}
for _, item := range items {
entry, ok := item.(string)
if !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.disabled" must be an array of strings`,
)
}
disabled = append(disabled, entry)
}
}
return geminiHookControls{enabled: enabled, disabled: disabled}, nil
}
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more