15 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
128 changed files with 2871 additions and 7621 deletions

No files matched your search

+1
View File
@@ -31,3 +31,4 @@ Thumbs.db
.env.local .env.local
*.pem *.pem
*.key *.key
/elydora
+12 -1
View File
@@ -47,6 +47,15 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- 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. - 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. - 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 ## Code Quality
- Preserve the minimum Go version declared in `go.mod`. - Preserve the minimum Go version declared in `go.mod`.
@@ -65,7 +74,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
Run the focused adapter test during development, then execute all release gates before commit: Run the focused adapter test during development, then execute all release gates before commit:
```powershell ```sh
go test ./cmd/elydora/plugins -run <Provider> -count=1 go test ./cmd/elydora/plugins -run <Provider> -count=1
go test ./... go test ./...
go test -race ./... go test -race ./...
@@ -75,6 +84,8 @@ govulncheck ./...
git diff --check 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. 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. 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`.
+51 -26
View File
@@ -10,6 +10,8 @@ go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration. 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 ## Quick Start
```go ```go
@@ -23,18 +25,12 @@ import (
) )
func main() { func main() {
// Authenticate // Create client with an API token issued in the Console
auth, err := elydora.Login("https://api.elydora.com", "user@example.com", "password")
if err != nil {
log.Fatal(err)
}
// Create client
client, err := elydora.NewClient(&elydora.Config{ client, err := elydora.NewClient(&elydora.Config{
OrgID: auth.User.OrgID, OrgID: "org-123",
AgentID: "my-agent-id", AgentID: "my-agent-id",
PrivateKey: "<base64url-encoded-ed25519-seed>", PrivateKey: "<base64url-encoded-ed25519-seed>",
Token: auth.Token, Token: "<api-token>",
}) })
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -113,8 +109,9 @@ Qwen Code `0.20.0` installation writes native user hooks to `$QWEN_HOME/settings
|---------|-------------| |---------|-------------|
| `elydora install` | Install Elydora audit hook for a coding agent | | `elydora install` | Install Elydora audit hook for a coding agent |
| `elydora uninstall` | Remove 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 agents` | List supported coding agents |
| `elydora version` | Print the CLI version |
### Supported Agents ### Supported Agents
@@ -162,24 +159,23 @@ client.SetToken("new-api-token")
### Authentication ### Authentication
```go ```go
// Register a new user and organization // Get the authenticated user and current organization
reg, err := elydora.Register(baseURL, email, password,
elydora.WithDisplayName("Alice"),
elydora.WithOrgName("Acme Corp"),
)
// Login and receive a session token
auth, err := elydora.Login(baseURL, email, password)
// Get current authenticated user profile
me, err := client.GetMe() 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{ tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
TTLSeconds: &ttlSeconds, 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 ### Operations
```go ```go
@@ -219,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
// Get agent details // Get agent details
details, err := client.GetAgent(agentID) details, err := client.GetAgent(agentID)
// Change the integration type
updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode)
// Freeze an agent // Freeze an agent
err := client.FreezeAgent(agentID, "security review") frozen, err := client.FreezeAgent(agentID, "security review")
// Revoke a key // Revoke a key
err := client.RevokeKey(agentID, kid, "key rotation") err := client.RevokeKey(agentID, kid, "key rotation")
@@ -229,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation")
agents, err := client.ListAgents() agents, err := client.ListAgents()
// Unfreeze a previously frozen agent // Unfreeze a previously frozen agent
err := client.UnfreezeAgent(agentID, "review complete") unfrozen, err := client.UnfreezeAgent(agentID, "review complete")
// Delete an agent permanently // Delete an agent permanently
deleted, err := client.DeleteAgent(agentID) deleted, err := client.DeleteAgent(agentID)
@@ -270,6 +269,25 @@ detail, err := client.GetExport(exportID)
data, err := client.DownloadExport(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 ### JWKS
```go ```go
@@ -281,13 +299,17 @@ jwks, err := client.GetJWKS()
```go ```go
// Check API health (no authentication required, does not need a client) // Check API health (no authentication required, does not need a client)
health, err := elydora.Health("https://api.elydora.com") 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 ### Constants
```go ```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" elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
``` ```
@@ -299,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` | | `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` | | `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` | | `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` | | `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 ## Error Handling
+1 -2
View File
@@ -2,8 +2,7 @@ package elydora
import "fmt" import "fmt"
// ListAdminEvents retrieves recent administrative events for the organization. // ListAdminEvents retrieves recent administrative events; limit 0 uses the server default.
// An optional limit (1–100) may be provided; pass 0 to use the server default (20).
func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) { func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) {
path := "/v1/admin/events" path := "/v1/admin/events"
if limit > 0 { if limit > 0 {
+7 -6
View File
@@ -3,6 +3,7 @@ package elydora
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
) )
// RegisterAgent registers a new AI agent within the organization. // RegisterAgent registers a new AI agent within the organization.
@@ -17,7 +18,7 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons
// GetAgent retrieves an agent by ID. // GetAgent retrieves an agent by ID.
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) { func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
var result GetAgentResponse 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 nil, err
} }
return &result, nil return &result, nil
@@ -26,7 +27,7 @@ func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
// FreezeAgent freezes an agent, preventing it from submitting operations. // FreezeAgent freezes an agent, preventing it from submitting operations.
func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) { func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
var result FreezeAgentResponse var result FreezeAgentResponse
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{ if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{
Reason: reason, Reason: reason,
}, &result); err != nil { }, &result); err != nil {
return nil, err return nil, err
@@ -36,7 +37,7 @@ func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, erro
// RevokeKey revokes a specific key for an agent. // RevokeKey revokes a specific key for an agent.
func (c *Client) RevokeKey(agentID, kid, reason string) error { 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, KID: kid,
Reason: reason, Reason: reason,
}, nil) }, nil)
@@ -54,7 +55,7 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) {
// UnfreezeAgent unfreezes a previously frozen agent. // UnfreezeAgent unfreezes a previously frozen agent.
func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) { func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
var result UnfreezeAgentResponse var result UnfreezeAgentResponse
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{ if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{
Reason: reason, Reason: reason,
}, &result); err != nil { }, &result); err != nil {
return nil, err return nil, err
@@ -65,7 +66,7 @@ func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse,
// UpdateAgent updates an agent's integration type. // UpdateAgent updates an agent's integration type.
func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) { func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) {
var result UpdateAgentResponse var result UpdateAgentResponse
if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", agentID), &UpdateAgentRequest{ if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &UpdateAgentRequest{
IntegrationType: integrationType, IntegrationType: integrationType,
}, &result); err != nil { }, &result); err != nil {
return nil, err return nil, err
@@ -76,7 +77,7 @@ func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*
// DeleteAgent permanently deletes an agent and all associated data. // DeleteAgent permanently deletes an agent and all associated data.
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) { func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
var result DeleteAgentResponse 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 nil, err
} }
return &result, nil return &result, nil
+11 -97
View File
@@ -1,51 +1,30 @@
package elydora package elydora
import ( import "net/http"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// RegisterOption is a functional option for the Register function. // RegisterOption configures a registration request.
type RegisterOption func(*AuthRegisterRequest) type RegisterOption func(*AuthRegisterRequest)
// WithDisplayName sets the display_name on a registration request. // WithDisplayName sets the display_name on a registration request.
func WithDisplayName(name string) RegisterOption { func WithDisplayName(name string) RegisterOption {
return func(req *AuthRegisterRequest) { return func(req *AuthRegisterRequest) { req.DisplayName = name }
req.DisplayName = name
}
} }
// WithOrgName sets the org_name on a registration request. // WithOrgName sets the org_name on a registration request.
func WithOrgName(name string) RegisterOption { func WithOrgName(name string) RegisterOption {
return func(req *AuthRegisterRequest) { return func(req *AuthRegisterRequest) { req.OrgName = name }
req.OrgName = name
}
} }
// Register creates a new user and organization. // Register creates a new user and organization.
// //
// Deprecated: Use Better Auth endpoints directly. See docs. // Deprecated: use the Console sign-up flow; password registration is being phased out.
func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) { func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) {
if baseURL == "" { req := &AuthRegisterRequest{Email: email, Password: password}
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
req := &AuthRegisterRequest{
Email: email,
Password: password,
}
for _, opt := range opts { for _, opt := range opts {
opt(req) opt(req)
} }
var result AuthRegisterResponse 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 nil, err
} }
return &result, nil return &result, nil
@@ -53,80 +32,16 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg
// Login authenticates a user and returns a session token. // Login authenticates a user and returns a session token.
// //
// Deprecated: Use Better Auth endpoints directly. See docs. // Deprecated: use the Console sign-in flow; password login is being phased out.
func Login(baseURL, email, password string) (*AuthLoginResponse, error) { func Login(baseURL, email, password string) (*AuthLoginResponse, error) {
if baseURL == "" { req := &AuthLoginRequest{Email: email, Password: password}
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
req := &AuthLoginRequest{
Email: email,
Password: password,
}
var result AuthLoginResponse 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 nil, err
} }
return &result, nil 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. // GetMe retrieves the current authenticated user's profile.
func (c *Client) GetMe() (*GetMeResponse, error) { func (c *Client) GetMe() (*GetMeResponse, error) {
var result GetMeResponse var result GetMeResponse
@@ -145,8 +60,7 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons
return &result, nil return &result, nil
} }
// RotateApiToken rotates the current API token and leaves the old token // RotateApiToken rotates the current API token; the old token stays valid for 24h.
// valid for a 24h grace window.
func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) { func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) {
var result RotateApiTokenResponse var result RotateApiTokenResponse
if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil { if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil {
+172 -110
View File
@@ -14,6 +14,8 @@ const (
defaultBaseURL = "https://api.elydora.com" defaultBaseURL = "https://api.elydora.com"
defaultTTLMs = 30000 defaultTTLMs = 30000
defaultMaxRetries = 3 defaultMaxRetries = 3
maxResponseBytes = 10 << 20
requestTimeout = 30 * time.Second
) )
// Config holds the configuration for creating a new Client. // Config holds the configuration for creating a new Client.
@@ -44,135 +46,195 @@ func NewClient(cfg *Config) (*Client, error) {
if cfg == nil { if cfg == nil {
return nil, fmt.Errorf("elydora: config must not be 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 ttlMs := cfg.TTLMs
if ttlMs <= 0 { if ttlMs <= 0 {
ttlMs = defaultTTLMs ttlMs = defaultTTLMs
} }
maxRetries := cfg.MaxRetries maxRetries := cfg.MaxRetries
if maxRetries <= 0 { if maxRetries <= 0 {
maxRetries = defaultMaxRetries maxRetries = defaultMaxRetries
} }
return &Client{ return &Client{
orgID: cfg.OrgID, orgID: cfg.OrgID,
agentID: cfg.AgentID, agentID: cfg.AgentID,
privateKey: cfg.PrivateKey, privateKey: cfg.PrivateKey,
baseURL: baseURL, baseURL: normalizeBaseURL(cfg.BaseURL),
ttlMs: ttlMs, ttlMs: ttlMs,
maxRetries: maxRetries, maxRetries: maxRetries,
token: cfg.Token, token: cfg.Token,
httpClient: &http.Client{ httpClient: &http.Client{Timeout: requestTimeout},
Timeout: 30 * time.Second,
},
}, nil }, 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
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("elydora: marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
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)
}
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
}
req, err := http.NewRequest(method, u, bodyReader)
if err != nil {
return fmt.Errorf("elydora: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
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 {
return fmt.Errorf("elydora: unmarshal response: %w", err)
}
}
return nil
}
// Parse error response
var errResp errorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
apiErr := &ElydoraError{
StatusCode: resp.StatusCode,
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
continue
}
lastErr = &ElydoraError{
StatusCode: resp.StatusCode,
Code: ErrorCodeInternalError,
Message: string(respBody),
}
if resp.StatusCode < 500 && resp.StatusCode != 429 {
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 API token used for authenticated API requests. // SetToken sets the API token used for authenticated API requests.
func (c *Client) SetToken(token string) { func (c *Client) SetToken(token string) {
c.token = token c.token = token
} }
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 nil, fmt.Errorf("elydora: marshal request body: %w", err)
}
return data, nil
}
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)
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("elydora: read response body: %w", err)
}
return body, nil
}
func decodeSuccess(body []byte, result interface{}) error {
if result == nil {
return nil
}
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("elydora: unmarshal response: %w", err)
}
return nil
}
func apiError(status int, body []byte) *ElydoraError {
var errResp errorResponse
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,
}
}
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
}
respBody, err := readBody(resp, maxResponseBytes)
if err != nil {
lastErr = err
if !idempotent(method) {
return lastErr
}
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
}
func (c *Client) doGet(path string, result interface{}) error {
return c.doRequest(http.MethodGet, path, nil, result)
}
func (c *Client) doPost(path string, body interface{}, result interface{}) error {
return c.doRequest(http.MethodPost, path, body, result)
}
// 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)
}
-3
View File
@@ -119,9 +119,6 @@ func readSecretFileWithOpen(
if err := validateSecretFile(before, path, label); err != nil { if err := validateSecretFile(before, path, label); err != nil {
return "", err return "", err
} }
if !os.SameFile(before, before) {
return "", fmt.Errorf("establish %s file identity at %s", label, path)
}
file, err := openFile(path) file, err := openFile(path)
if err != nil { if err != nil {
+1 -26
View File
@@ -11,7 +11,7 @@ import (
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins" "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
) )
const version = "2.0.0" const version = "2.1.1"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -57,10 +57,6 @@ Run "elydora <command> -h" for details on each command.
`) `)
} }
// ---------------------------------------------------------------------------
// install
// ---------------------------------------------------------------------------
func cmdInstall(args []string) { func cmdInstall(args []string) {
if err := rejectLegacySecretArguments(args); err != nil { if err := rejectLegacySecretArguments(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -160,10 +156,6 @@ func cmdInstall(args []string) {
fmt.Printf(" Guard script: %s\n", guardScriptPath) fmt.Printf(" Guard script: %s\n", guardScriptPath)
} }
// ---------------------------------------------------------------------------
// uninstall
// ---------------------------------------------------------------------------
func cmdUninstall(args []string) { func cmdUninstall(args []string) {
fs := flag.NewFlagSet("uninstall", flag.ExitOnError) fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (required)") agent := fs.String("agent", "", "Agent name (required)")
@@ -209,10 +201,6 @@ func cmdUninstall(args []string) {
} }
} }
// ---------------------------------------------------------------------------
// status
// ---------------------------------------------------------------------------
func cmdStatus(args []string) { func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError) fs := flag.NewFlagSet("status", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (optional — omit to show all)") agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
@@ -256,10 +244,6 @@ func showAgentStatus(name string) {
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state) fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
} }
// ---------------------------------------------------------------------------
// agents
// ---------------------------------------------------------------------------
func cmdAgents() { func cmdAgents() {
fmt.Println("Supported agents:") fmt.Println("Supported agents:")
fmt.Println() fmt.Println()
@@ -282,12 +266,3 @@ func sortedAgentNames() []string {
sort.Strings(names) sort.Strings(names)
return names return names
} }
// guardScriptPathForAgent returns the path to ~/.elydora/<agentId>/guard.js.
func guardScriptPathForAgent(agentId string) (string, error) {
agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(agentId)
if err != nil {
return "", err
}
return filepath.Join(agentDirectory, "guard.js"), nil
}
+4 -2
View File
@@ -5,6 +5,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
) )
func setMainTestHome(t *testing.T) string { func setMainTestHome(t *testing.T) string {
@@ -17,9 +19,9 @@ func setMainTestHome(t *testing.T) string {
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) { func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
setMainTestHome(t) setMainTestHome(t)
_, err := guardScriptPathForAgent("../escape") _, err := plugins.ResolveAgentRuntimeDirectory("../escape")
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") { if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
t.Fatalf("guardScriptPathForAgent() error = %v", err) t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err)
} }
} }
+5 -4
View File
@@ -10,8 +10,7 @@ type AugmentPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Auggie commits its provider guard together // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// with both wrappers, the audit runtime, and user settings.
func (p *AugmentPlugin) ManagesGuardRuntime() bool { func (p *AugmentPlugin) ManagesGuardRuntime() bool {
return true return true
} }
@@ -57,13 +56,14 @@ func (p *AugmentPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeAugmentChanges( if err := writeManagedChanges(
changes, changes,
"Install Augment Code CLI hooks", "Install Augment Code CLI hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.configPath), filepath.Dir(document.configPath),
"Auggie configuration directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -99,13 +99,14 @@ func (p *AugmentPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeAugmentChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Augment Code CLI hooks", "Uninstall Augment Code CLI hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.configPath), filepath.Dir(document.configPath),
"Auggie configuration directory",
) )
} }
+9 -63
View File
@@ -16,7 +16,6 @@ const (
augmentGuardScript = "guard.js" augmentGuardScript = "guard.js"
augmentAuditScript = "hook.js" augmentAuditScript = "hook.js"
augmentHookTimeout = float64(10_000) augmentHookTimeout = float64(10_000)
augmentPOSIXApostrophe = `'"'"'`
) )
var ( var (
@@ -68,14 +67,6 @@ type augmentRuntimeContract struct {
auditWrapper string auditWrapper string
} }
func cloneAugmentObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func validateAugmentHandler( func validateAugmentHandler(
value any, value any,
event string, event string,
@@ -114,7 +105,7 @@ func validateAugmentHandler(
return nil, fmt.Errorf("%s timeout must be a positive finite number", label) return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
} }
} }
return cloneAugmentObject(handler), nil return cloneJSONObject(handler), nil
} }
func validateAugmentMetadata(value any, label string) error { func validateAugmentMetadata(value any, label string) error {
@@ -169,7 +160,7 @@ func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup
} }
handlers = append(handlers, handler) handlers = append(handlers, handler)
} }
return augmentGroup{object: cloneAugmentObject(object), handlers: handlers}, nil return augmentGroup{object: cloneJSONObject(object), handlers: handlers}, nil
} }
func readAugmentHooks(root map[string]any) (augmentHooks, error) { func readAugmentHooks(root map[string]any) (augmentHooks, error) {
@@ -248,7 +239,7 @@ func renderAugmentDocument(
if !document.exists && len(hooks) == 0 { if !document.exists && len(hooks) == 0 {
return &augmentRenderedDocument{document: document}, nil return &augmentRenderedDocument{document: document}, nil
} }
root := cloneAugmentObject(document.root) root := cloneJSONObject(document.root)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
} else { } else {
@@ -281,7 +272,7 @@ func renderAugmentHooks(hooks augmentHooks) map[string]any {
for event, groups := range hooks { for event, groups := range hooks {
values := make([]any, 0, len(groups)) values := make([]any, 0, len(groups))
for _, group := range groups { for _, group := range groups {
object := cloneAugmentObject(group.object) object := cloneJSONObject(group.object)
handlers := make([]any, 0, len(group.handlers)) handlers := make([]any, 0, len(group.handlers))
for _, handler := range group.handlers { for _, handler := range group.handlers {
handlers = append(handlers, handler) handlers = append(handlers, handler)
@@ -314,23 +305,8 @@ func readAugmentWindowsArgument(command string) (string, bool) {
} }
func readAugmentPOSIXArgument(command string) (string, bool) { func readAugmentPOSIXArgument(command string) (string, bool) {
if len(command) < 2 || command[0] != '\'' { value, end, ok := readPOSIXArgument(command, 0)
return "", false return value, ok && end == len(command) && value != ""
}
var value strings.Builder
for index := 1; index < len(command); {
if strings.HasPrefix(command[index:], augmentPOSIXApostrophe) {
value.WriteByte('\'')
index += len(augmentPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index == len(command)-1 && value.Len() > 0
}
value.WriteByte(command[index])
index++
}
return "", false
} }
func parseAugmentCommand(command string) (string, bool) { func parseAugmentCommand(command string) (string, bool) {
@@ -340,36 +316,6 @@ func parseAugmentCommand(command string) (string, bool) {
return readAugmentPOSIXArgument(command) return readAugmentPOSIXArgument(command)
} }
func normalizeAugmentPath(value string) string {
absolute, err := filepath.Abs(value)
if err == nil {
value = absolute
}
value = filepath.ToSlash(filepath.Clean(value))
if runtime.GOOS == "windows" {
return strings.ToLower(value)
}
return value
}
func sameAugmentPath(left, right string) bool {
return normalizeAugmentPath(left) == normalizeAugmentPath(right)
}
func sameAugmentAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameAugmentFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func managedAugmentAgentID( func managedAugmentAgentID(
handler map[string]any, handler map[string]any,
wrapperName, runtimeRoot string, wrapperName, runtimeRoot string,
@@ -385,11 +331,11 @@ func managedAugmentAgentID(
return "", false return "", false
} }
wrapperPath, ok := parseAugmentCommand(command) wrapperPath, ok := parseAugmentCommand(command)
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) { if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) {
return "", false return "", false
} }
agentDirectory := filepath.Dir(wrapperPath) agentDirectory := filepath.Dir(wrapperPath)
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) { if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) agentID := filepath.Base(agentDirectory)
@@ -407,7 +353,7 @@ func removeManagedAugmentGroups(
groupChanged := false groupChanged := false
for _, handler := range group.handlers { for _, handler := range group.handlers {
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot) managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID)) remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID))
if remove { if remove {
changed = true changed = true
groupChanged = true groupChanged = true
+34 -154
View File
@@ -1,152 +1,68 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type augmentRuntimePaths struct { type augmentRuntimePaths struct {
runtimeRoot string managedRuntimePaths
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
guardWrapperPath string guardWrapperPath string
auditWrapperPath string auditWrapperPath string
} }
func validateAugmentInstallConfig(config InstallConfig) error { func augmentWrapperArtifacts(agentDirectory string) []managedArtifact {
for _, field := range []struct{ name, value string }{ wrappers := resolveAugmentWrapperPaths(agentDirectory)
{"agent name", config.AgentName}, return []managedArtifact{
{"organization ID", config.OrgID}, {wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes},
{"agent ID", config.AgentID}, {wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
} }
} }
if config.AgentName != augmentAgentKey {
return fmt.Errorf(
"Augment Code CLI installation requires agent name %s",
augmentAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) { func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) {
if err := validateAugmentInstallConfig(config); err != nil { if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil {
return nil, err return nil, err
} }
runtimeRoot, err := AgentRuntimeRoot() paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript)
if err != nil { if err != nil {
return nil, err return nil, err
} }
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) wrappers := resolveAugmentWrapperPaths(paths.agentDirectory)
if err != nil { return &augmentRuntimePaths{
return nil, err managedRuntimePaths: *paths,
}
wrappers := resolveAugmentWrapperPaths(agentDirectory)
paths := &augmentRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, augmentGuardScript),
auditPath: filepath.Join(agentDirectory, augmentAuditScript),
guardWrapperPath: wrappers.guard, guardWrapperPath: wrappers.guard,
auditWrapperPath: wrappers.audit, auditWrapperPath: wrappers.audit,
}, nil
} }
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameAugmentPath(config.GuardScriptPath, paths.guardPath) { func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
return nil, fmt.Errorf( return validateManagedRuntimeIdentity(
"Elydora guard runtime must use the managed agent directory: %s", agentDirectory, agentID, augmentAgentKey, "Auggie",
paths.guardPath, augmentWrapperArtifacts(agentDirectory)...,
) )
} }
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameAugmentPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightAugmentInstallation( func preflightAugmentInstallation(
config InstallConfig, config InstallConfig,
document *augmentDocument, document *augmentDocument,
) (*augmentRuntimePaths, string, error) { ) (*augmentRuntimePaths, string, error) {
if document == nil || document.configPath == "" { if document == nil || document.configPath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document")
"Augment Code CLI installation requires a user settings document",
)
} }
paths, err := augmentAgentPaths(config) paths, err := augmentAgentPaths(config)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateAugmentRuntimeIdentity( if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
paths.agentDirectory,
config.AgentID,
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
return nil, "", err
}
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil { if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
return nil, "", err return nil, "", err
} }
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildAugmentRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": augmentAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareAugmentInstallationChanges( func prepareAugmentInstallationChanges(
config InstallConfig, config InstallConfig,
paths *augmentRuntimePaths, paths *augmentRuntimePaths,
@@ -156,63 +72,27 @@ func prepareAugmentInstallationChanges(
if paths == nil || rendered == nil { if paths == nil || rendered == nil {
return nil, fmt.Errorf("prepared Auggie installation is required") return nil, fmt.Errorf("prepared Auggie installation is required")
} }
if err := validateAugmentRuntimeIdentity( if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
paths.agentDirectory,
config.AgentID,
); err != nil {
return nil, err return nil, err
} }
runtimeConfig, err := buildAugmentRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
config,
&paths.managedRuntimePaths,
augmentAgentKey,
generateGuardScript(augmentAgentKey, config.AgentID, "", false, ""),
buildHookScriptWithOutput(augmentAgentKey, config.AgentID, "", false, true),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct { for _, item := range []struct {
path, label string path, label string
content []byte content []byte
mode os.FileMode
}{ }{
{ {paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)},
paths.guardPath, {paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)},
"Elydora guard runtime", } {
[]byte(generateGuardScript( change, err := prepareFileChange(item.path, item.label, item.content, 0700)
augmentAgentKey,
config.AgentID,
"",
false,
"",
)),
0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath,
"Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
augmentAgentKey,
config.AgentID,
"",
false,
true,
)),
0700,
},
{
paths.guardWrapperPath,
"Auggie guard wrapper",
buildAugmentWrapper(nodePath, paths.guardPath),
0700,
},
{
paths.auditWrapperPath,
"Auggie audit wrapper",
buildAugmentWrapper(nodePath, paths.auditPath),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
-44
View File
@@ -83,43 +83,6 @@ func prepareRenderedAugmentChange(
) )
} }
func writeAugmentChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
settingsDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if settingsDirectory != "" {
if err := ensureManagedDirectory(
settingsDirectory,
"Auggie configuration directory",
); err != nil {
return err
}
}
return writeChanges(changes, label, rename)
}
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error { func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
events := make([]string, 0, len(hooks)) events := make([]string, 0, len(hooks))
for event := range hooks { for event := range hooks {
@@ -209,10 +172,3 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths {
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()), audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
} }
} }
func requireAugmentAbsoluteNode(nodePath string) error {
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
return fmt.Errorf("Auggie hooks require an absolute Node.js executable path")
}
return nil
}
+26 -281
View File
@@ -2,11 +2,8 @@ package plugins
import ( import (
"bytes" "bytes"
"fmt"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings"
) )
func managedAugmentIDs( func managedAugmentIDs(
@@ -17,19 +14,10 @@ func managedAugmentIDs(
result := map[string]string{} result := map[string]string{}
for _, group := range groups { for _, group := range groups {
for _, handler := range group.handlers { for _, handler := range group.handlers {
agentID, managed := managedAugmentAgentID( agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
handler, if managed {
wrapperName, result[managedReferenceKey(agentID)] = agentID
runtimeRoot,
)
if !managed {
continue
} }
key := agentID
if runtime.GOOS == "windows" {
key = strings.ToLower(key)
}
result[key] = agentID
} }
} }
return result return result
@@ -39,16 +27,8 @@ func augmentRuntimeContracts(
hooks augmentHooks, hooks augmentHooks,
runtimeRoot string, runtimeRoot string,
) []augmentRuntimeContract { ) []augmentRuntimeContract {
guards := managedAugmentIDs( guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot)
hooks["PreToolUse"], audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot)
augmentGuardWrapperName(),
runtimeRoot,
)
audits := managedAugmentIDs(
hooks["PostToolUse"],
augmentAuditWrapperName(),
runtimeRoot,
)
keys := make([]string, 0, len(guards)) keys := make([]string, 0, len(guards))
for key := range guards { for key := range guards {
if _, exists := audits[key]; exists { if _, exists := audits[key]; exists {
@@ -72,282 +52,47 @@ func augmentRuntimeContracts(
return contracts return contracts
} }
func readAugmentRuntimeConfig(path string) (map[string]any, bool, error) { func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) {
snapshot, err := readManagedFile( wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath))
path, if !sameManagedPath(contract.guardWrapper, wrappers.guard) ||
"Elydora runtime config", !sameManagedPath(contract.auditWrapper, wrappers.audit) {
maxRuntimeConfigBytes, return false, nil
)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
} }
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireAugmentRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateAugmentRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id",
"agent_id",
"kid",
"base_url",
"token",
"agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireAugmentRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireAugmentRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireAugmentRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameAugmentAgentID(agentID, expectedAgentID) ||
config["agent_name"] != augmentAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Auggie hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireAugmentRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireAugmentRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readAugmentRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct { for _, item := range []struct {
path string path, label string
label string expected []byte
limit int64
}{ }{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, {contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)},
{filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, {contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)},
{filepath.Join(agentDirectory, augmentAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, augmentGuardWrapperName()), "Auggie guard wrapper", maxManagedSourceBytes},
{filepath.Join(agentDirectory, augmentAuditWrapperName()), "Auggie audit wrapper", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} { } {
exists, inspectErr := managedPhysicalFileExists( snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes)
item.path, if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) {
item.label,
item.limit,
)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != augmentAgentKey ||
!sameAugmentAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Auggie agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func augmentRuntimeContractExists(
contract augmentRuntimeContract,
runtimeRoot string,
nodePath string,
) (bool, error) {
agentDirectory := filepath.Dir(contract.guardPath)
wrappers := resolveAugmentWrapperPaths(agentDirectory)
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameAugmentPath(
contract.auditPath,
filepath.Join(agentDirectory, augmentAuditScript),
) ||
!sameAugmentPath(contract.guardWrapper, wrappers.guard) ||
!sameAugmentPath(contract.auditWrapper, wrappers.audit) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err return false, err
} }
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
} }
configPath := filepath.Join(agentDirectory, "config.json") return true, nil
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readAugmentRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
guardWrapper, err := readManagedFile(
contract.guardWrapper,
"Auggie guard wrapper",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
auditWrapper, err := readManagedFile(
contract.auditWrapper,
"Auggie audit wrapper",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil ||
guardWrapper == nil || auditWrapper == nil {
return false, nil
}
if err := validateAugmentRuntimeConfig(
config,
contract.agentID,
configPath,
); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
return len(guard.contents) > 0 &&
len(audit.contents) > 0 &&
bytes.Equal(
guardWrapper.contents,
buildAugmentWrapper(nodePath, contract.guardPath),
) &&
bytes.Equal(
auditWrapper.contents,
buildAugmentWrapper(nodePath, contract.auditPath),
), nil
} }
func augmentRuntimeFilesExist( func augmentRuntimeFilesExist(
contracts []augmentRuntimeContract, contracts []augmentRuntimeContract,
runtimeRoot string, runtimeRoot string,
) (bool, error) { ) (bool, error) {
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
if err != nil { if err != nil {
return false, err return false, err
} }
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
return false, err
}
for _, contract := range contracts { for _, contract := range contracts {
exists, err := augmentRuntimeContractExists(contract, runtimeRoot, nodePath) runtime := managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath}
exists, err := managedRuntimeContractExists(runtime, augmentAgentKey, "Auggie", nil)
if err != nil || !exists {
if err != nil { if err != nil {
return false, err return false, err
} }
if exists { continue
return true, nil }
matches, err := augmentWrappersMatch(contract, nodePath)
if err != nil || matches {
return matches, err
} }
} }
return false, nil return false, nil
@@ -54,7 +54,7 @@ func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) {
) )
failed := false failed := false
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameAugmentPath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") && strings.HasSuffix(source, ".tmp") &&
!failed { !failed {
failed = true failed = true
@@ -87,13 +87,14 @@ func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) {
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err) t.Fatalf("write concurrent settings: %v", err)
} }
err := writeAugmentChanges( err := writeManagedChanges(
changes, changes,
"Install Augment Code CLI hooks", "Install Augment Code CLI hooks",
nil, nil,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.configPath), filepath.Dir(document.configPath),
"Auggie configuration directory",
) )
if err == nil || !strings.Contains(err.Error(), "changed during installation") { if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err) t.Fatalf("prepared install error = %v", err)
@@ -375,7 +376,7 @@ func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
t.Fatalf("read installed settings: %v", err) t.Fatalf("read installed settings: %v", err)
} }
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameAugmentPath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Auggie uninstall failure") return errors.New("injected Auggie uninstall failure")
} }
+5 -4
View File
@@ -10,8 +10,7 @@ type ClaudeCodePlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Claude Code commits its provider guard with // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// the audit runtime and user settings document.
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool { func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
return true return true
} }
@@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeClaudeChanges( if err := writeManagedChanges(
changes, changes,
"Install Claude Code hooks", "Install Claude Code hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Claude Code configuration directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeClaudeChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Claude Code hooks", "Uninstall Claude Code hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Claude Code configuration directory",
) )
} }
+5 -17
View File
@@ -77,19 +77,7 @@ type claudeRenderedDocument struct {
remove bool remove bool
} }
type claudeRuntimeContract struct { type claudeRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func cloneClaudeObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func cloneClaudeHooks(source claudeHooks) claudeHooks { func cloneClaudeHooks(source claudeHooks) claudeHooks {
clone := make(claudeHooks, len(source)) clone := make(claudeHooks, len(source))
@@ -299,7 +287,7 @@ func validateClaudeHandler(
} }
} }
} }
return cloneClaudeObject(handler), nil return cloneJSONObject(handler), nil
} }
func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) { func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) {
@@ -332,7 +320,7 @@ func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup,
} }
handlers = append(handlers, handler) handlers = append(handlers, handler)
} }
return claudeGroup{object: cloneClaudeObject(object), handlers: handlers}, nil return claudeGroup{object: cloneJSONObject(object), handlers: handlers}, nil
} }
func readClaudeHooks(root map[string]any) (claudeHooks, error) { func readClaudeHooks(root map[string]any) (claudeHooks, error) {
@@ -411,7 +399,7 @@ func renderClaudeHooks(hooks claudeHooks) map[string]any {
for event, groups := range hooks { for event, groups := range hooks {
values := make([]any, 0, len(groups)) values := make([]any, 0, len(groups))
for _, group := range groups { for _, group := range groups {
object := cloneClaudeObject(group.object) object := cloneJSONObject(group.object)
handlers := make([]any, 0, len(group.handlers)) handlers := make([]any, 0, len(group.handlers))
for _, handler := range group.handlers { for _, handler := range group.handlers {
handlers = append(handlers, handler) handlers = append(handlers, handler)
@@ -439,7 +427,7 @@ func renderClaudeDocument(
document: document, changed: true, remove: true, document: document, changed: true, remove: true,
}, nil }, nil
} }
root := cloneClaudeObject(document.root) root := cloneJSONObject(document.root)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
} else { } else {
+18 -152
View File
@@ -1,182 +1,48 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type claudeRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateClaudeInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != claudeAgentKey {
return fmt.Errorf(
"Claude Code installation requires agent name %s",
claudeAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func claudeAgentPaths(config InstallConfig) (*claudeRuntimePaths, error) {
if err := validateClaudeInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &claudeRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, claudeGuardScript),
auditPath: filepath.Join(agentDirectory, claudeAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameClaudePath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameClaudePath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightClaudeInstallation( func preflightClaudeInstallation(
config InstallConfig, config InstallConfig,
document *claudeDocument, document *claudeDocument,
) (*claudeRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if document == nil || document.filePath == "" { if document == nil || document.filePath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf("Claude Code installation requires a user settings document")
"Claude Code installation requires a user settings document",
)
} }
paths, err := claudeAgentPaths(config) if err := validateManagedInstallConfig(config, claudeAgentKey, "Claude Code"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, claudeGuardScript, claudeAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateClaudeRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, claudeAgentKey, "Claude Code",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Claude Code")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := requireClaudeAbsoluteNode(nodePath); err != nil {
return nil, "", err
}
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildClaudeRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": claudeAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareClaudeInstallationChanges( func prepareClaudeInstallationChanges(
config InstallConfig, config InstallConfig,
paths *claudeRuntimePaths, paths *managedRuntimePaths,
rendered *claudeRenderedDocument, rendered *claudeRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildClaudeRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
if err != nil { config,
return nil, err paths,
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(claudeAgentKey, config.AgentID, "", false, "")),
0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
claudeAgentKey, claudeAgentKey,
config.AgentID, generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""),
"", buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true),
false, )
true,
)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
documentChange, err := prepareRenderedClaudeChange(rendered) documentChange, err := prepareRenderedClaudeChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
-35
View File
@@ -71,38 +71,3 @@ func prepareRenderedClaudeChange(
rendered.remove, rendered.remove,
) )
} }
func writeClaudeChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
settingsDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(
settingsDirectory,
"Claude Code configuration directory",
); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
+7 -76
View File
@@ -1,73 +1,18 @@
package plugins package plugins
import ( import (
"fmt"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings" "strings"
) )
type claudeRuntimeReference struct { type claudeRuntimeReference = managedScriptReference
agentID string
scriptPath string
}
func sameClaudePath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameClaudeAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func isClaudeNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func sameClaudeFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func claudeRuntimeReferenceForScript( func claudeRuntimeReferenceForScript(
scriptPath string, scriptPath string,
scriptName string, scriptName string,
) (*claudeRuntimeReference, error) { ) (*claudeRuntimeReference, error) {
if !filepath.IsAbs(scriptPath) || return resolveManagedScript(scriptPath, scriptName)
!sameClaudeFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil
}
agentDirectory := filepath.Dir(scriptPath)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
} }
func claudeLegacyReference( func claudeLegacyReference(
@@ -95,7 +40,7 @@ func managedClaudeReference(
handler["statusMessage"] == statusMessage { handler["statusMessage"] == statusMessage {
nodePath, nodeOK := handler["command"].(string) nodePath, nodeOK := handler["command"].(string)
args, argsOK := handler["args"].([]any) args, argsOK := handler["args"].([]any)
if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) && if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) &&
argsOK && len(args) == 1 { argsOK && len(args) == 1 {
if scriptPath, ok := args[0].(string); ok { if scriptPath, ok := args[0].(string); ok {
return claudeRuntimeReferenceForScript(scriptPath, scriptName) return claudeRuntimeReferenceForScript(scriptPath, scriptName)
@@ -146,7 +91,7 @@ func removeManagedClaudeGroups(
return nil, false, err return nil, false, err
} }
owned := reference != nil && owned := reference != nil &&
(agentID == "" || sameClaudeAgentID(reference.agentID, agentID)) (agentID == "" || sameManagedAgentID(reference.agentID, agentID))
if owned { if owned {
removed = true removed = true
continue continue
@@ -154,7 +99,7 @@ func removeManagedClaudeGroups(
kept = append(kept, handler) kept = append(kept, handler)
} }
if len(kept) > 0 || !exactManagedClaudeGroup(group) { if len(kept) > 0 || !exactManagedClaudeGroup(group) {
object := cloneClaudeObject(group.object) object := cloneJSONObject(group.object)
object["hooks"] = group.object["hooks"] object["hooks"] = group.object["hooks"]
result = append(result, claudeGroup{object: object, handlers: kept}) result = append(result, claudeGroup{object: object, handlers: kept})
} }
@@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool {
return handlerCount > 0 return handlerCount > 0
} }
func claudeReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func claudeReferencesForEvent( func claudeReferencesForEvent(
groups []claudeGroup, groups []claudeGroup,
scriptName string, scriptName string,
@@ -263,7 +201,7 @@ func claudeReferencesForEvent(
return nil, err return nil, err
} }
if reference != nil { if reference != nil {
key := claudeReferenceKey(reference.agentID) key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference) result[key] = append(result[key], *reference)
} }
} }
@@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
success := successes[key] success := successes[key]
failure := failures[key] failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameClaudePath(success[0].scriptPath, failure[0].scriptPath) { !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue continue
} }
contracts = append(contracts, claudeRuntimeContract{ contracts = append(contracts, claudeRuntimeContract{
@@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
} }
return contracts, nil return contracts, nil
} }
func requireClaudeAbsoluteNode(nodePath string) error {
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path")
}
return 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)
}
}
@@ -150,10 +150,10 @@ func TestClaudeValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
}, "query parameters"}, }, "query parameters"},
{"guard path", func(f *claudeFixture) { {"guard path", func(f *claudeFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
{"audit path", func(f *claudeFixture) { {"audit path", func(f *claudeFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
} }
for _, testCase := range tests { for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
+2 -228
View File
@@ -1,231 +1,5 @@
package plugins package plugins
import ( func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"fmt" return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil)
"path/filepath"
"sort"
"strings"
)
func readClaudeRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireClaudeRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateClaudeRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireClaudeRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireClaudeRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireClaudeRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameClaudeAgentID(agentID, expectedAgentID) ||
config["agent_name"] != claudeAgentKey {
return fmt.Errorf(
"elydora runtime identity does not match Claude Code hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireClaudeRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireClaudeRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateClaudeRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readClaudeRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, claudeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, claudeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != claudeAgentKey ||
!sameClaudeAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Claude Code agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func claudeRuntimeContractExists(contract claudeRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameClaudePath(
contract.auditPath,
filepath.Join(agentDirectory, claudeAuditScript),
) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readClaudeRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateClaudeRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
}
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
}
func claudeRuntimeFilesExist(contracts []claudeRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := claudeRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
@@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) {
} }
before := snapshotClaudeFiles(t, paths...) before := snapshotClaudeFiles(t, paths...)
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameClaudePath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Claude settings failure") return errors.New("injected Claude settings failure")
} }
@@ -114,13 +114,14 @@ func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) {
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err) t.Fatalf("write concurrent settings: %v", err)
} }
err = writeClaudeChanges( err = writeManagedChanges(
changes, changes,
"Install Claude Code hooks", "Install Claude Code hooks",
nil, nil,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Claude Code configuration directory",
) )
if err == nil || !strings.Contains(err.Error(), "changed during installation") { if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err) t.Fatalf("prepared install error = %v", err)
@@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
t.Fatalf("read installed settings: %v", err) t.Fatalf("read installed settings: %v", err)
} }
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameClaudePath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Claude uninstall failure") return errors.New("injected Claude uninstall failure")
} }
+1 -2
View File
@@ -7,8 +7,7 @@ type ClinePlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Cline commits both generated runtimes and // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// both native hook files in one transaction.
func (p *ClinePlugin) ManagesGuardRuntime() bool { func (p *ClinePlugin) ManagesGuardRuntime() bool {
return true return true
} }
+3 -42
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
@@ -80,37 +79,6 @@ func resolveClineHookFiles() (clineHookPaths, error) {
}, nil }, nil
} }
func sameClineAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func normalizeClinePath(value string) (string, error) {
absolute, err := filepath.Abs(value)
if err != nil {
return "", err
}
normalized := filepath.Clean(absolute)
if runtime.GOOS == "windows" {
normalized = strings.ToLower(normalized)
}
return normalized, nil
}
func sameClinePath(left, right string) (bool, error) {
normalizedLeft, err := normalizeClinePath(left)
if err != nil {
return false, err
}
normalizedRight, err := normalizeClinePath(right)
if err != nil {
return false, err
}
return normalizedLeft == normalizedRight, nil
}
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) { func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
metadata := clineHookMetadata{ metadata := clineHookMetadata{
Version: clineMetadataVersion, Version: clineMetadataVersion,
@@ -314,7 +282,7 @@ func clineContractForFiles(
if guard.Kind != "guard" || audit.Kind != "audit" { if guard.Kind != "guard" || audit.Kind != "audit" {
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata") return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
} }
if !sameClineAgentID(guard.AgentID, audit.AgentID) { if !sameManagedAgentID(guard.AgentID, audit.AgentID) {
return nil, fmt.Errorf("Elydora Cline hook files reference different agents") return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
} }
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID) agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
@@ -323,15 +291,8 @@ func clineContractForFiles(
} }
expectedGuard := filepath.Join(agentDirectory, clineGuardScript) expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
expectedAudit := filepath.Join(agentDirectory, clineAuditScript) expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard) if !sameManagedPath(guard.RuntimePath, expectedGuard) ||
if err != nil { !sameManagedPath(audit.RuntimePath, expectedAudit) {
return nil, fmt.Errorf("compare Cline guard runtime path: %w", err)
}
auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit)
if err != nil {
return nil, fmt.Errorf("compare Cline audit runtime path: %w", err)
}
if !guardMatches || !auditMatches {
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path") return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
} }
return &clineRuntimeContract{ return &clineRuntimeContract{
+20 -128
View File
@@ -1,97 +1,21 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type clineRuntimePaths struct { type clineRuntimePaths struct {
runtimeRoot string managedRuntimePaths
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
hooks clineHookPaths hooks clineHookPaths
} }
func validateClineInstallConfig(config InstallConfig) error { func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) {
for _, field := range []struct{ name, value string }{ if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil {
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != clineAgentKey {
return fmt.Errorf("Cline installation requires agent name %s", clineAgentKey)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func clineAgentPaths(
config InstallConfig,
hooks clineHookPaths,
) (*clineRuntimePaths, error) {
if err := validateClineInstallConfig(config); err != nil {
return nil, err return nil, err
} }
runtimeRoot, err := AgentRuntimeRoot() paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript)
if err != nil { if err != nil {
return nil, err return nil, err
} }
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil
if err != nil {
return nil, err
}
paths := &clineRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, hooks: hooks,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, clineGuardScript),
auditPath: filepath.Join(agentDirectory, clineAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameClinePathValue(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameClinePathValue(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func sameClinePathValue(left, right string) bool {
matches, err := sameClinePath(left, right)
return err == nil && matches
} }
func preflightClineInstallation( func preflightClineInstallation(
@@ -102,39 +26,20 @@ func preflightClineInstallation(
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
); err != nil {
return nil, err return nil, err
} }
nodePath, err := resolveNodeRuntime() if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil {
if err != nil {
return nil, err return nil, err
} }
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path")
}
return paths, nil return paths, nil
} }
func buildClineRuntimeConfig(config InstallConfig) ([]byte, error) { func clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
value := map[string]any{ return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")),
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, []byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true))
"base_url": config.BaseURL, "agent_name": clineAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
} }
func prepareClineInstallationChanges( func prepareClineInstallationChanges(
@@ -146,11 +51,9 @@ func prepareClineInstallationChanges(
if paths == nil { if paths == nil {
return nil, fmt.Errorf("prepared Cline installation is required") return nil, fmt.Errorf("prepared Cline installation is required")
} }
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
return nil, err paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
} ); err != nil {
runtimeConfig, err := buildClineRuntimeConfig(config)
if err != nil {
return nil, err return nil, err
} }
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath) guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
@@ -175,24 +78,13 @@ func prepareClineInstallationChanges(
); err != nil { ); err != nil {
return nil, err return nil, err
} }
runtimeItems := []struct { guardScript, auditScript := clineExpectedScripts(config.AgentID, nil)
path, label string changes, err := managedRuntimeFileChanges(
content []byte config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript),
mode os.FileMode )
}{
{paths.guardPath, "Elydora guard runtime", []byte(generateGuardScript(clineAgentKey, config.AgentID, "", false, "")), 0700},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{paths.auditPath, "Elydora audit runtime", []byte(buildHookScriptWithOutput(clineAgentKey, config.AgentID, "", false, true)), 0700},
}
changes := make([]*fileChange, 0, len(runtimeItems)+2)
for _, item := range runtimeItems {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
for _, item := range []struct { for _, item := range []struct {
file clineHookFile file clineHookFile
label string label string
+3 -12
View File
@@ -74,7 +74,7 @@ func writeClineHookChanges(
rename renameFunc, rename renameFunc,
paths clineHookPaths, paths clineHookPaths,
) error { ) error {
if !hasClineChanges(changes) { if !hasFileChanges(changes) {
return nil return nil
} }
if err := ensureManagedDirectory( if err := ensureManagedDirectory(
@@ -95,7 +95,7 @@ func writeClineChanges(
rename renameFunc, rename renameFunc,
paths *clineRuntimePaths, paths *clineRuntimePaths,
) error { ) error {
if !hasClineChanges(changes) { if !hasFileChanges(changes) {
return nil return nil
} }
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil { if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
@@ -107,15 +107,6 @@ func writeClineChanges(
return writeClineHookChanges(changes, label, rename, paths.hooks) return writeClineHookChanges(changes, label, rename, paths.hooks)
} }
func hasClineChanges(changes []*fileChange) bool {
for _, change := range changes {
if change != nil {
return true
}
}
return false
}
func prepareClineUninstallChanges( func prepareClineUninstallChanges(
files []clineHookFile, files []clineHookFile,
agentID string, agentID string,
@@ -123,7 +114,7 @@ func prepareClineUninstallChanges(
changes := make([]*fileChange, 0, len(files)) changes := make([]*fileChange, 0, len(files))
for _, file := range files { for _, file := range files {
if file.metadata == nil || if file.metadata == nil ||
(agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) { (agentID != "" && !sameManagedAgentID(file.metadata.AgentID, agentID)) {
continue continue
} }
if err := assertClineWrapperIntegrity(file); err != nil { if err := assertClineWrapperIntegrity(file); err != nil {
+7 -198
View File
@@ -1,206 +1,15 @@
package plugins package plugins
import ( import "path/filepath"
"bytes"
"fmt"
"path/filepath"
"sort"
"strings"
)
func readClineRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireClineRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateClineRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireClineRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireClineRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireClineRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameClineAgentID(agentID, expectedAgentID) || config["agent_name"] != clineAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Cline hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireClineRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireClineRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateClineRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readClineRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, clineGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, clineAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != clineAgentKey ||
!sameClineAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Cline agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) { func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot() if !sameManagedPath(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) {
if err != nil {
return false, err
}
if !sameClinePathValue(filepath.Dir(contract.agentDirectory), runtimeRoot) ||
!sameClinePathValue(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) ||
!sameClinePathValue(contract.auditPath, filepath.Join(contract.agentDirectory, clineAuditScript)) {
return false, nil return false, nil
} }
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory") return managedRuntimeContractExists(
if err != nil || !rootExists { managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath},
return false, err clineAgentKey,
} "Cline",
directoryExists, err := managedPhysicalDirectoryExists( clineExpectedScripts,
contract.agentDirectory,
"Elydora agent runtime directory",
) )
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(contract.agentDirectory, "config.json")
keyPath := filepath.Join(contract.agentDirectory, "private.key")
config, configExists, err := readClineRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateClineRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(clineAgentKey, contract.agentID, "", false, ""))
expectedAudit := []byte(buildHookScriptWithOutput(clineAgentKey, contract.agentID, "", false, true))
return bytes.Equal(guard.contents, expectedGuard) && bytes.Equal(audit.contents, expectedAudit), nil
} }
+6 -6
View File
@@ -10,14 +10,12 @@ type CodexPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Codex commits its provider guard with the // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// audit runtime and user hook document.
func (p *CodexPlugin) ManagesGuardRuntime() bool { func (p *CodexPlugin) ManagesGuardRuntime() bool {
return true return true
} }
// PreflightInstall validates every existing source before the CLI creates // PreflightInstall validates every source before the CLI creates runtime state.
// runtime state.
func (p *CodexPlugin) PreflightInstall(config InstallConfig) error { func (p *CodexPlugin) PreflightInstall(config InstallConfig) error {
document, err := readCodexDocument() document, err := readCodexDocument()
if err != nil { if err != nil {
@@ -61,13 +59,14 @@ func (p *CodexPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeCodexChanges( if err := writeManagedChanges(
changes, changes,
"Install Codex hooks", "Install Codex hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Codex hooks directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -90,13 +89,14 @@ func (p *CodexPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeCodexChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Codex hooks", "Uninstall Codex hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Codex hooks directory",
) )
} }
+16 -176
View File
@@ -1,53 +1,12 @@
package plugins package plugins
import ( import "path/filepath"
"encoding/base64"
"encoding/binary"
"os"
"path/filepath"
"runtime"
"strings"
"unicode/utf16"
)
func quoteCodexPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func codexPowerShellPath() string {
root := "C:\\Windows"
if runtime.GOOS == "windows" {
configured := os.Getenv("SystemRoot")
if filepath.IsAbs(configured) && !strings.ContainsAny(configured, "\"%\r\n") {
root = configured
}
}
return strings.TrimRight(root, `\/`) +
`\System32\WindowsPowerShell\v1.0\powershell.exe`
}
func encodeCodexPowerShell(source string) string {
runes := utf16.Encode([]rune(source))
raw := make([]byte, len(runes)*2)
for index, value := range runes {
binary.LittleEndian.PutUint16(raw[index*2:], value)
}
return base64.StdEncoding.EncodeToString(raw)
}
func codexWindowsCommand(runtimePath, scriptPath string) string {
source := "& " + quoteCodexPowerShell(runtimePath) + " " +
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE"
return `"` + codexPowerShellPath() +
`" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` +
encodeCodexPowerShell(source)
}
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any { func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
return map[string]any{ return map[string]any{
"type": "command", "type": "command",
"command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), "command": posixSource(runtimePath, scriptPath),
"commandWindows": codexWindowsCommand(runtimePath, scriptPath), "commandWindows": encodedWindowsCommand(runtimePath, scriptPath),
"timeout": codexHookTimeout, "timeout": codexHookTimeout,
"statusMessage": statusMessage, "statusMessage": statusMessage,
} }
@@ -57,124 +16,6 @@ func codexMatcherGroup(handler map[string]any) map[string]any {
return map[string]any{"matcher": "*", "hooks": []any{handler}} return map[string]any{"matcher": "*", "hooks": []any{handler}}
} }
func readCodexPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], codexPOSIXApostrophe) {
value.WriteByte('\'')
index += len(codexPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func parseCodexPOSIXCommand(value any) (string, string, bool) {
command, ok := value.(string)
if !ok {
return "", "", false
}
runtimePath, next, ok := readCodexPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
scriptPath, end, ok := readCodexPOSIXArgument(command, next+1)
return runtimePath, scriptPath, ok && end == len(command)
}
func readCodexPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func parseCodexPowerShellSource(source string) (string, string, bool) {
if !strings.HasPrefix(source, "& ") {
return "", "", false
}
runtimePath, next, ok := readCodexPowerShellArgument(source, 2)
if !ok || next >= len(source) || source[next] != ' ' {
return "", "", false
}
scriptPath, end, ok := readCodexPowerShellArgument(source, next+1)
return runtimePath, scriptPath, ok && source[end:] == "; exit $LASTEXITCODE"
}
func windowsPathBase(value string) string {
index := strings.LastIndexAny(value, `\/`)
return value[index+1:]
}
func isAbsoluteWindowsPath(value string) bool {
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') ||
(value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' &&
(value[2] == '\\' || value[2] == '/') || strings.HasPrefix(value, `\\`)
}
func decodeCodexPowerShell(encoded string) (string, bool) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded || len(raw)%2 != 0 {
return "", false
}
units := make([]uint16, len(raw)/2)
for index := range units {
units[index] = uint16(raw[index*2]) | uint16(raw[index*2+1])<<8
}
return string(utf16.Decode(units)), true
}
func parseCodexWindowsCommand(value any) (string, string, bool) {
command, ok := value.(string)
if !ok || !strings.HasPrefix(command, `"`) {
return "", "", false
}
closing := strings.IndexByte(command[1:], '"')
if closing < 0 {
return "", "", false
}
closing++
powerShell := command[1:closing]
prefix := `" -NoLogo -NoProfile -NonInteractive -EncodedCommand `
if !isAbsoluteWindowsPath(powerShell) ||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") ||
!strings.HasPrefix(command[closing:], prefix) {
return "", "", false
}
encoded := command[closing+len(prefix):]
source, ok := decodeCodexPowerShell(encoded)
if !ok {
return "", "", false
}
return parseCodexPowerShellSource(source)
}
func isCodexNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func exactCodexHandlerKeys(handler map[string]any) bool { func exactCodexHandlerKeys(handler map[string]any) bool {
if len(handler) != 5 { if len(handler) != 5 {
return false return false
@@ -192,20 +33,21 @@ func codexManagedScriptPath(handler map[string]any, status string) (string, bool
handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status { handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status {
return "", false return "", false
} }
posixRuntime, posixScript, posixOK := parseCodexPOSIXCommand(handler["command"]) command, _ := handler["command"].(string)
windowsRuntime, windowsScript, windowsOK := parseCodexWindowsCommand(handler["commandWindows"]) commandWindows, _ := handler["commandWindows"].(string)
posixRuntime, posixScript, posixOK := parsePOSIXCommand(command)
windowsRuntime, windowsScript, windowsOK := parseEncodedWindowsCommand(commandWindows)
if !windowsOK && posixOK { if !windowsOK && posixOK {
legacy, ok := handler["commandWindows"].(string)
expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript) expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript)
if ok && legacy == expected { if commandWindows == expected {
windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true
} }
} }
if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) || if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) ||
!filepath.IsAbs(posixScript) || !isCodexNodeExecutable(posixRuntime) || !filepath.IsAbs(posixScript) || !isNodeExecutable(posixRuntime) ||
!isCodexNodeExecutable(windowsRuntime) || !isNodeExecutable(windowsRuntime) ||
!sameCodexPath(posixRuntime, windowsRuntime) || !sameManagedPath(posixRuntime, windowsRuntime) ||
!sameCodexPath(posixScript, windowsScript) { !sameManagedPath(posixScript, windowsScript) {
return "", false return "", false
} }
return posixScript, true return posixScript, true
@@ -217,14 +59,12 @@ func codexManagedAgentID(
status string, status string,
) (string, bool) { ) (string, bool) {
scriptPath, managed := codexManagedScriptPath(handler, status) scriptPath, managed := codexManagedScriptPath(handler, status)
if !managed || filepath.Base(scriptPath) != scriptName { if !managed {
return "", false return "", false
} }
agentDirectory := filepath.Dir(scriptPath) reference, err := resolveManagedScript(scriptPath, scriptName)
runtimeRoot, err := AgentRuntimeRoot() if err != nil || reference == nil {
if err != nil || !sameCodexPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) return reference.agentID, true
return agentID, agentID != "" && agentID != "." && agentID != ".."
} }
+5 -56
View File
@@ -6,9 +6,7 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime"
"sort" "sort"
"strings"
) )
const ( const (
@@ -20,7 +18,6 @@ const (
codexOwnedDescription = "Elydora audit and freeze enforcement" codexOwnedDescription = "Elydora audit and freeze enforcement"
codexGuardStatusMessage = "Checking Elydora agent state" codexGuardStatusMessage = "Checking Elydora agent state"
codexAuditStatusMessage = "Recording Elydora tool use" codexAuditStatusMessage = "Recording Elydora tool use"
codexPOSIXApostrophe = `'"'"'`
) )
type codexHooks map[string][]map[string]any type codexHooks map[string][]map[string]any
@@ -40,19 +37,7 @@ type codexRenderedDocument struct {
remove bool remove bool
} }
type codexRuntimeContract struct { type codexRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func cloneCodexObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func cloneCodexHooks(source codexHooks) codexHooks { func cloneCodexHooks(source codexHooks) codexHooks {
clone := make(codexHooks, len(source)) clone := make(codexHooks, len(source))
@@ -62,28 +47,6 @@ func cloneCodexHooks(source codexHooks) codexHooks {
return clone return clone
} }
func sameCodexPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCodexAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func readCodexHooks(value any, label string) (codexHooks, error) { func readCodexHooks(value any, label string) (codexHooks, error) {
object, ok := value.(map[string]any) object, ok := value.(map[string]any)
if !ok { if !ok {
@@ -126,16 +89,6 @@ func readCodexHooks(value any, label string) (codexHooks, error) {
return hooks, nil return hooks, nil
} }
func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) {
if !json.Valid(source) {
var value any
if err := json.Unmarshal(source, &value); err != nil {
return nil, fmt.Errorf("parse %s: %w", label, err)
}
}
return decodeJSONCObject(source, label, false)
}
func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) { func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) {
label := fmt.Sprintf("Codex user hooks at %s", filePath) label := fmt.Sprintf("Codex user hooks at %s", filePath)
root, err := decodeStrictJSONObject(raw, label) root, err := decodeStrictJSONObject(raw, label)
@@ -180,13 +133,13 @@ func removeCodexGroups(
for _, value := range handlers { for _, value := range handlers {
handler := value.(map[string]any) handler := value.(map[string]any)
managedID, managed := codexManagedAgentID(handler, scriptName, status) managedID, managed := codexManagedAgentID(handler, scriptName, status)
if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) { if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
continue continue
} }
kept = append(kept, handler) kept = append(kept, handler)
} }
if len(kept) > 0 || !exactCodexMatcherGroup(group) { if len(kept) > 0 || !exactCodexMatcherGroup(group) {
next := cloneCodexObject(group) next := cloneJSONObject(group)
next["hooks"] = kept next["hooks"] = kept
result = append(result, next) result = append(result, next)
} }
@@ -262,7 +215,7 @@ func renderCodexDocument(
if len(hooks) == 0 && entirelyManagedCodexDocument(document) { if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
} }
root := cloneCodexObject(document.root) root := cloneJSONObject(document.root)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
} else { } else {
@@ -295,11 +248,7 @@ func managedCodexIDs(
if !managed { if !managed {
continue continue
} }
key := agentID result[managedReferenceKey(agentID)] = agentID
if runtime.GOOS == "windows" {
key = strings.ToLower(agentID)
}
result[key] = agentID
} }
} }
return result return result
+17 -128
View File
@@ -1,94 +1,26 @@
package plugins package plugins
import ( import "path/filepath"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type codexRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateCodexInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != codexAgentKey {
return fmt.Errorf("codex installation requires agent name %s", codexAgentKey)
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
if err := validateCodexInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &codexRuntimePaths{
runtimeRoot: runtimeRoot,
agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, codexGuardScript),
auditPath: filepath.Join(agentDirectory, codexAuditScript),
}
if !sameCodexPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && !sameCodexPath(config.HookScript, paths.auditPath) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightCodexInstallation( func preflightCodexInstallation(
config InstallConfig, config InstallConfig,
hooksPath string, hooksPath string,
) (*codexRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
paths, err := codexAgentPaths(config) if err := validateManagedInstallConfig(config, codexAgentKey, "Codex"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, codexGuardScript, codexAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if _, err := managedPhysicalDirectoryExists( if _, err := managedPhysicalDirectoryExists(
filepath.Dir(hooksPath), filepath.Dir(hooksPath), "Codex hooks directory",
"Codex hooks directory",
); err != nil { ); err != nil {
return nil, "", err return nil, "", err
} }
if err := validateCodexRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, codexAgentKey, "Codex",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveNodeRuntime()
@@ -98,64 +30,21 @@ func preflightCodexInstallation(
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildCodexRuntimeConfig(config InstallConfig) ([]byte, error) {
encoded, err := json.MarshalIndent(agentRuntimeConfig{
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID,
BaseURL: config.BaseURL, Token: config.Token, AgentName: codexAgentKey,
}, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareCodexInstallationChanges( func prepareCodexInstallationChanges(
config InstallConfig, config InstallConfig,
paths *codexRuntimePaths, paths *managedRuntimePaths,
rendered *codexRenderedDocument, rendered *codexRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildCodexRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
if err != nil { config,
return nil, err paths,
} codexAgentKey,
items := []struct { generateGuardScript(codexAgentKey, config.AgentID, "", false, ""),
path, label string buildHookScriptWithOutput(codexAgentKey, config.AgentID, "", false, true),
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(codexAgentKey, config.AgentID, "", false, "")), 0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
codexAgentKey, config.AgentID, "", false, true,
)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(
item.path,
item.label,
item.content,
item.mode,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
documentChange, err := prepareRenderedCodexChange(rendered) documentChange, err := prepareRenderedCodexChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
+2 -148
View File
@@ -82,152 +82,6 @@ func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, e
) )
} }
func writeCodexChanges( func codexRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
changes []*fileChange, return managedRuntimeFilesPresent(contracts, codexAgentKey)
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
hooksDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(hooksDirectory, "Codex hooks directory"); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
func readCodexRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func validateCodexRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readCodexRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, codexGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, codexAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != codexAgentKey ||
!sameCodexAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Codex agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func codexRuntimeFilesExist(contracts []codexRuntimeContract) (bool, error) {
for _, contract := range contracts {
agentDirectory := filepath.Dir(contract.guardPath)
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil {
return false, err
}
if !directoryExists {
continue
}
config, exists, err := readCodexRuntimeConfig(
filepath.Join(agentDirectory, "config.json"),
)
if err != nil {
return false, err
}
configuredID, idOK := config["agent_id"].(string)
if !exists || !idOK || config["agent_name"] != codexAgentKey ||
!sameCodexAgentID(configuredID, contract.agentID) {
continue
}
complete := true
for _, item := range []struct {
path string
label string
limit int64
}{
{contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes},
{contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return false, inspectErr
}
complete = complete && exists
}
if complete {
return true, nil
}
}
return false, nil
} }
+1 -1
View File
@@ -249,7 +249,7 @@ func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) {
source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`) source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`)
fixture := prepareCodexFixture(t, string(source)) fixture := prepareCodexFixture(t, string(source))
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameCodexPath(destination, fixture.configPath) { if sameManagedPath(destination, fixture.configPath) {
return errors.New("injected Codex hooks commit failure") return errors.New("injected Codex hooks commit failure")
} }
return os.Rename(source, destination) return os.Rename(source, destination)
+3 -3
View File
@@ -355,10 +355,10 @@ func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 { if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("unexpected remaining hooks: %#v", hooks) t.Fatalf("unexpected remaining hooks: %#v", hooks)
} }
_, remainingScript, parsed := parseCodexPOSIXCommand( _, remainingScript, parsed := parsePOSIXCommand(
requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"], requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string),
) )
if !parsed || !sameCodexPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) { if !parsed || !sameManagedPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) {
t.Fatalf("other agent handler was removed: %#v", hooks) t.Fatalf("other agent handler was removed: %#v", hooks)
} }
} }
+3 -5
View File
@@ -10,14 +10,12 @@ type CopilotPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Copilot commits its provider guard with the // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// audit runtime and hook documents.
func (p *CopilotPlugin) ManagesGuardRuntime() bool { func (p *CopilotPlugin) ManagesGuardRuntime() bool {
return true return true
} }
// PreflightInstall validates hook sources, settings, matchers, credentials, // PreflightInstall validates every source before the CLI creates runtime state.
// and runtime identity before any write.
func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error { func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
sources, _, err := readCopilotSources() sources, _, err := readCopilotSources()
if err != nil { if err != nil {
@@ -165,7 +163,7 @@ func mergedCopilotContracts(
} }
unique := map[string]copilotRuntimeContract{} unique := map[string]copilotRuntimeContract{}
for _, contract := range contracts { for _, contract := range contracts {
unique[copilotEntryKey(contract.agentID)] = contract unique[managedReferenceKey(contract.agentID)] = contract
} }
result := make([]copilotRuntimeContract, 0, len(unique)) result := make([]copilotRuntimeContract, 0, len(unique))
for _, contract := range unique { for _, contract := range unique {
+20 -156
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings" "strings"
) )
@@ -17,7 +16,6 @@ const (
copilotConfigFile = "elydora-audit.json" copilotConfigFile = "elydora-audit.json"
copilotHookTimeout = float64(10) copilotHookTimeout = float64(10)
copilotLegacyTimeout = float64(5) copilotLegacyTimeout = float64(5)
copilotPOSIXApostrophe = `'"'"'`
) )
var copilotManagedEvents = []struct { var copilotManagedEvents = []struct {
@@ -61,23 +59,19 @@ type copilotRenderedDocument struct {
remove bool remove bool
} }
type copilotRuntimeContract struct { type copilotRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
type copilotManagedEntry struct { type copilotManagedEntry = managedScriptReference
agentID string
scriptPath string
}
func cloneCopilotObject(value map[string]any) map[string]any { func copilotManagedEntryForHandler(
clone := make(map[string]any, len(value)) handler map[string]any,
for key, item := range value { scriptName string,
clone[key] = item ) (*copilotManagedEntry, error) {
scriptPath, managed := copilotManagedScriptPath(handler)
if !managed {
return nil, nil
} }
return clone return resolveManagedScript(scriptPath, scriptName)
} }
func cloneCopilotHooks(source copilotHooks) copilotHooks { func cloneCopilotHooks(source copilotHooks) copilotHooks {
@@ -90,109 +84,15 @@ func cloneCopilotHooks(source copilotHooks) copilotHooks {
return clone return clone
} }
func sameCopilotPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCopilotAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCopilotFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func quoteCopilotPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func buildCopilotHandler(nodePath, scriptPath string) map[string]any { func buildCopilotHandler(nodePath, scriptPath string) map[string]any {
return map[string]any{ return map[string]any{
"type": "command", "type": "command",
"bash": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath), "bash": posixSource(nodePath, scriptPath),
"powershell": "& " + quoteCopilotPowerShell(nodePath) + " " + quoteCopilotPowerShell(scriptPath) + "; exit $LASTEXITCODE", "powershell": powerShellSource(nodePath, scriptPath),
"timeoutSec": copilotHookTimeout, "timeoutSec": copilotHookTimeout,
} }
} }
func readCopilotPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], copilotPOSIXApostrophe) {
value.WriteByte('\'')
index += len(copilotPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func readCopilotPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func parseCopilotBash(command string) (string, string, bool) {
executable, next, ok := readCopilotPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCopilotPOSIXArgument(command, next+1)
return executable, script, ok && end == len(command)
}
func parseCopilotPowerShell(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
executable, next, ok := readCopilotPowerShellArgument(command, 2)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCopilotPowerShellArgument(command, next+1)
return executable, script, ok && command[end:] == "; exit $LASTEXITCODE"
}
func parseCopilotLegacyCommand(command string) (string, bool) { func parseCopilotLegacyCommand(command string) (string, bool) {
command = strings.TrimSpace(command) command = strings.TrimSpace(command)
separator := strings.IndexAny(command, " \t") separator := strings.IndexAny(command, " \t")
@@ -211,11 +111,6 @@ func parseCopilotLegacyCommand(command string) (string, bool) {
return script, script != "" return script, script != ""
} }
func isCopilotNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func copilotManagedScriptPath(handler map[string]any) (string, bool) { func copilotManagedScriptPath(handler map[string]any) (string, bool) {
if len(handler) != 4 || handler["type"] != "command" { if len(handler) != 4 || handler["type"] != "command" {
return "", false return "", false
@@ -226,11 +121,11 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
return "", false return "", false
} }
if handler["timeoutSec"] == copilotHookTimeout { if handler["timeoutSec"] == copilotHookTimeout {
bashNode, bashScript, bashParsed := parseCopilotBash(bash) bashNode, bashScript, bashParsed := parsePOSIXCommand(bash)
psNode, psScript, psParsed := parseCopilotPowerShell(powershell) psNode, psScript, psParsed := parsePowerShellSource(powershell)
if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) && if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) &&
isCopilotNodeExecutable(bashNode) && sameCopilotPath(bashNode, psNode) && isNodeExecutable(bashNode) && sameManagedPath(bashNode, psNode) &&
sameCopilotPath(bashScript, psScript) { sameManagedPath(bashScript, psScript) {
return bashScript, true return bashScript, true
} }
} }
@@ -240,30 +135,6 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
return parseCopilotLegacyCommand(bash) return parseCopilotLegacyCommand(bash)
} }
func copilotManagedEntryForHandler(
handler map[string]any,
scriptName string,
) (*copilotManagedEntry, error) {
scriptPath, managed := copilotManagedScriptPath(handler)
if !managed || !filepath.IsAbs(scriptPath) ||
!sameCopilotFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory := filepath.Dir(scriptPath)
if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &copilotManagedEntry{agentID: agentID, scriptPath: scriptPath}, nil
}
func removeManagedCopilotHooks( func removeManagedCopilotHooks(
hooks copilotHooks, hooks copilotHooks,
agentID string, agentID string,
@@ -277,7 +148,7 @@ func removeManagedCopilotHooks(
return nil, err return nil, err
} }
remove := entry != nil && remove := entry != nil &&
(agentID == "" || sameCopilotAgentID(entry.agentID, agentID)) (agentID == "" || sameManagedAgentID(entry.agentID, agentID))
if !remove { if !remove {
kept = append(kept, handler) kept = append(kept, handler)
} }
@@ -361,7 +232,7 @@ func renderCopilotDocument(
document: document, changed: true, remove: true, document: document, changed: true, remove: true,
}, nil }, nil
} }
root := cloneCopilotObject(document.root) root := cloneJSONObject(document.root)
root["version"] = float64(1) root["version"] = float64(1)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
@@ -387,13 +258,6 @@ func renderCopilotDocument(
}, nil }, nil
} }
func copilotEntryKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func copilotManagedEntries( func copilotManagedEntries(
handlers []map[string]any, handlers []map[string]any,
scriptName string, scriptName string,
@@ -405,7 +269,7 @@ func copilotManagedEntries(
return nil, err return nil, err
} }
if entry != nil { if entry != nil {
key := copilotEntryKey(entry.agentID) key := managedReferenceKey(entry.agentID)
result[key] = append(result[key], *entry) result[key] = append(result[key], *entry)
} }
} }
@@ -438,7 +302,7 @@ func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, erro
success := successes[key] success := successes[key]
failure := failures[key] failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) { !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue continue
} }
contracts = append(contracts, copilotRuntimeContract{ contracts = append(contracts, copilotRuntimeContract{
+20 -142
View File
@@ -1,103 +1,19 @@
package plugins package plugins
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings"
) )
type copilotRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
type preparedCopilotInstallation struct { type preparedCopilotInstallation struct {
changes []*fileChange changes []*fileChange
preconditions []filePrecondition preconditions []filePrecondition
paths *copilotRuntimePaths paths *managedRuntimePaths
}
func validateCopilotInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != copilotAgentKey {
return fmt.Errorf(
"GitHub Copilot CLI installation requires agent name %s",
copilotAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func copilotAgentPaths(config InstallConfig) (*copilotRuntimePaths, error) {
if err := validateCopilotInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &copilotRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, copilotGuardScript),
auditPath: filepath.Join(agentDirectory, copilotAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameCopilotPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameCopilotPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
} }
func preflightCopilotInstallation( func preflightCopilotInstallation(
config InstallConfig, config InstallConfig,
sources *copilotSources, sources *copilotSources,
) (*copilotRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if sources == nil || sources.user == nil || sources.user.filePath == "" { if sources == nil || sources.user == nil || sources.user.filePath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf(
"GitHub Copilot CLI installation requires a user hook path", "GitHub Copilot CLI installation requires a user hook path",
@@ -106,22 +22,22 @@ func preflightCopilotInstallation(
if err := requireCopilotHooksEnabled(sources); err != nil { if err := requireCopilotHooksEnabled(sources); err != nil {
return nil, "", err return nil, "", err
} }
paths, err := copilotAgentPaths(config) if err := validateManagedInstallConfig(config, copilotAgentKey, "GitHub Copilot CLI"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, copilotGuardScript, copilotAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateCopilotRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, copilotAgentKey, "Copilot",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("GitHub Copilot CLI")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if !filepath.IsAbs(nodePath) || !isCopilotNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"GitHub Copilot CLI hooks require an absolute Node.js executable path",
)
}
hookSources := []copilotHooks{sources.user.hooks} hookSources := []copilotHooks{sources.user.hooks}
if sources.legacy != nil { if sources.legacy != nil {
hookSources = append(hookSources, sources.legacy.hooks) hookSources = append(hookSources, sources.legacy.hooks)
@@ -132,28 +48,6 @@ func preflightCopilotInstallation(
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildCopilotRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": copilotAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func validateCopilotRenderedSet( func validateCopilotRenderedSet(
sources *copilotSources, sources *copilotSources,
rendered []*copilotRenderedDocument, rendered []*copilotRenderedDocument,
@@ -169,7 +63,7 @@ func validateCopilotRenderedSet(
matches := 0 matches := 0
for _, item := range rendered { for _, item := range rendered {
if item != nil && item.document != nil && if item != nil && item.document != nil &&
sameCopilotPath(item.document.filePath, document.filePath) { sameManagedPath(item.document.filePath, document.filePath) {
matches++ matches++
} }
} }
@@ -218,34 +112,13 @@ func prepareCopilotInstallation(
if err := validateCopilotRenderedSet(sources, rendered); err != nil { if err := validateCopilotRenderedSet(sources, rendered); err != nil {
return nil, err return nil, err
} }
runtimeConfig, err := buildCopilotRuntimeConfig(config) guardScript, auditScript := copilotExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, copilotAgentKey, string(guardScript), string(auditScript),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(copilotAgentKey, config.AgentID, "", false, "")), 0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(copilotAgentKey, config.AgentID, "", false, true)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
for _, document := range rendered { for _, document := range rendered {
change, changeErr := prepareRenderedCopilotChange(document) change, changeErr := prepareRenderedCopilotChange(document)
if changeErr != nil { if changeErr != nil {
@@ -275,3 +148,8 @@ func commitCopilotInstallation(
prepared.preconditions, prepared.preconditions,
) )
} }
func copilotExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
return []byte(generateGuardScript(copilotAgentKey, agentID, "", false, "")),
[]byte(buildHookScriptWithOutput(copilotAgentKey, agentID, "", false, true))
}
+1 -8
View File
@@ -240,14 +240,7 @@ func writeCopilotChanges(
agentDirectory string, agentDirectory string,
preconditions []filePrecondition, preconditions []filePrecondition,
) error { ) error {
hasChanges := false if !hasFileChanges(changes) {
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return writeChanges(changes, label, rename, preconditions...) return writeChanges(changes, label, rename, preconditions...)
} }
if agentDirectory != "" { if agentDirectory != "" {
+1 -1
View File
@@ -274,7 +274,7 @@ func validateCopilotHooks(value any, label string) (copilotHooks, error) {
if err := validateCopilotHandler(handler, event, itemLabel); err != nil { if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
return nil, err return nil, err
} }
handlers = append(handlers, cloneCopilotObject(handler)) handlers = append(handlers, cloneJSONObject(handler))
} }
hooks[event] = handlers hooks[event] = handlers
} }
+2 -258
View File
@@ -1,261 +1,5 @@
package plugins package plugins
import ( func copilotRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"bytes" return managedRuntimeFilesExist(contracts, copilotAgentKey, "Copilot", copilotExpectedScripts)
"fmt"
"path/filepath"
"sort"
"strings"
)
func readCopilotRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireCopilotRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateCopilotRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireCopilotRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireCopilotRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireCopilotRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameCopilotAgentID(agentID, expectedAgentID) ||
config["agent_name"] != copilotAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Copilot hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireCopilotRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireCopilotRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateCopilotRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, copilotGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, copilotAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != copilotAgentKey ||
!sameCopilotAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Copilot agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func validCopilotContractPaths(contract copilotRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
return sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) &&
sameCopilotPath(
contract.guardPath,
filepath.Join(agentDirectory, copilotGuardScript),
) &&
sameCopilotPath(
contract.auditPath,
filepath.Join(agentDirectory, copilotAuditScript),
), nil
}
func copilotRuntimeContractExists(contract copilotRuntimeContract) (bool, error) {
validPaths, err := validCopilotContractPaths(contract)
if err != nil || !validPaths {
return false, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateCopilotRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(
copilotAgentKey,
contract.agentID,
"",
false,
"",
))
expectedAudit := []byte(buildHookScriptWithOutput(
copilotAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := copilotRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
+26 -54
View File
@@ -10,55 +10,27 @@ type CursorPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Cursor commits its provider-specific guard // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// in the same transaction as the rest of the installation.
func (p *CursorPlugin) ManagesGuardRuntime() bool { func (p *CursorPlugin) ManagesGuardRuntime() bool {
return true return true
} }
func cursorAgentPaths(config InstallConfig) ( func cursorAgentPaths(config InstallConfig) (*managedRuntimePaths, error) {
runtimeRoot, agentDirectory, guardPath, auditPath string, return resolveManagedRuntimePaths(config, cursorGuardScript, cursorAuditScript)
err error,
) {
if config.AgentID == "" {
return "", "", "", "", fmt.Errorf("agent ID is required")
}
runtimeRoot, err = AgentRuntimeRoot()
if err != nil {
return "", "", "", "", err
}
agentDirectory, err = ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return "", "", "", "", err
}
guardPath = filepath.Join(agentDirectory, cursorGuardScript)
if !sameCursorPath(config.GuardScriptPath, guardPath) {
return "", "", "", "", fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
guardPath,
)
}
auditPath = filepath.Join(agentDirectory, cursorAuditScript)
if config.HookScript != "" && !sameCursorPath(config.HookScript, auditPath) {
return "", "", "", "", fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
auditPath,
)
}
return runtimeRoot, agentDirectory, guardPath, auditPath, nil
} }
// PreflightInstall validates every existing source before the CLI creates a // PreflightInstall validates every source before the CLI creates runtime state.
// runtime directory or guard file.
func (p *CursorPlugin) PreflightInstall(config InstallConfig) error { func (p *CursorPlugin) PreflightInstall(config InstallConfig) error {
if _, err := readCursorDocument(); err != nil { if _, err := readCursorDocument(); err != nil {
return err return err
} }
_, agentDirectory, _, _, err := cursorAgentPaths(config) paths, err := cursorAgentPaths(config)
if err != nil { if err != nil {
return err return err
} }
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
); err != nil {
return err return err
} }
_, err = resolveNodeRuntime() _, err = resolveNodeRuntime()
@@ -70,44 +42,42 @@ func (p *CursorPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config) paths, err := cursorAgentPaths(config)
if err != nil { if err != nil {
return err return err
} }
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
); err != nil {
return err return err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveNodeRuntime()
if err != nil { if err != nil {
return err return err
} }
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "") hooks := removeManagedCursorHooks(document.hooks, paths.runtimeRoot, "")
hooks["preToolUse"] = append( hooks["preToolUse"] = append(hooks["preToolUse"], buildCursorHandler(nodePath, paths.guardPath))
hooks["preToolUse"], hooks["postToolUse"] = append(hooks["postToolUse"], buildCursorHandler(nodePath, paths.auditPath))
buildCursorHandler(nodePath, guardPath),
)
hooks["postToolUse"] = append(
hooks["postToolUse"],
buildCursorHandler(nodePath, auditPath),
)
hooks["postToolUseFailure"] = append( hooks["postToolUseFailure"] = append(
hooks["postToolUseFailure"], hooks["postToolUseFailure"],
buildCursorHandler(nodePath, auditPath), buildCursorHandler(nodePath, paths.auditPath),
) )
rendered, err := renderCursorDocument(document, hooks, runtimeRoot) rendered, err := renderCursorDocument(document, hooks, paths.runtimeRoot)
if err != nil { if err != nil {
return fmt.Errorf("render Cursor user hooks: %w", err) return fmt.Errorf("render Cursor user hooks: %w", err)
} }
changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered) changes, err := prepareCursorInstallationChanges(config, paths, rendered)
if err != nil { if err != nil {
return err return err
} }
if err := writeCursorChanges( if err := writeManagedChanges(
changes, changes,
"Install Cursor hooks", "Install Cursor hooks",
p.rename, p.rename,
runtimeRoot, paths.runtimeRoot,
agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath),
"Cursor hooks directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -133,12 +103,14 @@ func (p *CursorPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeCursorChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Cursor hooks", "Uninstall Cursor hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath),
"Cursor hooks directory",
) )
} }
+9 -111
View File
@@ -16,7 +16,6 @@ const (
cursorGuardScript = "guard.js" cursorGuardScript = "guard.js"
cursorAuditScript = "hook.js" cursorAuditScript = "hook.js"
cursorHookTimeout = float64(10) cursorHookTimeout = float64(10)
cursorPOSIXApostrophe = `'"'"'`
) )
type cursorHooks map[string][]map[string]any type cursorHooks map[string][]map[string]any
@@ -36,11 +35,7 @@ type cursorRenderedDocument struct {
remove bool remove bool
} }
type cursorRuntimeContract struct { type cursorRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func cloneCursorHooks(source cursorHooks) cursorHooks { func cloneCursorHooks(source cursorHooks) cursorHooks {
clone := make(cursorHooks, len(source)) clone := make(cursorHooks, len(source))
@@ -50,104 +45,16 @@ func cloneCursorHooks(source cursorHooks) cursorHooks {
return clone return clone
} }
func sameCursorPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCursorAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func quoteCursorPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func buildCursorHandler(nodePath, scriptPath string) map[string]any { func buildCursorHandler(nodePath, scriptPath string) map[string]any {
command := quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) command := posixSource(nodePath, scriptPath)
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
command = "& " + quoteCursorPowerShell(nodePath) + " " + command = powerShellSource(nodePath, scriptPath)
quoteCursorPowerShell(scriptPath) + "; exit $LASTEXITCODE"
} }
return map[string]any{ return map[string]any{
"command": command, "timeout": cursorHookTimeout, "failClosed": true, "command": command, "timeout": cursorHookTimeout, "failClosed": true,
} }
} }
func readCursorPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], cursorPOSIXApostrophe) {
value.WriteByte('\'')
index += len(cursorPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func readCursorPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func parseCursorPOSIXCommand(command string) (string, string, bool) {
executable, next, ok := readCursorPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCursorPOSIXArgument(command, next+1)
return executable, script, ok && end == len(command)
}
func parseCursorPowerShellCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
executable, next, ok := readCursorPowerShellArgument(command, 2)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCursorPowerShellArgument(command, next+1)
return executable, script, ok && command[end:] == "; exit $LASTEXITCODE"
}
func parseCursorLegacyCommand(command string) (string, bool) { func parseCursorLegacyCommand(command string) (string, bool) {
separator := strings.IndexByte(command, ' ') separator := strings.IndexByte(command, ' ')
if separator < 0 { if separator < 0 {
@@ -164,11 +71,6 @@ func parseCursorLegacyCommand(command string) (string, bool) {
return script, script != "" && !strings.ContainsAny(script, "\r\n") return script, script != "" && !strings.ContainsAny(script, "\r\n")
} }
func isCursorNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func cursorManagedScriptPath(handler map[string]any) (string, bool) { func cursorManagedScriptPath(handler map[string]any) (string, bool) {
if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true { if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true {
command, ok := handler["command"].(string) command, ok := handler["command"].(string)
@@ -178,12 +80,12 @@ func cursorManagedScriptPath(handler map[string]any) (string, bool) {
var nodePath, scriptPath string var nodePath, scriptPath string
var parsed bool var parsed bool
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
nodePath, scriptPath, parsed = parseCursorPowerShellCommand(command) nodePath, scriptPath, parsed = parsePowerShellSource(command)
} else { } else {
nodePath, scriptPath, parsed = parseCursorPOSIXCommand(command) nodePath, scriptPath, parsed = parsePOSIXCommand(command)
} }
if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) && if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) &&
isCursorNodeExecutable(nodePath) { isNodeExecutable(nodePath) {
return scriptPath, true return scriptPath, true
} }
} }
@@ -207,7 +109,7 @@ func cursorManagedAgentID(
return "", false return "", false
} }
agentDirectory := filepath.Dir(scriptPath) agentDirectory := filepath.Dir(scriptPath)
if !sameCursorPath(filepath.Dir(agentDirectory), runtimeRoot) { if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) agentID := filepath.Base(agentDirectory)
@@ -297,7 +199,7 @@ func removeManagedCursorHooks(hooks cursorHooks, runtimeRoot, agentID string) cu
kept := make([]map[string]any, 0, len(result[contract.event])) kept := make([]map[string]any, 0, len(result[contract.event]))
for _, handler := range result[contract.event] { for _, handler := range result[contract.event] {
managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot) managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot)
if managed && (agentID == "" || sameCursorAgentID(managedID, agentID)) { if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
continue continue
} }
kept = append(kept, handler) kept = append(kept, handler)
@@ -384,11 +286,7 @@ func managedCursorIDs(
if !managed { if !managed {
continue continue
} }
key := agentID result[managedReferenceKey(agentID)] = agentID
if runtime.GOOS == "windows" {
key = strings.ToLower(agentID)
}
result[key] = agentID
} }
return result return result
} }
+19 -264
View File
@@ -1,13 +1,9 @@
package plugins package plugins
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings"
) )
func cursorConfigPath() (string, error) { func cursorConfigPath() (string, error) {
@@ -27,21 +23,14 @@ func readCursorDocument() (*cursorDocument, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
info, err := os.Lstat(filePath) snapshot, err := readManagedFile(filePath, "Cursor user hooks", maxManagedSourceBytes)
if errors.Is(err, os.ErrNotExist) { if err != nil {
return nil, err
}
if snapshot == nil {
return createCursorDocument(filePath), nil return createCursorDocument(filePath), nil
} }
if err != nil { return parseCursorDocument(filePath, snapshot.contents, runtimeRoot)
return nil, fmt.Errorf("inspect Cursor user hooks at %s: %w", filePath, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, fmt.Errorf("Cursor user hooks path is not a physical file: %s", filePath)
}
raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is the fixed Cursor user configuration path.
if err != nil {
return nil, fmt.Errorf("read Cursor user hooks at %s: %w", filePath, err)
}
return parseCursorDocument(filePath, raw, runtimeRoot)
} }
func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) { func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) {
@@ -59,168 +48,26 @@ func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange,
) )
} }
func cursorPhysicalFileExists(path, label string) (bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return false, fmt.Errorf("%s path is not a physical file: %s", label, path)
}
return true, nil
}
func cursorPhysicalDirectoryExists(path string) (bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect Elydora runtime directory at %s: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return false, fmt.Errorf("Elydora runtime path is not a physical directory: %s", path)
}
return true, nil
}
func readCursorRuntimeConfig(path string) (map[string]any, bool, error) {
exists, err := cursorPhysicalFileExists(path, "Elydora runtime config")
if err != nil || !exists {
return nil, exists, err
}
raw, err := os.ReadFile(path) // #nosec G304 -- path is confined to one validated Elydora agent directory.
if err != nil {
return nil, true, fmt.Errorf("read Elydora runtime config at %s: %w", path, err)
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
if !json.Valid(raw) {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil, true, fmt.Errorf("parse %s: %w", label, err)
}
}
config, err := decodeJSONCObject(raw, label, false)
if err != nil {
return nil, true, err
}
return config, true, nil
}
func validateCursorRuntimeIdentity(path, agentID string) (bool, error) {
config, exists, err := readCursorRuntimeConfig(path)
if err != nil || !exists {
return exists, err
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != cursorAgentKey ||
!sameCursorAgentID(configuredID, agentID) {
return true, fmt.Errorf(
"Elydora runtime config identity does not match Cursor agent %s: %s",
agentID,
path,
)
}
return true, nil
}
func preflightCursorRuntime(agentDirectory, agentID string) error {
exists, err := cursorPhysicalDirectoryExists(agentDirectory)
if err != nil || !exists {
return err
}
identityExists, err := validateCursorRuntimeIdentity(
filepath.Join(agentDirectory, "config.json"), agentID,
)
if err != nil {
return err
}
runtimeExists := false
for _, item := range []struct{ path, label string }{
{filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime"},
{filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime"},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key"},
} {
exists, err := cursorPhysicalFileExists(item.path, item.label)
if err != nil {
return err
}
runtimeExists = runtimeExists || exists
}
if runtimeExists && !identityExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
func prepareCursorInstallationChanges( func prepareCursorInstallationChanges(
config InstallConfig, config InstallConfig,
agentDirectory string, paths *managedRuntimePaths,
rendered *cursorRenderedDocument, rendered *cursorRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
baseURL := config.BaseURL if config.BaseURL == "" {
if baseURL == "" { config.BaseURL = "https://api.elydora.com"
baseURL = "https://api.elydora.com"
} }
runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{ changes, err := managedRuntimeFileChanges(
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, config,
BaseURL: baseURL, Token: config.Token, AgentName: cursorAgentKey, paths,
}, "", " ") cursorAgentKey,
if err != nil { generateGuardScript(
return nil, fmt.Errorf("encode Elydora runtime config: %w", err) cursorAgentKey, config.AgentID, `{"permission":"allow"}`+"\n", true, "cursor",
} ),
runtimeConfig = append(runtimeConfig, '\n') buildHookScriptWithOutput(cursorAgentKey, config.AgentID, "{}\n", true, true),
if len(runtimeConfig) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
) )
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime",
[]byte(generateGuardScript(
cursorAgentKey,
config.AgentID,
`{"permission":"allow"}`+"\n",
true,
"cursor",
)), 0700,
},
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
{
filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
cursorAgentKey,
config.AgentID,
"{}\n",
true,
true,
)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
if err := validateRuntimeFileTarget(item.path, item.label); err != nil {
return nil, err
}
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
documentChange, err := prepareRenderedCursorChange(rendered) documentChange, err := prepareRenderedCursorChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -228,98 +75,6 @@ func prepareCursorInstallationChanges(
return append(changes, documentChange), nil return append(changes, documentChange), nil
} }
func ensureCursorDirectory(path, label string) error { func cursorRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
if err := os.MkdirAll(path, 0700); err != nil { return managedRuntimeFilesPresent(contracts, cursorAgentKey)
return fmt.Errorf("create %s directory at %s: %w", label, path, err)
}
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect %s directory at %s: %w", label, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("%s directory is not a physical directory: %s", label, path)
}
return nil
}
func writeCursorChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot, agentDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
directories := map[string]string{}
for _, change := range changes {
if change == nil {
continue
}
directory := filepath.Dir(change.filePath)
key := filepath.Clean(directory)
if runtime.GOOS == "windows" {
key = strings.ToLower(key)
}
directories[key] = directory
}
for _, directory := range directories {
if err := ensureCursorDirectory(directory, label); err != nil {
return err
}
}
return writeChanges(changes, label, rename)
}
func cursorRuntimeFilesExist(contracts []cursorRuntimeContract) (bool, error) {
for _, contract := range contracts {
agentDirectory := filepath.Dir(contract.guardPath)
directoryExists, err := cursorPhysicalDirectoryExists(agentDirectory)
if err != nil {
return false, err
}
if !directoryExists {
continue
}
config, exists, err := readCursorRuntimeConfig(filepath.Join(agentDirectory, "config.json"))
if err != nil {
return false, err
}
configuredID, idOK := config["agent_id"].(string)
if !exists || !idOK || config["agent_name"] != cursorAgentKey ||
!sameCursorAgentID(configuredID, contract.agentID) {
continue
}
complete := true
for _, item := range []struct{ path, label string }{
{contract.guardPath, "Elydora guard runtime"},
{contract.auditPath, "Elydora audit runtime"},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key"},
} {
exists, err := cursorPhysicalFileExists(item.path, item.label)
if err != nil {
return false, err
}
complete = complete && exists
}
if complete {
return true, nil
}
}
return false, nil
} }
+2 -2
View File
@@ -183,7 +183,7 @@ func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) {
fixture.guardPath, fixture.guardPath,
fixture.hookPath, fixture.hookPath,
} { } {
if exists, err := cursorPhysicalFileExists(path, "Cursor runtime"); err != nil || !exists { if exists, err := managedPhysicalFileExists(path, "Cursor runtime", maxManagedSourceBytes); err != nil || !exists {
t.Fatalf("runtime file %s exists = %v, %v", path, exists, err) t.Fatalf("runtime file %s exists = %v, %v", path, exists, err)
} }
} }
@@ -200,7 +200,7 @@ func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) {
} }
failed := false failed := false
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if !failed && sameCursorPath(destination, fixture.configPath) { if !failed && sameManagedPath(destination, fixture.configPath) {
failed = true failed = true
return errors.New("injected Cursor config commit failure") return errors.New("injected Cursor config commit failure")
} }
+4 -6
View File
@@ -7,14 +7,12 @@ type DroidPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Droid commits its guard, audit runtime, // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// credentials, and hook documents in one transaction.
func (p *DroidPlugin) ManagesGuardRuntime() bool { func (p *DroidPlugin) ManagesGuardRuntime() bool {
return true return true
} }
// PreflightInstall validates every hook source, policy layer, credential, // PreflightInstall validates every source before the CLI creates runtime state.
// matcher, and runtime identity before any write.
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error { func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
sources, err := readDroidSources() sources, err := readDroidSources()
if err != nil { if err != nil {
@@ -107,7 +105,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := AgentRuntimeRoot()
if err != nil { if err != nil {
return err return err
} }
@@ -133,7 +131,7 @@ func (p *DroidPlugin) Status() (PluginStatus, error) {
if droidHookBlocked(sources) != nil { if droidHookBlocked(sources) != nil {
return status, nil return status, nil
} }
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := AgentRuntimeRoot()
if err != nil { if err != nil {
return status, err return status, err
} }
+1 -1
View File
@@ -239,7 +239,7 @@ func droidAdditionsFor(
document, target *droidDocument, document, target *droidDocument,
groups map[string]map[string]any, groups map[string]map[string]any,
) map[string]map[string]any { ) map[string]map[string]any {
if sameDroidPath(document.filePath, target.filePath) { if sameManagedPath(document.filePath, target.filePath) {
return groups return groups
} }
return map[string]map[string]any{} return map[string]map[string]any{}
+9 -151
View File
@@ -20,8 +20,6 @@ const (
droidAuditScript = "hook.js" droidAuditScript = "hook.js"
droidHookTimeout = float64(10) droidHookTimeout = float64(10)
droidOwnedFileMarker = "// Managed by Elydora" droidOwnedFileMarker = "// Managed by Elydora"
droidPOSIXApostrophe = `'"'"'`
droidWindowsExitSuffix = "; exit $LASTEXITCODE"
) )
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"} var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"}
@@ -35,11 +33,7 @@ type droidManagedRemoval struct {
removeGroup bool removeGroup bool
} }
type droidRuntimeContract struct { type droidRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
type droidRegexEntry struct { type droidRegexEntry struct {
Label string `json:"label"` Label string `json:"label"`
@@ -178,145 +172,21 @@ for (const entry of entries) {
return nil return nil
} }
func buildDroidCommand(nodePath, scriptPath string) string {
if runtime.GOOS == "windows" {
return "& " + quoteDroidPowerShellArgument(nodePath) + " " +
quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix
}
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
}
func quoteDroidPowerShellArgument(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func buildDroidGroup(nodePath, scriptPath string) map[string]any { func buildDroidGroup(nodePath, scriptPath string) map[string]any {
return map[string]any{ return map[string]any{
"matcher": "*", "matcher": "*",
"hooks": []any{map[string]any{ "hooks": []any{map[string]any{
"type": "command", "command": buildDroidCommand(nodePath, scriptPath), "timeout": droidHookTimeout, "type": "command", "command": buildShellCommand(nodePath, scriptPath), "timeout": droidHookTimeout,
}}, }},
} }
} }
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) { func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
if runtime.GOOS != "windows" { executable, script, ok := parseShellCommand(command)
return parseDroidPOSIXCommand(command) if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] {
}
executable, script, ok := parseDroidPowerShellCommand(command)
if ok || len(includeLegacy) == 0 || !includeLegacy[0] {
return executable, script, ok return executable, script, ok
} }
return parseDroidLegacyWindowsCommand(command) return parseQuotedWindowsCommand(command)
}
func parseDroidPOSIXCommand(command string) (string, string, bool) {
executable, next, ok := readDroidPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidPOSIXArgument(command, next+1)
return executable, script, ok && end == len(command) && executable != "" && script != ""
}
func parseDroidPowerShellCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
executable, next, ok := readDroidPowerShellArgument(command, 2)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidPowerShellArgument(command, next+1)
return executable, script, ok && command[end:] == droidWindowsExitSuffix && executable != "" && script != ""
}
func parseDroidLegacyWindowsCommand(command string) (string, string, bool) {
executable, next, ok := readDroidLegacyWindowsArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidLegacyWindowsArgument(command, next+1)
return executable, script, ok && end == len(command) && executable != "" && script != ""
}
func readDroidPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '"' {
return "", start, false
}
end := strings.IndexByte(command[start+1:], '"')
if end < 0 {
return "", start, false
}
end += start + 1
value := command[start+1 : end]
return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n")
}
func readDroidPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], droidPOSIXApostrophe) {
value.WriteByte('\'')
index += len(droidPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func sameDroidPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameDroidAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func droidRuntimeRoot() (string, error) {
return AgentRuntimeRoot()
} }
func managedDroidAgentID( func managedDroidAgentID(
@@ -333,29 +203,17 @@ func managedDroidAgentID(
} }
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...) executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
!isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) { !isNodeExecutable(executable) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
return "", false return "", false
} }
agentDirectory := filepath.Dir(scriptPath) agentDirectory := filepath.Dir(scriptPath)
if !sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) { if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) agentID := filepath.Base(agentDirectory)
return agentID, agentID != "" && agentID != "." && agentID != ".." return agentID, agentID != "" && agentID != "." && agentID != ".."
} }
func isDroidNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func sameDroidFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval { func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval {
removals := make([]droidManagedRemoval, 0) removals := make([]droidManagedRemoval, 0)
for _, contract := range []struct{ event, script string }{ for _, contract := range []struct{ event, script string }{
@@ -370,7 +228,7 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
managedID, managed := managedDroidAgentID( managedID, managed := managedDroidAgentID(
handlerValue.(map[string]any), contract.script, runtimeRoot, true, handlerValue.(map[string]any), contract.script, runtimeRoot, true,
) )
if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) { if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
indexes = append(indexes, handlerIndex) indexes = append(indexes, handlerIndex)
} }
} }
@@ -400,7 +258,7 @@ func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []dro
contracts := make([]droidRuntimeContract, 0, len(guardIDs)) contracts := make([]droidRuntimeContract, 0, len(guardIDs))
for _, guardID := range guardIDs { for _, guardID := range guardIDs {
for auditID := range audits { for auditID := range audits {
if !sameDroidAgentID(guardID, auditID) { if !sameManagedAgentID(guardID, auditID) {
continue continue
} }
root := filepath.Join(runtimeRoot, guardID) root := filepath.Join(runtimeRoot, guardID)
+23 -156
View File
@@ -1,24 +1,13 @@
package plugins package plugins
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings"
) )
type droidRuntimePaths struct {
runtimeRoot string
agentDirectory string
guardPath string
auditPath string
}
type preparedDroidInstallation struct { type preparedDroidInstallation struct {
changes []*fileChange changes []*fileChange
preconditions []filePrecondition preconditions []filePrecondition
paths *droidRuntimePaths paths *managedRuntimePaths
} }
type preparedDroidUninstall struct { type preparedDroidUninstall struct {
@@ -39,80 +28,10 @@ func droidSourceLabel(document *droidDocument) string {
} }
} }
func validateDroidInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != droidAgentKey {
return fmt.Errorf(
"Factory Droid installation requires agent name %s",
droidAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func droidAgentPaths(config InstallConfig) (*droidRuntimePaths, error) {
if err := validateDroidInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &droidRuntimePaths{
runtimeRoot: runtimeRoot,
agentDirectory: agentDirectory,
guardPath: filepath.Join(agentDirectory, droidGuardScript),
auditPath: filepath.Join(agentDirectory, droidAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameDroidPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameDroidPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightDroidInstallation( func preflightDroidInstallation(
config InstallConfig, config InstallConfig,
sources *droidSources, sources *droidSources,
) (*droidRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if sources == nil || sources.root == nil || sources.policy == nil { if sources == nil || sources.root == nil || sources.policy == nil {
return nil, "", fmt.Errorf("Factory Droid installation sources are required") return nil, "", fmt.Errorf("Factory Droid installation sources are required")
} }
@@ -126,50 +45,25 @@ func preflightDroidInstallation(
if err := validateDroidRegexes(hooks...); err != nil { if err := validateDroidRegexes(hooks...); err != nil {
return nil, "", err return nil, "", err
} }
paths, err := droidAgentPaths(config) if err := validateManagedInstallConfig(config, droidAgentKey, "Factory Droid"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, droidGuardScript, droidAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateDroidRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, droidAgentKey, "Factory Droid",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Factory Droid")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if !filepath.IsAbs(nodePath) || !isDroidNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"Factory Droid hooks require an absolute Node.js executable path",
)
}
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildDroidRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID,
"agent_id": config.AgentID,
"kid": config.KID,
"base_url": config.BaseURL,
"agent_name": droidAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareRenderedDroidChange( func prepareRenderedDroidChange(
rendered *droidRenderedDocument, rendered *droidRenderedDocument,
) (*fileChange, error) { ) (*fileChange, error) {
@@ -198,7 +92,7 @@ func validateDroidRenderedSet(
matches := 0 matches := 0
for _, item := range rendered { for _, item := range rendered {
if item != nil && item.document != nil && if item != nil && item.document != nil &&
sameDroidPath(item.document.filePath, document.filePath) { sameManagedPath(item.document.filePath, document.filePath) {
matches++ matches++
} }
} }
@@ -217,7 +111,7 @@ func droidSourcePreconditions(
for _, document := range droidSourceDocuments(sources) { for _, document := range droidSourceDocuments(sources) {
changed := false changed := false
for _, path := range changedPaths { for _, path := range changedPaths {
if sameDroidPath(document.filePath, path) { if sameManagedPath(document.filePath, path) {
changed = true changed = true
break break
} }
@@ -246,38 +140,13 @@ func prepareDroidInstallation(
if err := validateDroidRenderedSet(sources, rendered); err != nil { if err := validateDroidRenderedSet(sources, rendered); err != nil {
return nil, err return nil, err
} }
runtimeConfig, err := buildDroidRuntimeConfig(config) guardScript, auditScript := droidExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, droidAgentKey, string(guardScript), string(auditScript),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath,
"Elydora guard runtime",
[]byte(generateGuardScript(droidAgentKey, config.AgentID, "", false, "")),
0700,
},
{filepath.Join(paths.agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
{filepath.Join(paths.agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath,
"Elydora audit runtime",
[]byte(buildHookScriptWithOutput(droidAgentKey, config.AgentID, "", false, true)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
changedPaths := make([]string, 0, len(rendered)) changedPaths := make([]string, 0, len(rendered))
for _, document := range rendered { for _, document := range rendered {
change, changeErr := prepareRenderedDroidChange(document) change, changeErr := prepareRenderedDroidChange(document)
@@ -305,14 +174,7 @@ func writeDroidChanges(
runtimeRoot, agentDirectory string, runtimeRoot, agentDirectory string,
preconditions []filePrecondition, preconditions []filePrecondition,
) error { ) error {
hasChanges := false if !hasFileChanges(changes) {
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return writeChanges(changes, label, rename, preconditions...) return writeChanges(changes, label, rename, preconditions...)
} }
if err := assertFilePreconditions(preconditions, label); err != nil { if err := assertFilePreconditions(preconditions, label); err != nil {
@@ -365,7 +227,7 @@ func prepareDroidUninstall(
for _, item := range rendered { for _, item := range rendered {
changed := false changed := false
for _, path := range changedPaths { for _, path := range changedPaths {
if sameDroidPath(item.document.filePath, path) { if sameManagedPath(item.document.filePath, path) {
changed = true changed = true
break break
} }
@@ -398,3 +260,8 @@ func commitDroidUninstall(
prepared.preconditions, prepared.preconditions,
) )
} }
func droidExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
return []byte(generateGuardScript(droidAgentKey, agentID, "", false, "")),
[]byte(buildHookScriptWithOutput(droidAgentKey, agentID, "", false, true))
}
+2 -248
View File
@@ -1,251 +1,5 @@
package plugins package plugins
import ( func droidRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"bytes" return managedRuntimeFilesExist(contracts, droidAgentKey, "Factory Droid", droidExpectedScripts)
"fmt"
"path/filepath"
"sort"
"strings"
)
func readDroidRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
config, err := decodeStrictJSONObject(
snapshot.contents,
fmt.Sprintf("Elydora runtime config at %s", path),
)
return config, true, err
}
func requireDroidRuntimeString(
config map[string]any,
field, configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateDroidRuntimeConfig(
config map[string]any,
expectedAgentID, configPath string,
) error {
supported := stringSet(
"org_id",
"agent_id",
"kid",
"base_url",
"token",
"agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireDroidRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireDroidRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireDroidRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameDroidAgentID(agentID, expectedAgentID) || config["agent_name"] != droidAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Factory Droid hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireDroidRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireDroidRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateDroidRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readDroidRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, droidGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, droidAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != droidAgentKey ||
!sameDroidAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Factory Droid agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func validDroidContractPaths(contract droidRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
return sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) &&
sameDroidPath(
contract.guardPath,
filepath.Join(agentDirectory, droidGuardScript),
) &&
sameDroidPath(
contract.auditPath,
filepath.Join(agentDirectory, droidAuditScript),
), nil
}
func droidRuntimeContractExists(contract droidRuntimeContract) (bool, error) {
validPaths, err := validDroidContractPaths(contract)
if err != nil || !validPaths {
return false, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readDroidRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateDroidRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(droidAgentKey, contract.agentID, "", false, ""))
expectedAudit := []byte(buildHookScriptWithOutput(
droidAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := droidRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
+1 -1
View File
@@ -57,7 +57,7 @@ func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
requireDroidNativeGroup(t, auditGroup) requireDroidNativeGroup(t, auditGroup)
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string) guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
if runtime.GOOS == "windows" && if runtime.GOOS == "windows" &&
(!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, droidWindowsExitSuffix)) { (!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, powerShellExitSuffix)) {
t.Fatalf("Windows Factory command = %q", guardCommand) t.Fatalf("Windows Factory command = %q", guardCommand)
} }
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig) runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
+11 -2
View File
@@ -237,7 +237,7 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
handler := requireDroidObject(t, handlerValue) handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string) command, _ := handler["command"].(string)
_, configuredPath, managed := parseDroidCommand(command, true) _, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameDroidPath(configuredPath, scriptPath) { if managed && sameManagedPath(configuredPath, scriptPath) {
return group return group
} }
} }
@@ -253,7 +253,7 @@ func droidManagedHandler(t *testing.T, groupsValue any, scriptPath string) map[s
handler := requireDroidObject(t, handlerValue) handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string) command, _ := handler["command"].(string)
_, configuredPath, managed := parseDroidCommand(command, true) _, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameDroidPath(configuredPath, scriptPath) { if managed && sameManagedPath(configuredPath, scriptPath) {
return handler return handler
} }
} }
@@ -344,3 +344,12 @@ func requireNoDroidStagingFiles(t *testing.T, root string) {
t.Fatalf("walk staging files: %v", err) 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
}
@@ -53,7 +53,7 @@ func TestDroidInstallRollsBackRuntimeAfterLateHookCommitFailure(t *testing.T) {
prepared := prepareDroidTestInstallation(t, fixture, fixture.config) prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
failed := false failed := false
rename := func(source, destination string) error { rename := func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.configPath) { if !failed && sameManagedPath(destination, fixture.configPath) {
failed = true failed = true
return errors.New("injected Droid hook commit failure") return errors.New("injected Droid hook commit failure")
} }
@@ -85,7 +85,7 @@ func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T)
t.Fatalf("concurrent source error = %v", err) t.Fatalf("concurrent source error = %v", err)
} }
for path, source := range before { for path, source := range before {
if sameDroidPath(path, fixture.configPath) { if sameManagedPath(path, fixture.configPath) {
continue continue
} }
if readDroidTestFile(t, path) != source { if readDroidTestFile(t, path) != source {
@@ -205,7 +205,7 @@ func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) {
t.Fatal("same-content replacement changed") t.Fatal("same-content replacement changed")
} }
for path, source := range before { for path, source := range before {
if !sameDroidPath(path, fixture.configPath) && readDroidTestFile(t, path) != source { if !sameManagedPath(path, fixture.configPath) && readDroidTestFile(t, path) != source {
t.Fatalf("runtime changed after inode replacement: %s", path) t.Fatalf("runtime changed after inode replacement: %s", path)
} }
} }
@@ -226,7 +226,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read uninstall sources: %v", err) t.Fatalf("read uninstall sources: %v", err)
} }
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := AgentRuntimeRoot()
if err != nil { if err != nil {
t.Fatalf("resolve runtime root: %v", err) t.Fatalf("resolve runtime root: %v", err)
} }
@@ -240,7 +240,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
} }
failed := false failed := false
rename := func(source, destination string) error { rename := func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.settingsPath) { if !failed && sameManagedPath(destination, fixture.settingsPath) {
failed = true failed = true
return errors.New("injected Droid uninstall failure") return errors.New("injected Droid uninstall failure")
} }
+5 -4
View File
@@ -10,8 +10,7 @@ type GeminiPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Gemini commits both generated runtimes and // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// the user settings document through one transaction.
func (p *GeminiPlugin) ManagesGuardRuntime() bool { func (p *GeminiPlugin) ManagesGuardRuntime() bool {
return true return true
} }
@@ -92,13 +91,14 @@ func (p *GeminiPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeGeminiChanges( if err := writeManagedChanges(
changes, changes,
"Install Gemini CLI hooks", "Install Gemini CLI hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Gemini CLI configuration directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -120,13 +120,14 @@ func (p *GeminiPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeGeminiChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Gemini CLI hooks", "Uninstall Gemini CLI hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Gemini CLI configuration directory",
) )
} }
+15 -74
View File
@@ -7,83 +7,37 @@ import (
"strings" "strings"
) )
type geminiRuntimeReference struct { type geminiRuntimeReference = managedScriptReference
agentID string
scriptPath string
}
func sameGeminiPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameGeminiAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameGeminiFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func isGeminiNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func buildGeminiCommand(runtimePath, scriptPath string) (string, error) { func buildGeminiCommand(runtimePath, scriptPath string) (string, error) {
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
return "", fmt.Errorf( return "", fmt.Errorf("Gemini CLI hook commands require absolute runtime and script paths")
"Gemini CLI hook commands require absolute runtime and script paths",
)
} }
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil return posixSource(runtimePath, scriptPath), nil
} }
source := "& " + quoteCodexPowerShell(runtimePath) + " " + return "& " + quotePowerShellArgument(windowsPowerShellPath()) + powerShellEncodedFlags +
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE" encodePowerShellSource(powerShellSource(runtimePath, scriptPath)), nil
return "& " + quoteCodexPowerShell(codexPowerShellPath()) +
" -NoLogo -NoProfile -NonInteractive -EncodedCommand " +
encodeCodexPowerShell(source), nil
} }
func parseGeminiWindowsCommand(command string) (string, string, bool) { func parseGeminiWindowsCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") { if !strings.HasPrefix(command, "& ") {
return "", "", false return "", "", false
} }
powerShell, next, ok := readCodexPowerShellArgument(command, 2) powerShell, next, ok := readPowerShellArgument(command, 2)
if !ok || !isAbsoluteWindowsPath(powerShell) || if !ok || !isPowerShellExecutable(powerShell) ||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") { !strings.HasPrefix(command[next:], powerShellEncodedFlags) {
return "", "", false return "", "", false
} }
prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand " source, ok := decodePowerShellSource(command[next+len(powerShellEncodedFlags):])
if !strings.HasPrefix(command[next:], prefix) {
return "", "", false
}
source, ok := decodeCodexPowerShell(command[next+len(prefix):])
if !ok { if !ok {
return "", "", false return "", "", false
} }
return parseCodexPowerShellSource(source) return parsePowerShellSource(source)
} }
func parseGeminiCommand(command string) (string, string, bool) { func parseGeminiCommand(command string) (string, string, bool) {
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok {
return runtimePath, scriptPath, true return runtimePath, scriptPath, true
} }
return parseGeminiWindowsCommand(command) return parseGeminiWindowsCommand(command)
@@ -110,23 +64,10 @@ func geminiRuntimeReferenceForCommand(
if !current && includeLegacy { if !current && includeLegacy {
runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command) runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command)
} }
validRuntime := (current && filepath.IsAbs(runtimePath) && validRuntime := (current && filepath.IsAbs(runtimePath) && isNodeExecutable(runtimePath)) ||
isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node") (legacy && runtimePath == "node")
if !validRuntime || !filepath.IsAbs(scriptPath) || if !validRuntime {
!sameGeminiFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil return nil, nil
} }
agentDirectory := filepath.Dir(scriptPath) return resolveManagedScript(scriptPath, scriptName)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &geminiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
} }
+3 -25
View File
@@ -1,10 +1,7 @@
package plugins package plugins
import ( import (
"path/filepath"
"runtime"
"sort" "sort"
"strings"
) )
const ( const (
@@ -27,11 +24,7 @@ type geminiManagedRemoval struct {
removeGroup bool removeGroup bool
} }
type geminiRuntimeContract struct { type geminiRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) { func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) {
command, err := buildGeminiCommand(runtimePath, scriptPath) command, err := buildGeminiCommand(runtimePath, scriptPath)
@@ -151,7 +144,7 @@ func managedGeminiRemovals(
return nil, err return nil, err
} }
if reference != nil && if reference != nil &&
(agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) { (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) {
indexes = append(indexes, handlerIndex) indexes = append(indexes, handlerIndex)
} }
} }
@@ -168,13 +161,6 @@ func managedGeminiRemovals(
return removals, nil return removals, nil
} }
func geminiReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func geminiReferencesForEvent( func geminiReferencesForEvent(
groups []any, groups []any,
scriptName string, scriptName string,
@@ -196,7 +182,7 @@ func geminiReferencesForEvent(
return nil, err return nil, err
} }
if reference != nil { if reference != nil {
key := geminiReferenceKey(reference.agentID) key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference) result[key] = append(result[key], *reference)
} }
} }
@@ -243,11 +229,3 @@ func geminiRuntimeContracts(
} }
return contracts, nil return contracts, nil
} }
func geminiRuntimeRoot() (string, error) {
root, err := AgentRuntimeRoot()
if err != nil {
return "", err
}
return filepath.Clean(root), nil
}
+19 -161
View File
@@ -1,190 +1,48 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type geminiRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateGeminiInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != geminiAgentKey {
return fmt.Errorf(
"Gemini CLI installation requires agent name %s",
geminiAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func geminiAgentPaths(config InstallConfig) (*geminiRuntimePaths, error) {
if err := validateGeminiInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := geminiRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &geminiRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, geminiGuardScript),
auditPath: filepath.Join(agentDirectory, geminiAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameGeminiPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameGeminiPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightGeminiInstallation( func preflightGeminiInstallation(
config InstallConfig, config InstallConfig,
document *geminiDocument, document *geminiDocument,
) (*geminiRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if document == nil || document.filePath == "" { if document == nil || document.filePath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf("Gemini CLI installation requires a user settings document")
"Gemini CLI installation requires a user settings document",
)
} }
paths, err := geminiAgentPaths(config) if err := validateManagedInstallConfig(config, geminiAgentKey, "Gemini CLI"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, geminiGuardScript, geminiAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateGeminiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, geminiAgentKey, "Gemini CLI",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Gemini CLI")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if !filepath.IsAbs(nodePath) || !isGeminiNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"Gemini CLI hooks require an absolute Node.js executable path",
)
}
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildGeminiRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": geminiAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareGeminiInstallationChanges( func prepareGeminiInstallationChanges(
config InstallConfig, config InstallConfig,
paths *geminiRuntimePaths, paths *managedRuntimePaths,
rendered *geminiRenderedDocument, rendered *geminiRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildGeminiRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
config,
paths,
geminiAgentKey,
generateGuardScript(geminiAgentKey, config.AgentID, "{}\n", false, ""),
buildHookScriptWithOutput(geminiAgentKey, config.AgentID, "{}\n", false, true),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(
geminiAgentKey,
config.AgentID,
"{}\n",
false,
"",
)),
0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
geminiAgentKey,
config.AgentID,
"{}\n",
false,
true,
)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
documentChange, err := prepareRenderedGeminiChange(rendered) documentChange, err := prepareRenderedGeminiChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
-35
View File
@@ -67,38 +67,3 @@ func prepareRenderedGeminiChange(
rendered.remove, rendered.remove,
) )
} }
func writeGeminiChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
settingsDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(
settingsDirectory,
"Gemini CLI configuration directory",
); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
+2 -2
View File
@@ -164,10 +164,10 @@ func TestGeminiValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
}, "query parameters"}, }, "query parameters"},
{"guard path", func(f *geminiFixture) { {"guard path", func(f *geminiFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
{"audit path", func(f *geminiFixture) { {"audit path", func(f *geminiFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
} { } {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
+2 -228
View File
@@ -1,231 +1,5 @@
package plugins package plugins
import ( func geminiRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"fmt" return managedRuntimeFilesExist(contracts, geminiAgentKey, "Gemini CLI", nil)
"path/filepath"
"sort"
"strings"
)
func readGeminiRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireGeminiRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateGeminiRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireGeminiRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireGeminiRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireGeminiRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameGeminiAgentID(agentID, expectedAgentID) ||
config["agent_name"] != geminiAgentKey {
return fmt.Errorf(
"elydora runtime identity does not match Gemini CLI hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireGeminiRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireGeminiRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateGeminiRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readGeminiRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, geminiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, geminiAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != geminiAgentKey ||
!sameGeminiAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Gemini CLI agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func geminiRuntimeContractExists(contract geminiRuntimeContract) (bool, error) {
runtimeRoot, err := geminiRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameGeminiPath(
contract.auditPath,
filepath.Join(agentDirectory, geminiAuditScript),
) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readGeminiRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateGeminiRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
}
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
}
func geminiRuntimeFilesExist(contracts []geminiRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := geminiRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
+4 -4
View File
@@ -33,8 +33,8 @@ func TestGeminiCommandRoundTripAndRejectsInjection(t *testing.T) {
t.Fatalf("build Gemini command: %v", err) t.Fatalf("build Gemini command: %v", err)
} }
executable, script, ok := parseGeminiCommand(command) executable, script, ok := parseGeminiCommand(command)
if !ok || !sameGeminiPath(executable, nodePath) || if !ok || !sameManagedPath(executable, nodePath) ||
!sameGeminiPath(script, fixture.guardPath) { !sameManagedPath(script, fixture.guardPath) {
t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok) t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok)
} }
if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") || if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") ||
@@ -425,12 +425,12 @@ func TestGeminiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) { func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
if !sameGeminiAgentID("AGENT-1", "agent-1") { if !sameManagedAgentID("AGENT-1", "agent-1") {
t.Fatal("Windows Gemini agent IDs should compare case-insensitively") t.Fatal("Windows Gemini agent IDs should compare case-insensitively")
} }
return return
} }
if sameGeminiAgentID("AGENT-1", "agent-1") { if sameManagedAgentID("AGENT-1", "agent-1") {
t.Fatal("POSIX Gemini agent IDs should preserve case") t.Fatal("POSIX Gemini agent IDs should preserve case")
} }
} }
@@ -251,7 +251,7 @@ func runGeminiRawCommand(
var process *exec.Cmd var process *exec.Cmd
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
process = exec.Command( process = exec.Command(
codexPowerShellPath(), windowsPowerShellPath(),
"-NoLogo", "-NoLogo",
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
@@ -35,7 +35,7 @@ func prepareGeminiTestChanges(
t *testing.T, t *testing.T,
fixture *geminiFixture, fixture *geminiFixture,
document *geminiDocument, document *geminiDocument,
) ([]*fileChange, *geminiRuntimePaths) { ) ([]*fileChange, *managedRuntimePaths) {
t.Helper() t.Helper()
paths, nodePath, err := preflightGeminiInstallation(fixture.config, document) paths, nodePath, err := preflightGeminiInstallation(fixture.config, document)
if err != nil { if err != nil {
@@ -110,7 +110,7 @@ func TestGeminiInstallRollsBackAllFiveFilesAfterSettingsFailure(t *testing.T) {
fixture.config.OrgID = "org-updated" fixture.config.OrgID = "org-updated"
fixture.config.Token = "token-updated" fixture.config.Token = "token-updated"
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameGeminiPath(destination, fixture.settingsPath) && if sameManagedPath(destination, fixture.settingsPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Gemini settings failure") return errors.New("injected Gemini settings failure")
} }
@@ -139,13 +139,14 @@ func TestPreparedGeminiInstallRejectsConcurrentSettingsChange(t *testing.T) {
if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil { if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err) t.Fatalf("write concurrent settings: %v", err)
} }
err = writeGeminiChanges( err = writeManagedChanges(
changes, changes,
"Install Gemini CLI hooks", "Install Gemini CLI hooks",
nil, nil,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Gemini CLI configuration directory",
) )
if err == nil || !strings.Contains(err.Error(), "changed during installation") { if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err) t.Fatalf("prepared install error = %v", err)
@@ -204,7 +205,7 @@ func TestGeminiUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
t.Fatalf("read installed Gemini settings: %v", err) t.Fatalf("read installed Gemini settings: %v", err)
} }
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameGeminiPath(destination, fixture.settingsPath) && if sameManagedPath(destination, fixture.settingsPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Gemini uninstall failure") return errors.New("injected Gemini uninstall failure")
} }
+2 -2
View File
@@ -36,11 +36,11 @@ func (p *GrokPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath)
if err != nil { if err != nil {
return err return err
} }
auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath)
if err != nil { if err != nil {
return err return err
} }
+6 -117
View File
@@ -1,132 +1,21 @@
package plugins package plugins
import ( import "path/filepath"
"fmt"
"path/filepath"
"runtime"
"strings"
)
type grokRuntimeReference struct {
agentID string
scriptPath string
}
func sameGrokPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameGrokAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func buildGrokCommand(runtimePath, scriptPath string) (string, error) {
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
return "", fmt.Errorf(
"Grok hook commands require absolute runtime and script paths",
)
}
if runtime.GOOS == "windows" {
return codexWindowsCommand(runtimePath, scriptPath), nil
}
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
}
func quoteGrokLegacyWindowsArgument(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
func readGrokLegacyWindowsArgument(
command string,
start int,
) (string, int, bool) {
if start >= len(command) || command[start] != '"' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] == '"' {
return value.String(), index + 1, true
}
if command[index] == '\r' || command[index] == '\n' {
return "", start, false
}
value.WriteByte(command[index])
}
return "", start, false
}
func parseGrokLegacyWindowsCommand(command string) (string, string, bool) {
runtimePath, next, ok := readGrokLegacyWindowsArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
scriptPath, end, ok := readGrokLegacyWindowsArgument(command, next+1)
if !ok || end != len(command) {
return "", "", false
}
expected := quoteGrokLegacyWindowsArgument(runtimePath) + " " +
quoteGrokLegacyWindowsArgument(scriptPath)
return runtimePath, scriptPath, command == expected
}
func parseGrokCommand(command string) (string, string, bool) { func parseGrokCommand(command string) (string, string, bool) {
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok {
return runtimePath, scriptPath, true return runtimePath, scriptPath, true
} }
if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok { return parseQuotedWindowsCommand(command)
return runtimePath, scriptPath, true
}
return parseGrokLegacyWindowsCommand(command)
}
func isGrokNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
} }
func grokRuntimeReferenceForCommand( func grokRuntimeReferenceForCommand(
command string, command string,
scriptName string, scriptName string,
) (*grokRuntimeReference, error) { ) (*managedScriptReference, error) {
runtimePath, scriptPath, ok := parseGrokCommand(command) runtimePath, scriptPath, ok := parseGrokCommand(command)
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
!isGrokNodeExecutable(runtimePath) ||
!sameGrokFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil return nil, nil
} }
agentDirectory := filepath.Dir(scriptPath) return resolveManagedScript(scriptPath, scriptName)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &grokRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
}
func sameGrokFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
} }
+13 -36
View File
@@ -6,9 +6,7 @@ import (
"fmt" "fmt"
"math" "math"
"reflect" "reflect"
"runtime"
"sort" "sort"
"strings"
) )
const ( const (
@@ -48,20 +46,6 @@ type grokRenderedDocument struct {
remove bool remove bool
} }
type grokRuntimeContract struct {
agentID string
guardPath string
auditPath string
}
func cloneGrokObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func cloneGrokHooks(source grokHooks) grokHooks { func cloneGrokHooks(source grokHooks) grokHooks {
clone := make(grokHooks, len(source)) clone := make(grokHooks, len(source))
for event, groups := range source { for event, groups := range source {
@@ -128,7 +112,7 @@ func validateGrokHandler(
} }
} }
} }
return cloneGrokObject(handler), nil return cloneJSONObject(handler), nil
} }
func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, error) { func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, error) {
@@ -166,7 +150,7 @@ func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, erro
} }
handlers = append(handlers, handler) handlers = append(handlers, handler)
} }
return grokGroup{object: cloneGrokObject(object), handlers: handlers}, nil return grokGroup{object: cloneJSONObject(object), handlers: handlers}, nil
} }
func readGrokHooks(root map[string]any) (grokHooks, error) { func readGrokHooks(root map[string]any) (grokHooks, error) {
@@ -229,7 +213,7 @@ func renderGrokHooks(hooks grokHooks) map[string]any {
for event, groups := range hooks { for event, groups := range hooks {
values := make([]any, 0, len(groups)) values := make([]any, 0, len(groups))
for _, group := range groups { for _, group := range groups {
object := cloneGrokObject(group.object) object := cloneJSONObject(group.object)
handlers := make([]any, 0, len(group.handlers)) handlers := make([]any, 0, len(group.handlers))
for _, handler := range group.handlers { for _, handler := range group.handlers {
handlers = append(handlers, handler) handlers = append(handlers, handler)
@@ -262,7 +246,7 @@ func exactManagedGrokGroup(group grokGroup) bool {
func managedGrokReference( func managedGrokReference(
handler map[string]any, handler map[string]any,
scriptName string, scriptName string,
) (*grokRuntimeReference, error) { ) (*managedScriptReference, error) {
if len(handler) != 3 || handler["type"] != "command" || if len(handler) != 3 || handler["type"] != "command" ||
handler["timeout"] != grokHookTimeout { handler["timeout"] != grokHookTimeout {
return nil, nil return nil, nil
@@ -303,7 +287,7 @@ func removeManagedGrokGroups(
return nil, err return nil, err
} }
remove := reference != nil && remove := reference != nil &&
(agentID == "" || sameGrokAgentID(reference.agentID, agentID)) (agentID == "" || sameManagedAgentID(reference.agentID, agentID))
if !remove { if !remove {
kept = append(kept, handler) kept = append(kept, handler)
} }
@@ -386,7 +370,7 @@ func renderGrokDocument(
document: document, changed: true, remove: true, document: document, changed: true, remove: true,
}, nil }, nil
} }
root := cloneGrokObject(document.root) root := cloneJSONObject(document.root)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
} else { } else {
@@ -407,18 +391,11 @@ func renderGrokDocument(
}, nil }, nil
} }
func grokReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func grokReferencesForEvent( func grokReferencesForEvent(
groups []grokGroup, groups []grokGroup,
scriptName string, scriptName string,
) (map[string][]grokRuntimeReference, error) { ) (map[string][]managedScriptReference, error) {
result := map[string][]grokRuntimeReference{} result := map[string][]managedScriptReference{}
for _, group := range groups { for _, group := range groups {
if !exactManagedGrokGroup(group) { if !exactManagedGrokGroup(group) {
continue continue
@@ -431,14 +408,14 @@ func grokReferencesForEvent(
if reference == nil { if reference == nil {
continue continue
} }
key := grokReferenceKey(reference.agentID) key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference) result[key] = append(result[key], *reference)
} }
} }
return result, nil return result, nil
} }
func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) { func grokRuntimeContracts(hooks grokHooks) ([]managedRuntimeContract, error) {
guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript) guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -459,16 +436,16 @@ func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Strings(keys) sort.Strings(keys)
contracts := make([]grokRuntimeContract, 0, len(keys)) contracts := make([]managedRuntimeContract, 0, len(keys))
for _, key := range keys { for _, key := range keys {
guard := guards[key] guard := guards[key]
success := successes[key] success := successes[key]
failure := failures[key] failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameGrokPath(success[0].scriptPath, failure[0].scriptPath) { !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue continue
} }
contracts = append(contracts, grokRuntimeContract{ contracts = append(contracts, managedRuntimeContract{
agentID: guard[0].agentID, guardPath: guard[0].scriptPath, agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
auditPath: success[0].scriptPath, auditPath: success[0].scriptPath,
}) })
+17 -135
View File
@@ -1,102 +1,24 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type grokRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateGrokInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != grokAgentKey {
return fmt.Errorf("grok installation requires agent name %s", grokAgentKey)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func grokAgentPaths(config InstallConfig) (*grokRuntimePaths, error) {
if err := validateGrokInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &grokRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, grokGuardScript),
auditPath: filepath.Join(agentDirectory, grokAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameGrokPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameGrokPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightGrokInstallation( func preflightGrokInstallation(
config InstallConfig, config InstallConfig,
document *grokDocument, document *grokDocument,
) (*grokRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if document == nil { if document == nil {
return nil, "", fmt.Errorf("grok installation requires a hook document") return nil, "", fmt.Errorf("grok installation requires a hook document")
} }
paths, err := grokAgentPaths(config) if err := validateManagedInstallConfig(config, grokAgentKey, "grok"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, grokGuardScript, grokAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateGrokRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, grokAgentKey, "Grok",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveNodeRuntime()
@@ -106,63 +28,23 @@ func preflightGrokInstallation(
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildGrokRuntimeConfig(config InstallConfig) ([]byte, error) { func grokExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
value := map[string]any{ return []byte(generateGuardScript(grokAgentKey, agentID, "", false, "grok")),
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, []byte(buildHookScriptWithOutput(grokAgentKey, agentID, "", false, true))
"base_url": config.BaseURL, "agent_name": grokAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
} }
func prepareGrokInstallationChanges( func prepareGrokInstallationChanges(
config InstallConfig, config InstallConfig,
paths *grokRuntimePaths, paths *managedRuntimePaths,
rendered *grokRenderedDocument, rendered *grokRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildGrokRuntimeConfig(config) guardScript, auditScript := grokExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, grokAgentKey, string(guardScript), string(auditScript),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(grokAgentKey, config.AgentID, "", false, "grok")), 0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
grokAgentKey, config.AgentID, "", false, true,
)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
documentChange, err := prepareRenderedGrokChange(rendered) documentChange, err := prepareRenderedGrokChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
+5 -20
View File
@@ -85,32 +85,17 @@ func writeGrokChanges(
agentDirectory string, agentDirectory string,
hooksDirectory string, hooksDirectory string,
) error { ) error {
hasChanges := false if !hasFileChanges(changes) {
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil return nil
} }
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory( if err := ensureManagedDirectory(
filepath.Dir(hooksDirectory), filepath.Dir(hooksDirectory),
"Grok home directory", "Grok home directory",
); err != nil { ); err != nil {
return err return err
} }
if err := ensureManagedDirectory(hooksDirectory, "Grok hooks directory"); err != nil { return writeManagedChanges(
return err changes, label, rename, runtimeRoot, agentDirectory,
} hooksDirectory, "Grok hooks directory",
return writeChanges(changes, label, rename) )
} }
+2 -2
View File
@@ -164,10 +164,10 @@ func TestGrokValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
}, "query parameters"}, }, "query parameters"},
{"guard path", func(f *grokFixture) { {"guard path", func(f *grokFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
{"audit path", func(f *grokFixture) { {"audit path", func(f *grokFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
} { } {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
fixture := prepareGrokFixture(t, grokFixtureOptions{}) fixture := prepareGrokFixture(t, grokFixtureOptions{})
+2 -229
View File
@@ -1,232 +1,5 @@
package plugins package plugins
import ( func grokRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"fmt" return managedRuntimeFilesExist(contracts, grokAgentKey, "Grok", nil)
"path/filepath"
"sort"
"strings"
)
func readGrokRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireGrokRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateGrokRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireGrokRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireGrokRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireGrokRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameGrokAgentID(agentID, expectedAgentID) ||
config["agent_name"] != grokAgentKey {
return fmt.Errorf(
"elydora runtime identity does not match Grok hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireGrokRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireGrokRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateGrokRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readGrokRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, grokGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, grokAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != grokAgentKey ||
!sameGrokAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Grok agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func grokRuntimeContractExists(contract grokRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameGrokPath(
contract.auditPath,
filepath.Join(agentDirectory, grokAuditScript),
) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readGrokRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateGrokRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
}
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
}
func grokRuntimeFilesExist(contracts []grokRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := grokRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
+8 -8
View File
@@ -27,22 +27,22 @@ func TestGrokCommandRoundTripAndRejectsInjection(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err) t.Fatalf("resolve Node.js runtime: %v", err)
} }
command, err := buildGrokCommand(nodePath, fixture.guardPath) command, err := buildEncodedCommand("Grok", nodePath, fixture.guardPath)
if err != nil { if err != nil {
t.Fatalf("build Grok command: %v", err) t.Fatalf("build Grok command: %v", err)
} }
executable, script, ok := parseGrokCommand(command) executable, script, ok := parseGrokCommand(command)
if !ok || !sameGrokPath(executable, nodePath) || if !ok || !sameManagedPath(executable, nodePath) ||
!sameGrokPath(script, fixture.guardPath) { !sameManagedPath(script, fixture.guardPath) {
t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok) t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok)
} }
if strings.Contains(command, "%ELYDORA_HOOK_PATH%") { if runtime.GOOS == "windows" && strings.Contains(command, "%ELYDORA_HOOK_PATH%") {
t.Fatalf("Grok command exposes an expandable path: %q", command) t.Fatalf("Grok command exposes an expandable path: %q", command)
} }
if _, err := buildGrokCommand("node", fixture.guardPath); err == nil { if _, err := buildEncodedCommand("Grok", "node", fixture.guardPath); err == nil {
t.Fatal("accepted a relative Grok runtime path") t.Fatal("accepted a relative Grok runtime path")
} }
if _, err := buildGrokCommand(nodePath, "guard.js"); err == nil { if _, err := buildEncodedCommand("Grok", nodePath, "guard.js"); err == nil {
t.Fatal("accepted a relative Grok script path") t.Fatal("accepted a relative Grok script path")
} }
for _, invalid := range []string{ for _, invalid := range []string{
@@ -337,12 +337,12 @@ func TestGrokRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) { func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
if !sameGrokAgentID("AGENT-1", "agent-1") { if !sameManagedAgentID("AGENT-1", "agent-1") {
t.Fatal("Windows Grok agent IDs should compare case-insensitively") t.Fatal("Windows Grok agent IDs should compare case-insensitively")
} }
return return
} }
if sameGrokAgentID("AGENT-1", "agent-1") { if sameManagedAgentID("AGENT-1", "agent-1") {
t.Fatal("POSIX Grok agent IDs should preserve case") t.Fatal("POSIX Grok agent IDs should preserve case")
} }
} }
@@ -275,8 +275,7 @@ func legacyGrokCommand(t *testing.T, scriptPath string) string {
t.Fatalf("resolve Node.js runtime: %v", err) t.Fatalf("resolve Node.js runtime: %v", err)
} }
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return quoteGrokLegacyWindowsArgument(nodePath) + " " + return `"` + nodePath + `" "` + scriptPath + `"`
quoteGrokLegacyWindowsArgument(scriptPath)
} }
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
} }
+4 -4
View File
@@ -59,7 +59,7 @@ func TestGrokInstallRollsBackFourRuntimesAfterConfigCommitFailure(t *testing.T)
} }
before := snapshotGrokFiles(t, paths...) before := snapshotGrokFiles(t, paths...)
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameGrokPath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Grok config failure") return errors.New("injected Grok config failure")
} }
@@ -85,11 +85,11 @@ func TestPreparedGrokInstallRejectsConcurrentConfigChange(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("preflight Grok installation: %v", err) t.Fatalf("preflight Grok installation: %v", err)
} }
guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath)
if err != nil { if err != nil {
t.Fatalf("build guard command: %v", err) t.Fatalf("build guard command: %v", err)
} }
auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath)
if err != nil { if err != nil {
t.Fatalf("build audit command: %v", err) t.Fatalf("build audit command: %v", err)
} }
@@ -179,7 +179,7 @@ func TestGrokUninstallPreservesConfigAfterCommitFailure(t *testing.T) {
t.Fatalf("read installed Grok config: %v", err) t.Fatalf("read installed Grok config: %v", err)
} }
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameGrokPath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Grok uninstall failure") return errors.New("injected Grok uninstall failure")
} }
+1 -23
View File
@@ -146,30 +146,8 @@ function writeStatusCache(status) {
}); });
} }
function resolveBaseURL(config) {
const value = config.base_url || 'https://api.elydora.com';
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
let parsed;
try {
parsed = new URL(value);
} catch (error) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error(
'Agent config base_url must exclude credentials, query parameters, and fragments',
);
}
return value.replace(/\/+$/, '');
}
async function resolveRemoteStatus(config) { async function resolveRemoteStatus(config) {
const baseURL = resolveBaseURL(config); const baseURL = resolveConfigBaseURL(config);
const token = config.token || ''; const token = config.token || '';
if (typeof token !== 'string') throw new Error('Agent config token must be a string'); if (typeof token !== 'string') throw new Error('Agent config token must be a string');
const controller = new AbortController(); const controller = new AbortController();
+123 -97
View File
@@ -13,10 +13,7 @@ func buildHookScriptWithOutput(
return fmt.Sprintf(`#!/usr/bin/env node return fmt.Sprintf(`#!/usr/bin/env node
'use strict'; 'use strict';
// --------------------------------------------------------------------------- // Elydora audit hook for %s. Generated by Elydora SDK.
// Self-contained Elydora audit hook for: %s
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const fs = require('node:fs'); const fs = require('node:fs');
@@ -53,10 +50,6 @@ function parsePrivateKey(raw) {
return value; return value;
} }
// ---------------------------------------------------------------------------
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
// ---------------------------------------------------------------------------
function base64urlEncode(data) { function base64urlEncode(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buf.toString('base64url'); return buf.toString('base64url');
@@ -113,6 +106,8 @@ function signEd25519(privateKeyBase64url, data) {
return base64urlEncode(signature); return base64urlEncode(signature);
} }
const MAX_CHAIN_ATTEMPTS = 5;
const SUBMIT_BUDGET_MS = 4000;
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
function validateChainHash(value, source) { function validateChainHash(value, source) {
@@ -156,10 +151,6 @@ function generateNonce() {
return base64urlEncode(crypto.randomBytes(16)); return base64urlEncode(crypto.randomBytes(16));
} }
// ---------------------------------------------------------------------------
// Chain state persistence (atomic writes)
// ---------------------------------------------------------------------------
function readChainState() { function readChainState() {
let raw; let raw;
try { try {
@@ -188,9 +179,80 @@ function writeChainState(chainHash) {
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash }); writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
} }
// --------------------------------------------------------------------------- function lockOwnerAlive(lockPath) {
// Error logging let owner;
// --------------------------------------------------------------------------- try {
owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
return false;
}
if (!Number.isInteger(owner) || owner <= 0) return false;
try {
process.kill(owner, 0);
return true;
} catch (error) {
if (hasRuntimeErrorCode(error, 'ESRCH')) return false;
return true;
}
}
function withChainLock(update) {
const lockPath = CHAIN_STATE_PATH + '.lock';
const deadline = performance.now() + 1000;
for (;;) {
let descriptor;
try {
descriptor = fs.openSync(lockPath, 'wx', 0o600);
fs.writeSync(descriptor, String(process.pid));
} catch (error) {
if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error;
if (lockOwnerAlive(lockPath)) {
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
continue;
}
let age = 0;
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) {
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
}
if (age > 5000) {
const reclaimed = lockPath + '.' + process.pid + '.stale';
try {
fs.renameSync(lockPath, reclaimed);
fs.unlinkSync(reclaimed);
} catch (reclaimError) {
if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError;
}
continue;
}
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
continue;
}
try {
return update();
} finally {
let current = null;
try {
current = fs.lstatSync(lockPath);
} catch (statError) {
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
}
const own = fs.fstatSync(descriptor);
fs.closeSync(descriptor);
if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath);
}
}
}
function advanceChainState(fromHash, toHash) {
return withChainLock(() => {
if (readChainState() !== fromHash) return false;
writeChainState(toHash);
return true;
});
}
function logError(err) { function logError(err) {
try { try {
@@ -204,35 +266,43 @@ function logError(err) {
} }
} }
function resolveAuditBaseURL(config) { async function postOperation(baseUrl, token, eor, deadline) {
const value = config.base_url || 'https://api.elydora.com'; const remaining = deadline - performance.now();
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) { if (remaining <= 0) throw new Error('Audit API retry budget exhausted');
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); const controller = new AbortController();
} const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining));
let parsed;
try { try {
parsed = new URL(value); const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
} catch (error) { if (token) headers['Authorization'] = 'Bearer ' + token;
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); const res = await fetch(baseUrl + '/v1/operations', {
method: 'POST',
headers: headers,
body: JSON.stringify(eor),
signal: controller.signal,
});
if (res.ok) return { accepted: true };
if (res.status === 400) {
let errBody;
try {
errBody = await res.json();
} catch (err) {
throw new Error('Audit API returned invalid error JSON: ' + err.message);
} }
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) { if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
if (match) {
return { accepted: false, expected: validateChainHash(match[1], 'Audit API mismatch response') };
} }
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error(
'Agent config base_url must exclude credentials, query parameters, and fragments',
);
} }
return value.replace(/\/+$/, '');
} }
throw new Error('Audit API returned HTTP ' + res.status);
// --------------------------------------------------------------------------- } finally {
// Main hook logic clearTimeout(timeout);
// --------------------------------------------------------------------------- }
}
async function main() { async function main() {
try { try {
// Read JSON from stdin
const chunks = []; const chunks = [];
for await (const chunk of process.stdin) { for await (const chunk of process.stdin) {
chunks.push(chunk); chunks.push(chunk);
@@ -268,7 +338,6 @@ async function main() {
hookData.conversation_id || hookData.session_id || hookData.sessionId || hookData.conversation_id || hookData.session_id || hookData.sessionId ||
hookData.session || hookData.taskId || 'unknown'; hookData.session || hookData.taskId || 'unknown';
// Read agent config and private key
let config; let config;
let privateKey; let privateKey;
try { try {
@@ -296,7 +365,7 @@ async function main() {
const orgId = config.org_id; const orgId = config.org_id;
const agentId = config.agent_id; const agentId = config.agent_id;
const kid = config.kid; const kid = config.kid;
const baseUrl = resolveAuditBaseURL(config); const baseUrl = resolveConfigBaseURL(config);
const token = config.token || ''; const token = config.token || '';
if (typeof orgId !== 'string' || !orgId || typeof agentId !== 'string' || if (typeof orgId !== 'string' || !orgId || typeof agentId !== 'string' ||
!agentId || typeof kid !== 'string' || !kid) { !agentId || typeof kid !== 'string' || !kid) {
@@ -304,23 +373,19 @@ async function main() {
} }
if (typeof token !== 'string') throw new Error('Agent config token must be a string'); if (typeof token !== 'string') throw new Error('Agent config token must be a string');
// Read chain state // Retry from the server chain head on PREV_HASH_MISMATCH.
const prevChainHash = readChainState();
// Build EOR
const operationId = uuidv7();
const issuedAt = Date.now();
const nonce = generateNonce();
const payload = NATIVE_PAYLOAD ? hookData : { const payload = NATIVE_PAYLOAD ? hookData : {
tool_name: toolName, tool_name: toolName,
tool_input: toolInput, tool_input: toolInput,
session_id: sessionId, session_id: sessionId,
}; };
const payloadHash = computePayloadHash(payload); const payloadHash = computePayloadHash(payload);
const deadline = performance.now() + SUBMIT_BUDGET_MS;
let prevChainHash = readChainState();
for (let attempt = 1; ; attempt += 1) {
const operationId = uuidv7();
const issuedAt = Date.now();
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt); const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
const eorWithoutSig = { const eorWithoutSig = {
op_version: '1.0', op_version: '1.0',
operation_id: operationId, operation_id: operationId,
@@ -328,7 +393,7 @@ async function main() {
agent_id: agentId, agent_id: agentId,
issued_at: issuedAt, issued_at: issuedAt,
ttl_ms: 30000, ttl_ms: 30000,
nonce: nonce, nonce: generateNonce(),
operation_type: 'ai.tool_use', operation_type: 'ai.tool_use',
subject: { session_id: sessionId }, subject: { session_id: sessionId },
action: { tool: toolName }, action: { tool: toolName },
@@ -337,58 +402,19 @@ async function main() {
prev_chain_hash: prevChainHash, prev_chain_hash: prevChainHash,
agent_pubkey_kid: kid, agent_pubkey_kid: kid,
}; };
const signature = signEd25519(privateKey, Buffer.from(jcsCanonicalise(eorWithoutSig), 'utf-8'));
const canonical = jcsCanonicalise(eorWithoutSig);
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature }); const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
const result = await postOperation(baseUrl, token, eor, deadline);
// POST to /v1/operations (5s timeout) if (result.accepted) {
// Only update local chain state on success to prevent desync advanceChainState(prevChainHash, chainHash);
const url = baseUrl + '/v1/operations';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(eor),
signal: controller.signal,
});
if (res.ok) {
writeChainState(chainHash);
return; return;
} }
if (res.status === 400) { advanceChainState(prevChainHash, result.expected);
// Possible chain hash mismatch — try to resync from server response logError(new Error('Chain hash resynced to server: ' + result.expected));
try { if (attempt >= MAX_CHAIN_ATTEMPTS) {
const errBody = await res.json(); throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
// Extract the expected hash from the error message
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
if (match) {
const expectedHash = validateChainHash(match[1], 'Audit API mismatch response');
writeChainState(expectedHash);
logError(new Error('Chain hash resynced to server: ' + expectedHash));
} }
} prevChainHash = readChainState();
} catch (err) {
logError(new Error('Failed to parse audit API error: ' + err.message));
}
}
throw new Error('Audit API returned HTTP ' + res.status);
} finally {
clearTimeout(timeout);
} }
} catch (err) { } catch (err) {
logError(err); logError(err);
+18 -9
View File
@@ -17,15 +17,6 @@ type jsoncEditor struct {
label string label string
} }
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
}
func parseJSONC(source []byte, label string, allowTrailingCommas bool) (hujson.Value, error) { func parseJSONC(source []byte, label string, allowTrailingCommas bool) (hujson.Value, error) {
value, err := hujson.Parse(source) value, err := hujson.Parse(source)
if err != nil { if err != nil {
@@ -97,6 +88,16 @@ func rejectDuplicateJSONCKeys(value *hujson.Value, label, location string) error
return nil return nil
} }
func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) {
if !json.Valid(source) {
var value any
if err := json.Unmarshal(source, &value); err != nil {
return nil, fmt.Errorf("parse %s: %w", label, err)
}
}
return decodeJSONCObject(source, label, false)
}
func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) { func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) {
value, err := parseJSONC(source, label, allowTrailingCommas) value, err := parseJSONC(source, label, allowTrailingCommas)
if err != nil { if err != nil {
@@ -292,3 +293,11 @@ func jsonPointer(path []any) string {
} }
return pointer.String() return pointer.String()
} }
func cloneJSONObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
+18 -16
View File
@@ -2,25 +2,13 @@ package plugins
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// expandHome replaces a leading ~ with the user's home directory. // readJSONFile reads a JSON object; a missing file yields an empty map.
func expandHome(p string) (string, error) {
if !strings.HasPrefix(p, "~") {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, p[1:]), nil
}
// readJSONFile reads a JSON file into a map. Returns an empty map if the file does not exist.
func readJSONFile(path string) (map[string]interface{}, error) { func readJSONFile(path string) (map[string]interface{}, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -53,7 +41,7 @@ func writeJSONFile(path string, data map[string]interface{}) error {
return nil return nil
} }
// hookScriptPath returns the default path for the hook script inside ~/.elydora/<agentId>/. // hookScriptPath returns ~/.elydora/<agentId>/hook.js.
func hookScriptPath(agentId string) (string, error) { func hookScriptPath(agentId string) (string, error) {
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
if err != nil { if err != nil {
@@ -62,7 +50,21 @@ func hookScriptPath(agentId string) (string, error) {
return filepath.Join(agentDirectory, "hook.js"), nil return filepath.Join(agentDirectory, "hook.js"), nil
} }
// guardScriptPath returns the default path for the guard script inside ~/.elydora/<agentId>/. // removeAgentScripts deletes hook.js and guard.js; missing files are fine.
func removeAgentScripts(agentID string) error {
for _, resolve := range []func(string) (string, error){hookScriptPath, guardScriptPath} {
path, err := resolve(agentID)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove %s: %w", path, err)
}
}
return nil
}
// guardScriptPath returns ~/.elydora/<agentId>/guard.js.
func guardScriptPath(agentId string) (string, error) { func guardScriptPath(agentId string) (string, error) {
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
if err != nil { if err != nil {
+6 -15
View File
@@ -34,11 +34,11 @@ func (p *KimiPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath)
if err != nil { if err != nil {
return err return err
} }
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath)
if err != nil { if err != nil {
return err return err
} }
@@ -70,12 +70,9 @@ func (p *KimiPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeKimiChanges( if err := writeManagedChanges(
changes, changes, "Install Kimi hooks", p.rename,
"Install Kimi hooks", paths.runtimeRoot, paths.agentDirectory, "", "",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
); err != nil { ); err != nil {
return err return err
} }
@@ -107,13 +104,7 @@ func (p *KimiPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeKimiChanges( return writeManagedChanges(changes, "Uninstall Kimi hooks", p.rename, "", "", "", "")
changes,
"Uninstall Kimi hooks",
p.rename,
"",
"",
)
} }
func (p *KimiPlugin) Status() (PluginStatus, error) { func (p *KimiPlugin) Status() (PluginStatus, error) {
+5 -64
View File
@@ -1,49 +1,10 @@
package plugins package plugins
import ( import (
"fmt"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
type kimiRuntimeReference struct {
agentID string
scriptPath string
}
func sameKimiPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameKimiAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func buildKimiCommand(runtimePath, scriptPath string) (string, error) {
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths")
}
if runtime.GOOS == "windows" {
return codexWindowsCommand(runtimePath, scriptPath), nil
}
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
}
func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) { func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) {
if start >= len(command) { if start >= len(command) {
return "", start, false return "", start, false
@@ -66,6 +27,7 @@ func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool
return command[start:end], end, end > start return command[start:end], end, end > start
} }
// parseKimiLegacyWindowsCommand reads the pre-2.1 quoteWindowsArgument form.
func parseKimiLegacyWindowsCommand(command string) (string, string, bool) { func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0) runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' { if !ok || next >= len(command) || command[next] != ' ' {
@@ -80,40 +42,19 @@ func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
} }
func parseKimiCommand(command string) (string, string, bool) { func parseKimiCommand(command string) (string, string, bool) {
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok {
return runtimePath, scriptPath, true
}
if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok {
return runtimePath, scriptPath, true return runtimePath, scriptPath, true
} }
return parseKimiLegacyWindowsCommand(command) return parseKimiLegacyWindowsCommand(command)
} }
func isKimiNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func kimiRuntimeReferenceForCommand( func kimiRuntimeReferenceForCommand(
command string, command string,
scriptName string, scriptName string,
) (*kimiRuntimeReference, error) { ) (*managedScriptReference, error) {
runtimePath, scriptPath, ok := parseKimiCommand(command) runtimePath, scriptPath, ok := parseKimiCommand(command)
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
!isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName {
return nil, nil return nil, nil
} }
agentDirectory := filepath.Dir(scriptPath) return resolveManagedScript(scriptPath, scriptName)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &kimiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
} }
+12 -28
View File
@@ -2,9 +2,7 @@ package plugins
import ( import (
"fmt" "fmt"
"runtime"
"sort" "sort"
"strings"
) )
const ( const (
@@ -54,20 +52,10 @@ type kimiHook struct {
} }
type kimiRuntimeContract struct { type kimiRuntimeContract struct {
agentID string managedRuntimeContract
guardPath string
auditPath string
configPath string configPath string
} }
func stringSet(values ...string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
result[value] = struct{}{}
}
return result
}
func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} { func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} {
result := map[string]struct{}{} result := map[string]struct{}{}
for _, set := range sets { for _, set := range sets {
@@ -173,7 +161,7 @@ func managedKimiReference(
hook kimiHook, hook kimiHook,
event string, event string,
scriptName string, scriptName string,
) (*kimiRuntimeReference, error) { ) (*managedScriptReference, error) {
if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil || if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil ||
hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds { hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
return nil, nil return nil, nil
@@ -196,7 +184,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
indices := make([]int, 0, len(hooks)) indices := make([]int, 0, len(hooks))
for index, hook := range hooks { for index, hook := range hooks {
event, scriptName, managedEvent := managedKimiEvent(hook.event) event, scriptName, managedEvent := managedKimiEvent(hook.event)
var reference *kimiRuntimeReference var reference *managedScriptReference
var err error var err error
if managedEvent { if managedEvent {
reference, err = managedKimiReference(hook, event, scriptName) reference, err = managedKimiReference(hook, event, scriptName)
@@ -205,7 +193,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
} }
} }
remove := reference != nil && remove := reference != nil &&
(agentID == "" || sameKimiAgentID(reference.agentID, agentID)) (agentID == "" || sameManagedAgentID(reference.agentID, agentID))
if !remove { if !remove {
indices = append(indices, index) indices = append(indices, index)
} }
@@ -213,19 +201,12 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
return indices, nil return indices, nil
} }
func kimiReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func kimiReferencesForEvent( func kimiReferencesForEvent(
hooks []kimiHook, hooks []kimiHook,
event string, event string,
scriptName string, scriptName string,
) (map[string][]kimiRuntimeReference, error) { ) (map[string][]managedScriptReference, error) {
result := map[string][]kimiRuntimeReference{} result := map[string][]managedScriptReference{}
for _, hook := range hooks { for _, hook := range hooks {
reference, err := managedKimiReference(hook, event, scriptName) reference, err := managedKimiReference(hook, event, scriptName)
if err != nil { if err != nil {
@@ -234,7 +215,7 @@ func kimiReferencesForEvent(
if reference == nil { if reference == nil {
continue continue
} }
key := kimiReferenceKey(reference.agentID) key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference) result[key] = append(result[key], *reference)
} }
return result, nil return result, nil
@@ -267,12 +248,15 @@ func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, erro
success := successes[key] success := successes[key]
failure := failures[key] failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameKimiPath(success[0].scriptPath, failure[0].scriptPath) { !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue continue
} }
contracts = append(contracts, kimiRuntimeContract{ contracts = append(contracts, kimiRuntimeContract{
managedRuntimeContract: managedRuntimeContract{
agentID: guard[0].agentID, guardPath: guard[0].scriptPath, agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
auditPath: success[0].scriptPath, configPath: document.contract.configPath, auditPath: success[0].scriptPath,
},
configPath: document.contract.configPath,
}) })
} }
} }
+18 -164
View File
@@ -2,107 +2,32 @@ package plugins
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
) )
type kimiRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
type kimiRenderedDocument struct { type kimiRenderedDocument struct {
document kimiDocument document kimiDocument
next []byte next []byte
} }
func validateKimiInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != kimiAgentKey {
return fmt.Errorf("kimi installation requires agent name %s", kimiAgentKey)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func kimiAgentPaths(config InstallConfig) (*kimiRuntimePaths, error) {
if err := validateKimiInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &kimiRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, kimiGuardScript),
auditPath: filepath.Join(agentDirectory, kimiAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameKimiPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameKimiPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightKimiInstallation( func preflightKimiInstallation(
config InstallConfig, config InstallConfig,
documents []kimiDocument, documents []kimiDocument,
) (*kimiRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if len(documents) == 0 { if len(documents) == 0 {
return nil, "", fmt.Errorf("kimi installation requires at least one hook contract") return nil, "", fmt.Errorf("kimi installation requires at least one hook contract")
} }
paths, err := kimiAgentPaths(config) if err := validateManagedInstallConfig(config, kimiAgentKey, "kimi"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, kimiGuardScript, kimiAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateKimiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, kimiAgentKey, "Kimi",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveNodeRuntime()
@@ -112,26 +37,9 @@ func preflightKimiInstallation(
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) { func kimiExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
value := map[string]any{ return []byte(generateGuardScript(kimiAgentKey, agentID, "", false, "")),
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, []byte(buildHookScriptWithOutput(kimiAgentKey, agentID, "", false, true))
"base_url": config.BaseURL, "agent_name": kimiAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
} }
func renderKimiChange( func renderKimiChange(
@@ -172,47 +80,21 @@ func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, erro
func prepareKimiInstallationChanges( func prepareKimiInstallationChanges(
config InstallConfig, config InstallConfig,
paths *kimiRuntimePaths, paths *managedRuntimePaths,
rendered []kimiRenderedDocument, rendered []kimiRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildKimiRuntimeConfig(config) guardScript, auditScript := kimiExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, kimiAgentKey, string(guardScript), string(auditScript),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct { documentChanges, err := prepareKimiUninstallChanges(rendered)
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false, "")), 0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
kimiAgentKey, config.AgentID, "", false, true,
)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change) return append(changes, documentChanges...), nil
}
for _, document := range rendered {
change, err := prepareRenderedKimiChange(document)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
return changes, nil
} }
func prepareKimiUninstallChanges( func prepareKimiUninstallChanges(
@@ -228,31 +110,3 @@ func prepareKimiUninstallChanges(
} }
return changes, nil return changes, nil
} }
func writeKimiChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
return writeChanges(changes, label, rename)
}
+1 -1
View File
@@ -66,7 +66,7 @@ func resolveKimiContracts() ([]kimiContract, error) {
legacyHome := filepath.Join(home, ".kimi") legacyHome := filepath.Join(home, ".kimi")
stable := stableKimiContract(filepath.Join(stableHome, "config.toml")) stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml")) legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
if sameKimiPath(stable.configPath, legacy.configPath) { if sameManagedPath(stable.configPath, legacy.configPath) {
return []kimiContract{stable}, nil return []kimiContract{stable}, nil
} }
+5 -187
View File
@@ -1,194 +1,12 @@
package plugins package plugins
import (
"fmt"
"path/filepath"
"sort"
"strings"
)
func readKimiRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireKimiRuntimeString(config map[string]any, field, configPath string) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"elydora runtime config %s is invalid: %s", field, configPath,
)
}
return value, nil
}
func validateKimiRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet("org_id", "agent_id", "kid", "base_url", "token", "agent_name")
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`elydora runtime config has unsupported field %q: %s`, field, configPath,
)
}
}
if _, err := requireKimiRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireKimiRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireKimiRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey {
return fmt.Errorf("elydora runtime identity does not match Kimi hooks: %s", configPath)
}
if _, exists := config["token"]; exists {
if _, err := requireKimiRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireKimiRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
}
return nil
}
func validateKimiRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory, "Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readKimiRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, kimiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, kimiAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != kimiAgentKey ||
!sameKimiAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Kimi agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameKimiPath(contract.auditPath, filepath.Join(agentDirectory, kimiAuditScript)) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory, "Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readKimiRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateKimiRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
}
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
}
func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) { func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) {
for _, contract := range contracts { for _, contract := range contracts {
exists, err := kimiRuntimeContractExists(contract) exists, err := managedRuntimeContractExists(
if err != nil { contract.managedRuntimeContract, kimiAgentKey, "Kimi", nil,
return false, err )
} if err != nil || exists {
if exists { return exists, err
return true, nil
} }
} }
return false, nil return false, nil
+6 -9
View File
@@ -38,7 +38,7 @@ func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) {
modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)), modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)),
}) })
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameKimiPath(destination, fixture.legacyPath) { if sameManagedPath(destination, fixture.legacyPath) {
return errors.New("injected legacy config failure") return errors.New("injected legacy config failure")
} }
return os.Rename(source, destination) return os.Rename(source, destination)
@@ -160,11 +160,11 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("preflight Kimi installation: %v", err) t.Fatalf("preflight Kimi installation: %v", err)
} }
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath)
if err != nil { if err != nil {
t.Fatalf("build guard command: %v", err) t.Fatalf("build guard command: %v", err)
} }
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath)
if err != nil { if err != nil {
t.Fatalf("build audit command: %v", err) t.Fatalf("build audit command: %v", err)
} }
@@ -204,12 +204,9 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
t.Fatalf("write concurrent source: %v", err) t.Fatalf("write concurrent source: %v", err)
} }
err = writeKimiChanges( err = writeManagedChanges(
changes, changes, "Install Kimi hooks", nil,
"Install Kimi hooks", paths.runtimeRoot, paths.agentDirectory, "", "",
nil,
paths.runtimeRoot,
paths.agentDirectory,
) )
if err == nil || !strings.Contains(err.Error(), "changed during installation") { if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err) t.Fatalf("prepared install error = %v", err)
+1 -1
View File
@@ -79,7 +79,7 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error {
copyKiroObject(nextV2, v2Settings) copyKiroObject(nextV2, v2Settings)
nextV2["hooks"] = v2Hooks nextV2["hooks"] = v2Hooks
nextV3 := cloneKiroObject(v3Settings) nextV3 := cloneJSONObject(v3Settings)
nextV3["version"] = "v1" nextV3["version"] = "v1"
v3Hooks := make([]any, 0, len(currentV3Hooks)+2) v3Hooks := make([]any, 0, len(currentV3Hooks)+2)
for _, hook := range currentV3Hooks { for _, hook := range currentV3Hooks {
+3 -9
View File
@@ -28,12 +28,6 @@ type kiroConfigMutation struct {
remove bool remove bool
} }
func cloneKiroObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
copyKiroObject(clone, value)
return clone
}
func copyKiroObject(destination, source map[string]any) { func copyKiroObject(destination, source map[string]any) {
for key, value := range source { for key, value := range source {
destination[key] = value destination[key] = value
@@ -49,7 +43,7 @@ func kiroHooksObject(settings map[string]any, label string) (map[string]any, err
if !ok || hooks == nil { if !ok || hooks == nil {
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
} }
return cloneKiroObject(hooks), nil return cloneJSONObject(hooks), nil
} }
func kiroHookEntries(hooks map[string]any, event, label string) ([]map[string]any, error) { func kiroHookEntries(hooks map[string]any, event, label string) ([]map[string]any, error) {
@@ -193,7 +187,7 @@ func prepareKiroV2Uninstall(
} }
hooks["preToolUse"] = filteredPre hooks["preToolUse"] = filteredPre
hooks["postToolUse"] = filteredPost hooks["postToolUse"] = filteredPost
next := cloneKiroObject(settings) next := cloneJSONObject(settings)
next["hooks"] = hooks next["hooks"] = hooks
mutation.value = next mutation.value = next
mutation.changed = true mutation.changed = true
@@ -250,7 +244,7 @@ func prepareKiroV3Uninstall(
if len(filtered) == len(hooks) { if len(filtered) == len(hooks) {
return mutation, nil return mutation, nil
} }
next := cloneKiroObject(settings) next := cloneJSONObject(settings)
next["hooks"] = filtered next["hooks"] = filtered
owned := true owned := true
for key := range settings { for key := range settings {
-17
View File
@@ -3,7 +3,6 @@ package plugins
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -18,18 +17,6 @@ func kiroConfigPaths() (string, string, error) {
filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil
} }
func resolveNodeRuntime() (string, error) {
nodePath, err := exec.LookPath("node")
if err != nil {
return "", fmt.Errorf("resolve Node.js runtime: %w", err)
}
absolute, err := filepath.Abs(nodePath)
if err != nil {
return "", fmt.Errorf("resolve Node.js runtime path: %w", err)
}
return absolute, nil
}
func buildKiroCommand(runtimePath, scriptPath string) string { func buildKiroCommand(runtimePath, scriptPath string) string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath) return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
@@ -37,10 +24,6 @@ func buildKiroCommand(runtimePath, scriptPath string) string {
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath)
} }
func quotePOSIXArgument(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func quoteWindowsArgument(value string) string { func quoteWindowsArgument(value string) string {
if value != "" && !strings.ContainsAny(value, " \t\n\v\"") { if value != "" && !strings.ContainsAny(value, " \t\n\v\"") {
return value return value
+9 -94
View File
@@ -1,101 +1,32 @@
package plugins package plugins
import ( import (
"fmt"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
type kiroIdeRuntimeReference struct {
agentID string
scriptPath string
}
func sameKiroIdePath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameKiroIdeAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameKiroIdeFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func buildKiroIdeCommand(runtimePath, scriptPath string) (string, error) {
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
return "", fmt.Errorf(
"Kiro IDE hook commands require absolute runtime and script paths",
)
}
if runtime.GOOS == "windows" {
return codexWindowsCommand(runtimePath, scriptPath), nil
}
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
}
func parseKiroIdeCommand(command string) (string, string, bool) {
if runtime.GOOS == "windows" {
return parseCodexWindowsCommand(command)
}
return parseCodexPOSIXCommand(command)
}
func kiroIdeRuntimeReferenceForCommand( func kiroIdeRuntimeReferenceForCommand(
command string, command string,
scriptName string, scriptName string,
) (*kiroIdeRuntimeReference, error) { ) (*managedScriptReference, error) {
runtimePath, scriptPath, ok := parseKiroIdeCommand(command) runtimePath, scriptPath, ok := parseEncodedCommand(command)
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
!isCodexNodeExecutable(runtimePath) ||
!sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil return nil, nil
} }
agentDirectory := filepath.Dir(scriptPath) return resolveManagedScript(scriptPath, scriptName)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &kiroIdeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
} }
func currentKiroIdeReference( func currentKiroIdeReference(
hook map[string]any, hook map[string]any,
nodePath string, nodePath string,
) (*kiroIdeRuntimeReference, error) { ) (*managedScriptReference, error) {
reference, err := managedKiroIdeReference(hook) reference, err := managedKiroIdeReference(hook)
if err != nil || reference == nil { if err != nil || reference == nil {
return reference, err return reference, err
} }
action := hook["action"].(map[string]any) action := hook["action"].(map[string]any)
command := action["command"].(string) command := action["command"].(string)
expected, err := buildKiroIdeCommand(nodePath, reference.scriptPath) expected, err := buildEncodedCommand("Kiro IDE", nodePath, reference.scriptPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -105,30 +36,14 @@ func currentKiroIdeReference(
return reference, nil return reference, nil
} }
// legacyKiroIdeGoReference reads the pre-2.1 `node <script>` form.
func legacyKiroIdeGoReference( func legacyKiroIdeGoReference(
command any, command any,
scriptName string, scriptName string,
) (*kiroIdeRuntimeReference, error) { ) (*managedScriptReference, error) {
text, ok := command.(string) text, ok := command.(string)
if !ok || !strings.HasPrefix(text, "node ") { if !ok || !strings.HasPrefix(text, "node ") {
return nil, nil return nil, nil
} }
scriptPath := strings.TrimPrefix(text, "node ") return resolveManagedScript(strings.TrimPrefix(text, "node "), scriptName)
if !filepath.IsAbs(scriptPath) ||
!sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil
}
agentDirectory := filepath.Dir(scriptPath)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &kiroIdeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
} }
+17 -31
View File
@@ -45,7 +45,7 @@ type kiroIdeDocument struct {
type kiroIdeLegacyDocument struct { type kiroIdeLegacyDocument struct {
filePath string filePath string
snapshot *managedFileSnapshot snapshot *managedFileSnapshot
contract *kiroIdeRuntimeContract contract *managedRuntimeContract
} }
type kiroIdeRenderedDocument struct { type kiroIdeRenderedDocument struct {
@@ -55,12 +55,6 @@ type kiroIdeRenderedDocument struct {
remove bool remove bool
} }
type kiroIdeRuntimeContract struct {
agentID string
guardPath string
auditPath string
}
type kiroIdeHookSpecification struct { type kiroIdeHookSpecification struct {
description string description string
trigger string trigger string
@@ -82,14 +76,6 @@ func kiroIdeSpecification(name any) (kiroIdeHookSpecification, bool) {
} }
} }
func cloneKiroIdeObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func validateKiroIdeAction(value any, label string) error { func validateKiroIdeAction(value any, label string) error {
action, ok := value.(map[string]any) action, ok := value.(map[string]any)
if !ok || action == nil { if !ok || action == nil {
@@ -212,7 +198,7 @@ func exactKiroIdeKeys(value map[string]any, keys ...string) bool {
func ownedKiroIdeReference( func ownedKiroIdeReference(
hook map[string]any, hook map[string]any,
) (*kiroIdeRuntimeReference, error) { ) (*managedScriptReference, error) {
specification, managed := kiroIdeSpecification(hook["name"]) specification, managed := kiroIdeSpecification(hook["name"])
if !managed { if !managed {
return nil, nil return nil, nil
@@ -230,7 +216,7 @@ func ownedKiroIdeReference(
func managedKiroIdeReference( func managedKiroIdeReference(
hook map[string]any, hook map[string]any,
) (*kiroIdeRuntimeReference, error) { ) (*managedScriptReference, error) {
specification, managed := kiroIdeSpecification(hook["name"]) specification, managed := kiroIdeSpecification(hook["name"])
if !managed || if !managed ||
!exactKiroIdeKeys( !exactKiroIdeKeys(
@@ -278,7 +264,7 @@ func buildKiroIdeHook(
if !managed { if !managed {
return nil, fmt.Errorf("unsupported Elydora Kiro IDE hook name %q", name) return nil, fmt.Errorf("unsupported Elydora Kiro IDE hook name %q", name)
} }
command, err := buildKiroIdeCommand(runtimePath, scriptPath) command, err := buildEncodedCommand("Kiro IDE", runtimePath, scriptPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -298,7 +284,7 @@ func withoutManagedKiroIdeHooks(
for _, hook := range hooks { for _, hook := range hooks {
reference, err := ownedKiroIdeReference(hook) reference, err := ownedKiroIdeReference(hook)
remove := err == nil && reference != nil && remove := err == nil && reference != nil &&
(agentID == "" || sameKiroIdeAgentID(reference.agentID, agentID)) (agentID == "" || sameManagedAgentID(reference.agentID, agentID))
if !remove { if !remove {
filtered = append(filtered, hook) filtered = append(filtered, hook)
} }
@@ -339,7 +325,7 @@ func renderKiroIdeDocument(
document: document, changed: true, remove: true, document: document, changed: true, remove: true,
}, nil }, nil
} }
root := cloneKiroIdeObject(document.root) root := cloneJSONObject(document.root)
root["version"] = "v1" root["version"] = "v1"
values := make([]any, 0, len(hooks)) values := make([]any, 0, len(hooks))
for _, hook := range hooks { for _, hook := range hooks {
@@ -364,7 +350,7 @@ func renderKiroIdeDocument(
func kiroIdeRuntimeContracts( func kiroIdeRuntimeContracts(
hooks []map[string]any, hooks []map[string]any,
) ([]kiroIdeRuntimeContract, error) { ) ([]managedRuntimeContract, error) {
managedHooks := make([]map[string]any, 0, 2) managedHooks := make([]map[string]any, 0, 2)
for _, hook := range hooks { for _, hook := range hooks {
_, managed := kiroIdeSpecification(hook["name"]) _, managed := kiroIdeSpecification(hook["name"])
@@ -373,38 +359,38 @@ func kiroIdeRuntimeContracts(
} }
} }
if len(managedHooks) != 2 { if len(managedHooks) != 2 {
return []kiroIdeRuntimeContract{}, nil return []managedRuntimeContract{}, nil
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveNodeRuntime()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var guard, audit *kiroIdeRuntimeReference var guard, audit *managedScriptReference
for _, hook := range managedHooks { for _, hook := range managedHooks {
reference, err := currentKiroIdeReference(hook, nodePath) reference, err := currentKiroIdeReference(hook, nodePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if reference == nil { if reference == nil {
return []kiroIdeRuntimeContract{}, nil return []managedRuntimeContract{}, nil
} }
if hook["name"] == kiroIdeGuardName { if hook["name"] == kiroIdeGuardName {
if guard != nil { if guard != nil {
return []kiroIdeRuntimeContract{}, nil return []managedRuntimeContract{}, nil
} }
guard = reference guard = reference
} else { } else {
if audit != nil { if audit != nil {
return []kiroIdeRuntimeContract{}, nil return []managedRuntimeContract{}, nil
} }
audit = reference audit = reference
} }
} }
if guard == nil || audit == nil || if guard == nil || audit == nil ||
!sameKiroIdeAgentID(guard.agentID, audit.agentID) { !sameManagedAgentID(guard.agentID, audit.agentID) {
return []kiroIdeRuntimeContract{}, nil return []managedRuntimeContract{}, nil
} }
return []kiroIdeRuntimeContract{{ return []managedRuntimeContract{{
agentID: guard.agentID, guardPath: guard.scriptPath, agentID: guard.agentID, guardPath: guard.scriptPath,
auditPath: audit.scriptPath, auditPath: audit.scriptPath,
}}, nil }}, nil
@@ -449,10 +435,10 @@ func parseKiroIdeLegacyDocument(
return nil, err return nil, err
} }
if guardReference == nil || auditReference == nil || if guardReference == nil || auditReference == nil ||
!sameKiroIdeAgentID(guardReference.agentID, auditReference.agentID) { !sameManagedAgentID(guardReference.agentID, auditReference.agentID) {
return document, nil return document, nil
} }
document.contract = &kiroIdeRuntimeContract{ document.contract = &managedRuntimeContract{
agentID: guardReference.agentID, guardPath: guardReference.scriptPath, agentID: guardReference.agentID, guardPath: guardReference.scriptPath,
auditPath: auditReference.scriptPath, auditPath: auditReference.scriptPath,
} }
+15 -122
View File
@@ -1,20 +1,13 @@
package plugins package plugins
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
type kiroIdeRuntimePaths struct { type kiroIdeRuntimePaths struct {
runtimeRoot string managedRuntimePaths
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
initialDirectories []kiroIdeDirectoryIdentity initialDirectories []kiroIdeDirectoryIdentity
} }
@@ -28,84 +21,17 @@ type kiroIdePreparedTransaction struct {
initialDirectories []kiroIdeDirectoryIdentity initialDirectories []kiroIdeDirectoryIdentity
} }
func validateKiroIdeInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != kiroIdeAgentKey {
return fmt.Errorf(
"Kiro IDE installation requires agent name %s",
kiroIdeAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func kiroIdeAgentPaths(config InstallConfig) (*kiroIdeRuntimePaths, error) {
if err := validateKiroIdeInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &kiroIdeRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, kiroIdeGuardScript),
auditPath: filepath.Join(agentDirectory, kiroIdeAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameKiroIdePath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameKiroIdePath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightKiroIdeInstallation( func preflightKiroIdeInstallation(
config InstallConfig, config InstallConfig,
) (*kiroIdeRuntimePaths, string, error) { ) (*kiroIdeRuntimePaths, string, error) {
paths, err := kiroIdeAgentPaths(config) if err := validateManagedInstallConfig(config, kiroIdeAgentKey, "Kiro IDE"); err != nil {
return nil, "", err
}
managed, err := resolveManagedRuntimePaths(config, kiroIdeGuardScript, kiroIdeAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
paths := &kiroIdeRuntimePaths{managedRuntimePaths: *managed}
initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 3) initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 3)
for _, item := range []struct{ path, label string }{ for _, item := range []struct{ path, label string }{
{filepath.Dir(paths.runtimeRoot), "home directory"}, {filepath.Dir(paths.runtimeRoot), "home directory"},
@@ -124,9 +50,8 @@ func preflightKiroIdeInstallation(
initialDirectories[0].path, initialDirectories[0].path,
) )
} }
if err := validateKiroIdeRuntimeIdentity( if err := validateManagedRuntimeIdentity(
paths.agentDirectory, paths.agentDirectory, config.AgentID, kiroIdeAgentKey, "Kiro IDE",
config.AgentID,
); err != nil { ); err != nil {
return nil, "", err return nil, "", err
} }
@@ -144,29 +69,6 @@ func preflightKiroIdeInstallation(
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildKiroIdeRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID,
"kid": config.KID, "base_url": config.BaseURL,
"agent_name": kiroIdeAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareKiroIdeFile( func prepareKiroIdeFile(
filePath string, filePath string,
label string, label string,
@@ -221,10 +123,11 @@ func prepareKiroIdeInstallation(
if sources == nil || paths == nil || rendered == nil { if sources == nil || paths == nil || rendered == nil {
return nil, fmt.Errorf("Kiro IDE installation requires prepared sources") return nil, fmt.Errorf("Kiro IDE installation requires prepared sources")
} }
runtimeConfig, err := buildKiroIdeRuntimeConfig(config) runtimeConfig, err := buildManagedRuntimeConfig(config, kiroIdeAgentKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
guardScript, auditScript := kiroIdeExpectedScripts(config.AgentID, nil)
prepared := &kiroIdePreparedTransaction{ prepared := &kiroIdePreparedTransaction{
operation: "installation", paths: sources.paths, runtimePaths: paths, operation: "installation", paths: sources.paths, runtimePaths: paths,
initialDirectories: append( initialDirectories: append(
@@ -238,20 +141,10 @@ func prepareKiroIdeInstallation(
mode os.FileMode mode os.FileMode
maximum int64 maximum int64
}{ }{
{ {paths.guardPath, "Elydora guard runtime", guardScript, 0700, maxManagedSourceBytes},
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(kiroIdeAgentKey, config.AgentID, "", false, "")),
0700, maxManagedSourceBytes,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600, maxRuntimeConfigBytes}, {paths.configPath, "Elydora runtime config", runtimeConfig, 0600, maxRuntimeConfigBytes},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600, maxProtectedSecretBytes}, {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600, maxProtectedSecretBytes},
{ {paths.auditPath, "Elydora audit runtime", auditScript, 0700, maxManagedSourceBytes},
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
kiroIdeAgentKey, config.AgentID, "", false, true,
)),
0700, maxManagedSourceBytes,
},
} { } {
change, condition, err := prepareKiroIdeFile( change, condition, err := prepareKiroIdeFile(
item.path, item.label, item.contents, item.mode, item.maximum, item.path, item.label, item.contents, item.mode, item.maximum,
@@ -274,7 +167,7 @@ func prepareKiroIdeInstallation(
prepared.changes = append(prepared.changes, documentChange) prepared.changes = append(prepared.changes, documentChange)
} }
removeLegacy := sources.legacy.contract != nil && removeLegacy := sources.legacy.contract != nil &&
sameKiroIdeAgentID(sources.legacy.contract.agentID, config.AgentID) sameManagedAgentID(sources.legacy.contract.agentID, config.AgentID)
if removeLegacy { if removeLegacy {
legacyChange, err := prepareSnapshotSourceChange( legacyChange, err := prepareSnapshotSourceChange(
sources.legacy.filePath, sources.legacy.filePath,
@@ -335,7 +228,7 @@ func prepareKiroIdeUninstall(
prepared.changes = append(prepared.changes, documentChange) prepared.changes = append(prepared.changes, documentChange)
} }
removeLegacy := sources.legacy.contract != nil && removeLegacy := sources.legacy.contract != nil &&
(agentID == "" || sameKiroIdeAgentID(sources.legacy.contract.agentID, agentID)) (agentID == "" || sameManagedAgentID(sources.legacy.contract.agentID, agentID))
if removeLegacy { if removeLegacy {
change, err := prepareSnapshotSourceChange( change, err := prepareSnapshotSourceChange(
sources.legacy.filePath, sources.legacy.filePath,
@@ -383,7 +276,7 @@ func commitKiroIdeTransaction(
} }
workspaceChange := false workspaceChange := false
for _, change := range prepared.changes { for _, change := range prepared.changes {
if change != nil && sameKiroIdePath(change.filePath, prepared.paths.configPath) { if change != nil && sameManagedPath(change.filePath, prepared.paths.configPath) {
workspaceChange = true workspaceChange = true
break break
} }
+36 -252
View File
@@ -1,157 +1,13 @@
package plugins package plugins
import ( import (
"bytes"
"fmt"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"strings"
) )
func readKiroIdeRuntimeConfig(path string) (map[string]any, bool, error) { func kiroIdeExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) return []byte(generateGuardScript(kiroIdeAgentKey, agentID, "", false, "")),
if err != nil || snapshot == nil { []byte(buildHookScriptWithOutput(kiroIdeAgentKey, agentID, "", false, true))
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireKiroIdeRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateKiroIdeRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireKiroIdeRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireKiroIdeRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireKiroIdeRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameKiroIdeAgentID(agentID, expectedAgentID) ||
config["agent_name"] != kiroIdeAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Kiro IDE hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireKiroIdeRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireKiroIdeRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateKiroIdeRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readKiroIdeRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, kiroIdeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, kiroIdeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != kiroIdeAgentKey ||
!sameKiroIdeAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Kiro IDE agent %s: %s",
agentID,
configPath,
)
}
return nil
} }
func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool { func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool {
@@ -161,117 +17,45 @@ func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool
return uint32(snapshot.mode.Perm()) == expected return uint32(snapshot.mode.Perm()) == expected
} }
func kiroIdeRuntimeContractExists(contract kiroIdeRuntimeContract) (bool, error) { // kiroIdeRuntimeModesMatch requires the private modes the installer writes.
runtimeRoot, err := AgentRuntimeRoot() func kiroIdeRuntimeModesMatch(contract managedRuntimeContract) (bool, error) {
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath) agentDirectory := filepath.Dir(contract.guardPath)
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) || for _, item := range []struct {
!sameKiroIdeAgentID(filepath.Base(agentDirectory), contract.agentID) || path, label string
!sameKiroIdePath( limit int64
contract.guardPath, mode uint32
filepath.Join(agentDirectory, kiroIdeGuardScript), }{
) || {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", maxRuntimeConfigBytes, 0600},
!sameKiroIdePath( {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes, 0600},
contract.auditPath, {contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes, 0700},
filepath.Join(agentDirectory, kiroIdeAuditScript), {contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes, 0700},
) { } {
snapshot, err := readManagedFile(item.path, item.label, item.limit)
if err != nil || snapshot == nil {
return false, err
}
if !kiroIdeRuntimeFileMode(snapshot, item.mode) {
return false, nil return false, nil
} }
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
} }
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
configSnapshot, err := readManagedFile(
configPath,
"Elydora runtime config",
maxRuntimeConfigBytes,
)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if configSnapshot == nil || key == nil || guard == nil || audit == nil {
return false, nil
}
config, err := decodeStrictJSONObject(
configSnapshot.contents,
fmt.Sprintf("Elydora runtime config at %s", configPath),
)
if err != nil {
return false, err
}
if err := validateKiroIdeRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
if !kiroIdeRuntimeFileMode(configSnapshot, 0600) ||
!kiroIdeRuntimeFileMode(key, 0600) ||
!kiroIdeRuntimeFileMode(guard, 0700) ||
!kiroIdeRuntimeFileMode(audit, 0700) {
return false, nil
}
expectedGuard := []byte(generateGuardScript(
kiroIdeAgentKey,
contract.agentID,
"",
false,
"",
))
expectedAudit := []byte(buildHookScriptWithOutput(
kiroIdeAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func kiroIdeRuntimeFilesExist(contracts []kiroIdeRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := kiroIdeRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil return true, nil
} }
func kiroIdeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := managedRuntimeContractExists(
contract, kiroIdeAgentKey, "Kiro IDE", kiroIdeExpectedScripts,
)
if err != nil {
return false, err
}
if !exists {
continue
}
modes, err := kiroIdeRuntimeModesMatch(contract)
if err != nil || modes {
return modes, err
}
} }
return false, nil return false, nil
} }
+3 -3
View File
@@ -164,7 +164,7 @@ func TestKiroIdeStatusRequiresUniqueExactPairAndRuntimeIntegrity(t *testing.T) {
document = readKiroIdeObject(t, fixture.configPath) document = readKiroIdeObject(t, fixture.configPath)
document["hooks"] = append( document["hooks"] = append(
requireArray(t, document["hooks"]), requireArray(t, document["hooks"]),
cloneKiroIdeObject(findKiroIdeHook(t, document, kiroIdeGuardName)), cloneJSONObject(findKiroIdeHook(t, document, kiroIdeGuardName)),
) )
writeKiroIdeJSON(t, fixture.configPath, document) writeKiroIdeJSON(t, fixture.configPath, document)
assertStatus(false, false) assertStatus(false, false)
@@ -283,7 +283,7 @@ func TestKiroIdeStatusRejectsExtraOrphanAndStaleRuntimeCommands(t *testing.T) {
name = "node.exe" name = "node.exe"
} }
staleNode := filepath.Join(fixture.homeDir, "old-node", name) staleNode := filepath.Join(fixture.homeDir, "old-node", name)
command, err := buildKiroIdeCommand(staleNode, fixture.guardPath) command, err := buildEncodedCommand("Kiro IDE", staleNode, fixture.guardPath)
if err != nil { if err != nil {
t.Fatalf("build stale Kiro IDE command: %v", err) t.Fatalf("build stale Kiro IDE command: %v", err)
} }
@@ -331,7 +331,7 @@ func TestKiroIdeStatusRejectsStaleWindowsPowerShellLauncher(t *testing.T) {
command := action["command"].(string) command := action["command"].(string)
action["command"] = strings.Replace( action["command"] = strings.Replace(
command, command,
codexPowerShellPath(), windowsPowerShellPath(),
`C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`, `C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`,
1, 1,
) )
@@ -20,7 +20,7 @@ func TestKiroIdeRollbackRestoresRuntimeWorkspaceAndLegacySources(t *testing.T) {
legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID), legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID),
) )
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameKiroIdePath(source, fixture.legacyPath) && if sameManagedPath(source, fixture.legacyPath) &&
strings.HasSuffix(destination, ".rollback") { strings.HasSuffix(destination, ".rollback") {
return errors.New("injected Kiro IDE legacy cleanup failure") return errors.New("injected Kiro IDE legacy cleanup failure")
} }
@@ -320,7 +320,7 @@ func TestKiroIdeInstallSurvivesAdversarialWorkspaceLinkSwap(t *testing.T) {
} }
swapped := false swapped := false
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if !swapped && sameKiroIdePath(destination, fixture.configPath) && if !swapped && sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
swapped = true swapped = true
backup := kiroDirectory + ".original" backup := kiroDirectory + ".original"
+13 -2
View File
@@ -2,8 +2,7 @@ package plugins
import "strings" import "strings"
// extractElydoraScriptPath supports adapters that still use the legacy shared // extractElydoraScriptPath reads the legacy shared command-hook shape.
// command-hook shape. Provider-specific adapters should define exact ownership.
func extractElydoraScriptPath(hookArray any) string { func extractElydoraScriptPath(hookArray any) string {
entries, _ := hookArray.([]any) entries, _ := hookArray.([]any)
for _, entry := range entries { for _, entry := range entries {
@@ -43,6 +42,18 @@ func extractPathFromNodeCommand(command string) string {
return "" return ""
} }
func withoutElydoraEntries(hookArray any) []any {
entries, _ := hookArray.([]any)
kept := make([]any, 0, len(entries))
for _, entry := range entries {
if object, ok := entry.(map[string]any); ok && isElydoraHookEntry(object) {
continue
}
kept = append(kept, entry)
}
return kept
}
func hasElydoraEntry(hookArray any) bool { func hasElydoraEntry(hookArray any) bool {
entries, _ := hookArray.([]any) entries, _ := hookArray.([]any)
for _, entry := range entries { for _, entry := range entries {
+34 -120
View File
@@ -6,11 +6,10 @@ import (
"path/filepath" "path/filepath"
) )
// LettaPlugin manages the Elydora audit hook for Letta Code. // LettaPlugin merges Elydora hooks into ~/.letta/settings.json.
// It merges PreToolUse/PostToolUse hooks into ~/.letta/settings.json.
type LettaPlugin struct{} type LettaPlugin struct{}
func (p *LettaPlugin) configPath() (string, error) { func lettaConfigPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err) return "", fmt.Errorf("resolve home directory: %w", err)
@@ -18,6 +17,16 @@ func (p *LettaPlugin) configPath() (string, error) {
return filepath.Join(home, ".letta", "settings.json"), nil return filepath.Join(home, ".letta", "settings.json"), nil
} }
func lettaHookEntry(scriptPath string) map[string]any {
return map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command",
"command": "node " + scriptPath,
}},
}
}
func (p *LettaPlugin) Install(config InstallConfig) error { func (p *LettaPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID) scriptPath, err := hookScriptPath(config.AgentID)
if err != nil { if err != nil {
@@ -26,11 +35,9 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
if config.HookScript != "" { if config.HookScript != "" {
scriptPath = config.HookScript scriptPath = config.HookScript
} }
if err := GenerateHookScript(scriptPath, config); err != nil { if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err) return fmt.Errorf("generate hook script: %w", err)
} }
guardPath := config.GuardScriptPath guardPath := config.GuardScriptPath
if guardPath == "" { if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID) guardPath, err = guardScriptPath(config.AgentID)
@@ -38,70 +45,21 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
return err return err
} }
} }
configPath, err := lettaConfigPath()
configPath, err := p.configPath()
if err != nil { if err != nil {
return err return err
} }
settings, err := readJSONFile(configPath) settings, err := readJSONFile(configPath)
if err != nil { if err != nil {
return err return err
} }
hooks, _ := settings["hooks"].(map[string]any)
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil { if hooks == nil {
hooks = make(map[string]interface{}) hooks = map[string]any{}
} }
hooks["PreToolUse"] = append(withoutElydoraEntries(hooks["PreToolUse"]), lettaHookEntry(guardPath))
// --- PreToolUse (guard — freeze enforcement) --- hooks["PostToolUse"] = append(withoutElydoraEntries(hooks["PostToolUse"]), lettaHookEntry(scriptPath))
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{}{
"matcher": "*",
"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{}{
"matcher": "*",
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + scriptPath,
},
},
}
postFiltered = append(postFiltered, hookEntry)
hooks["PostToolUse"] = postFiltered
settings["hooks"] = hooks settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil { if err := writeJSONFile(configPath, settings); err != nil {
return err return err
} }
@@ -110,74 +68,38 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
} }
func (p *LettaPlugin) Uninstall(agentID string) error { func (p *LettaPlugin) Uninstall(agentID string) error {
configPath, err := p.configPath() configPath, err := lettaConfigPath()
if err != nil { if err != nil {
return err return err
} }
settings, err := readJSONFile(configPath) settings, err := readJSONFile(configPath)
if err != nil { if err != nil {
return err return err
} }
hooks, _ := settings["hooks"].(map[string]any)
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil { if hooks == nil {
fmt.Println("No Letta Code hooks found.") fmt.Println("No Letta Code hooks found.")
return nil return nil
} }
for _, event := range []string{"PreToolUse", "PostToolUse"} {
// Remove PreToolUse Elydora entries kept := withoutElydoraEntries(hooks[event])
preToolUse, _ := hooks["PreToolUse"].([]interface{}) if len(kept) == 0 {
var preFiltered []interface{} delete(hooks, event)
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 { } else {
hooks["PreToolUse"] = preFiltered hooks[event] = kept
}
// 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 { if len(hooks) == 0 {
delete(settings, "hooks") delete(settings, "hooks")
} else { } else {
settings["hooks"] = hooks settings["hooks"] = hooks
} }
if err := writeJSONFile(configPath, settings); err != nil { if err := writeJSONFile(configPath, settings); err != nil {
return err return err
} }
if agentID != "" { if agentID != "" {
scriptPath, _ := hookScriptPath(agentID) if err := removeAgentScripts(agentID); err != nil {
if scriptPath != "" { return err
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
} }
} }
fmt.Println("Uninstalled Elydora hook for Letta Code.") fmt.Println("Uninstalled Elydora hook for Letta Code.")
@@ -185,38 +107,30 @@ func (p *LettaPlugin) Uninstall(agentID string) error {
} }
func (p *LettaPlugin) Status() (PluginStatus, error) { func (p *LettaPlugin) Status() (PluginStatus, error) {
configPath, err := p.configPath() configPath, err := lettaConfigPath()
if err != nil { if err != nil {
return PluginStatus{}, err return PluginStatus{}, err
} }
status := PluginStatus{ status := PluginStatus{
AgentName: "letta", AgentName: "letta",
DisplayName: "Letta Code", DisplayName: "Letta Code",
ConfigPath: configPath, ConfigPath: configPath,
} }
settings, err := readJSONFile(configPath) settings, err := readJSONFile(configPath)
if err != nil { if err != nil {
return status, err
}
hooks, _ := settings["hooks"].(map[string]any)
if hooks == nil {
return status, nil return status, nil
} }
status.HookConfigured = hasElydoraEntry(hooks["PreToolUse"]) &&
hooks, _ := settings["hooks"].(map[string]interface{}) hasElydoraEntry(hooks["PostToolUse"])
if hooks != nil { if scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]); scriptPath != "" {
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 { if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true status.HookScriptExists = true
} }
} }
}
status.Installed = status.HookConfigured && status.HookScriptExists status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil return status, nil
} }
+95
View File
@@ -0,0 +1,95 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
)
func buildManagedRuntimeConfig(config InstallConfig, agentKey string) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": agentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
// managedRuntimeFileChanges prepares the guard, config, key, and audit runtime changes.
func managedRuntimeFileChanges(
config InstallConfig,
paths *managedRuntimePaths,
agentKey, guardScript, auditScript string,
) ([]*fileChange, error) {
runtimeConfig, err := buildManagedRuntimeConfig(config, agentKey)
if err != nil {
return nil, err
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{paths.guardPath, "Elydora guard runtime", []byte(guardScript), 0700},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{paths.auditPath, "Elydora audit runtime", []byte(auditScript), 0700},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
return changes, nil
}
func hasFileChanges(changes []*fileChange) bool {
for _, change := range changes {
if change != nil {
return true
}
}
return false
}
// writeManagedChanges creates the runtime and settings directories, then commits.
func writeManagedChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot, agentDirectory, settingsDirectory, settingsLabel string,
) error {
if !hasFileChanges(changes) {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if settingsDirectory != "" {
if err := ensureManagedDirectory(settingsDirectory, settingsLabel); err != nil {
return err
}
}
return writeChanges(changes, label, rename)
}
+413
View File
@@ -0,0 +1,413 @@
package plugins
import (
"fmt"
"path/filepath"
"runtime"
"sort"
"strings"
)
type managedRuntimeContract struct {
agentID string
guardPath string
auditPath string
}
type managedRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
type managedScriptReference struct {
agentID string
scriptPath string
}
func stringSet(values ...string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
result[value] = struct{}{}
}
return result
}
var managedRuntimeConfigFields = stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
func sameManagedPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
return sameManagedName(left, right)
}
// sameManagedName compares identifiers case-insensitively on Windows.
func sameManagedName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameManagedAgentID(left, right string) bool {
return sameManagedName(left, right)
}
func managedReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
// managedScriptWithin accepts absolute <runtimeRoot>/<agent>/<scriptName> paths.
func managedScriptWithin(scriptPath, scriptName, runtimeRoot string) *managedScriptReference {
if !filepath.IsAbs(scriptPath) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
return nil
}
agentDirectory := filepath.Dir(scriptPath)
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil
}
return &managedScriptReference{agentID: agentID, scriptPath: scriptPath}
}
func resolveManagedScript(scriptPath, scriptName string) (*managedScriptReference, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
return managedScriptWithin(scriptPath, scriptName, runtimeRoot), nil
}
func validateManagedInstallConfig(config InstallConfig, agentKey, product string) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if strings.TrimSpace(field.value) == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != agentKey {
return fmt.Errorf("%s installation requires agent name %s", product, agentKey)
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func resolveManagedRuntimePaths(
config InstallConfig,
guardScript, auditScript string,
) (*managedRuntimePaths, error) {
if config.AgentID == "" {
return nil, fmt.Errorf("agent ID is required")
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &managedRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, guardScript),
auditPath: filepath.Join(agentDirectory, auditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameManagedPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameManagedPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func readManagedRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireManagedRuntimeString(config map[string]any, field, configPath string) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf("elydora runtime config %s is invalid: %s", field, configPath)
}
return value, nil
}
func validateManagedRuntimeConfig(
config map[string]any,
expectedAgentID, configPath, agentKey, product string,
) error {
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := managedRuntimeConfigFields[field]; !ok {
return fmt.Errorf(
`elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
for _, field := range []string{"org_id", "kid"} {
if _, err := requireManagedRuntimeString(config, field, configPath); err != nil {
return err
}
}
agentID, err := requireManagedRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameManagedAgentID(agentID, expectedAgentID) || config["agent_name"] != agentKey {
return fmt.Errorf(
"elydora runtime identity does not match %s hooks: %s",
product,
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireManagedRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireManagedRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
}
return nil
}
type managedArtifact struct {
path string
label string
limit int64
}
func managedRuntimeArtifacts(agentDirectory, guardScript, auditScript string) []managedArtifact {
return []managedArtifact{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, guardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, auditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
}
}
// validateManagedRuntimeIdentity rejects an agent directory owned by another identity.
func validateManagedRuntimeIdentity(
agentDirectory, agentID, agentKey, product string,
extraArtifacts ...managedArtifact,
) error {
rootExists, err := managedPhysicalDirectoryExists(
filepath.Dir(agentDirectory), "Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory, "Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readManagedRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
artifacts := append(managedRuntimeArtifacts(agentDirectory, "guard.js", "hook.js"), extraArtifacts...)
for _, item := range artifacts {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != agentKey || !sameManagedAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match %s agent %s: %s",
product,
agentID,
configPath,
)
}
return nil
}
type expectedRuntimeScripts func(agentID string, config map[string]any) (guard, audit []byte)
// managedRuntimeContractExists is the strict status check; nil expected accepts non-empty scripts.
func managedRuntimeContractExists(
contract managedRuntimeContract,
agentKey, product string,
expected expectedRuntimeScripts,
) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameManagedPath(contract.auditPath, filepath.Join(agentDirectory, "hook.js")) {
return false, nil
}
for _, directory := range []struct{ path, label string }{
{runtimeRoot, "Elydora runtime directory"},
{agentDirectory, "Elydora agent runtime directory"},
} {
exists, err := managedPhysicalDirectoryExists(directory.path, directory.label)
if err != nil || !exists {
return false, err
}
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readManagedRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateManagedRuntimeConfig(config, contract.agentID, configPath, agentKey, product); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
}
if expected == nil {
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
}
expectedGuard, expectedAudit := expected(contract.agentID, config)
return string(guard.contents) == string(expectedGuard) &&
string(audit.contents) == string(expectedAudit), nil
}
func managedRuntimeFilesExist(
contracts []managedRuntimeContract,
agentKey, product string,
expected expectedRuntimeScripts,
) (bool, error) {
for _, contract := range contracts {
exists, err := managedRuntimeContractExists(contract, agentKey, product, expected)
if err != nil || exists {
return exists, err
}
}
return false, nil
}
// managedRuntimePresent is the presence-only status check; identity mismatch reports false.
func managedRuntimePresent(contract managedRuntimeContract, agentKey string) (bool, error) {
agentDirectory := filepath.Dir(contract.guardPath)
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory, "Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
config, exists, err := readManagedRuntimeConfig(filepath.Join(agentDirectory, "config.json"))
if err != nil {
return false, err
}
configuredID, idOK := config["agent_id"].(string)
if !exists || !idOK || config["agent_name"] != agentKey ||
!sameManagedAgentID(configuredID, contract.agentID) {
return false, nil
}
for _, item := range []managedArtifact{
{contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes},
{contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil || !exists {
return false, inspectErr
}
}
return true, nil
}
func managedRuntimeFilesPresent(contracts []managedRuntimeContract, agentKey string) (bool, error) {
for _, contract := range contracts {
present, err := managedRuntimePresent(contract, agentKey)
if err != nil || present {
return present, err
}
}
return false, nil
}
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more