Initial commit: Elydora Open Source — self-hosted audit platform for AI agents
Full self-hosted port of the Elydora tamper-evident audit protocol: - Hono API server with PostgreSQL, Redis, MinIO adapters - Ed25519 signed operations, chain hashing, Merkle epoch rollups - PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC - Next.js management console - Node.js, Python, and Go SDKs - Docker Compose for local deployment - Helm chart for Kubernetes - Comprehensive security hardening and audit fixes applied Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
15547a8c35
commit
e390e68594
207 files changed
+32790
-63
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of go coverage
|
||||
*.out
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
@@ -0,0 +1,280 @@
|
||||
# Elydora Go SDK
|
||||
|
||||
Official Go SDK for the [Elydora](https://elydora.com) tamper-evident audit platform. Build cryptographically verifiable audit trails for AI agent operations.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
||||
```
|
||||
|
||||
Requires Go 1.21+. Zero third-party dependencies (stdlib only).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Authenticate
|
||||
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{
|
||||
OrgID: auth.User.OrgID,
|
||||
AgentID: "my-agent-id",
|
||||
PrivateKey: "<base64url-encoded-ed25519-seed>",
|
||||
Token: auth.Token,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create and submit an operation
|
||||
eor, err := client.CreateOperation(&elydora.CreateOperationParams{
|
||||
OperationType: "data.access",
|
||||
Subject: map[string]interface{}{"user_id": "u-123"},
|
||||
Action: map[string]interface{}{"type": "read"},
|
||||
Payload: map[string]interface{}{"record_id": "rec-456"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := client.SubmitOperation(eor)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Receipt: %s\n", resp.Receipt.ReceiptID)
|
||||
}
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
The SDK includes a CLI for installing audit hooks into AI coding agents.
|
||||
|
||||
```bash
|
||||
go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest
|
||||
|
||||
elydora install \
|
||||
--agent claudecode \
|
||||
--org-id org-123 \
|
||||
--agent-id agent-456 \
|
||||
--private-key <key> \
|
||||
--kid agent-456-key-v1
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `elydora install` | Install Elydora audit hook for a coding agent |
|
||||
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
|
||||
| `elydora status` | Show installation status for all agents |
|
||||
| `elydora agents` | List supported coding agents |
|
||||
|
||||
### Supported Agents
|
||||
|
||||
| Agent | Key |
|
||||
|-------|-----|
|
||||
| Claude Code | `claudecode` |
|
||||
| Copilot CLI | `copilot` |
|
||||
| Cursor | `cursor` |
|
||||
| Gemini CLI | `gemini` |
|
||||
| Kiro CLI | `kirocli` |
|
||||
| Kiro IDE | `kiroide` |
|
||||
| Letta Code | `letta` |
|
||||
| OpenCode | `opencode` |
|
||||
|
||||
## API Reference
|
||||
|
||||
### Configuration
|
||||
|
||||
```go
|
||||
client, err := elydora.NewClient(&elydora.Config{
|
||||
OrgID: "org-123", // Organization ID
|
||||
AgentID: "agent-456", // Agent ID
|
||||
PrivateKey: "<seed>", // Base64url-encoded Ed25519 seed
|
||||
BaseURL: "https://...", // API base URL (default: https://api.elydora.com)
|
||||
TTLMs: 30000, // Operation TTL in ms (default: 30000)
|
||||
MaxRetries: 3, // Max retries on transient failures (default: 3)
|
||||
Token: "<jwt>", // JWT token for authenticated requests
|
||||
})
|
||||
```
|
||||
|
||||
### Client Methods
|
||||
|
||||
```go
|
||||
// Update the JWT token at runtime
|
||||
client.SetToken("new-jwt-token")
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```go
|
||||
// Register a new user and organization
|
||||
reg, err := elydora.Register(baseURL, email, password,
|
||||
elydora.WithDisplayName("Alice"),
|
||||
elydora.WithOrgName("Acme Corp"),
|
||||
)
|
||||
|
||||
// Login and receive a JWT
|
||||
auth, err := elydora.Login(baseURL, email, password)
|
||||
|
||||
// Get current authenticated user profile
|
||||
me, err := client.GetMe()
|
||||
|
||||
// Issue a new API token (with optional TTL)
|
||||
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
|
||||
TTLSeconds: &ttlSeconds,
|
||||
})
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
```go
|
||||
// Create a signed EOR locally (no network call)
|
||||
eor, err := client.CreateOperation(&elydora.CreateOperationParams{
|
||||
OperationType: "inference",
|
||||
Subject: map[string]interface{}{"model": "gpt-4"},
|
||||
Action: map[string]interface{}{"type": "completion"},
|
||||
Payload: map[string]interface{}{"prompt": "Hello"},
|
||||
KID: "agent-456-key-v1",
|
||||
})
|
||||
|
||||
// Submit to API
|
||||
resp, err := client.SubmitOperation(eor)
|
||||
|
||||
// Retrieve an operation
|
||||
op, err := client.GetOperation(operationID)
|
||||
|
||||
// Verify integrity
|
||||
result, err := client.VerifyOperation(operationID)
|
||||
```
|
||||
|
||||
### Agent Management
|
||||
|
||||
```go
|
||||
// Register a new agent
|
||||
agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
|
||||
AgentID: "my-agent",
|
||||
DisplayName: "My Agent",
|
||||
ResponsibleEntity: "team@example.com",
|
||||
Keys: []elydora.RegisterAgentKeyInput{
|
||||
{KID: "key-v1", PublicKey: "<base64url>", Algorithm: "ed25519"},
|
||||
},
|
||||
})
|
||||
|
||||
// Get agent details
|
||||
details, err := client.GetAgent(agentID)
|
||||
|
||||
// Freeze an agent
|
||||
err := client.FreezeAgent(agentID, "security review")
|
||||
|
||||
// Revoke a key
|
||||
err := client.RevokeKey(agentID, kid, "key rotation")
|
||||
|
||||
// List all agents in the organization
|
||||
agents, err := client.ListAgents()
|
||||
|
||||
// Unfreeze a previously frozen agent
|
||||
err := client.UnfreezeAgent(agentID, "review complete")
|
||||
|
||||
// Delete an agent permanently
|
||||
deleted, err := client.DeleteAgent(agentID)
|
||||
```
|
||||
|
||||
### Audit
|
||||
|
||||
```go
|
||||
results, err := client.QueryAudit(&elydora.AuditQueryRequest{
|
||||
AgentID: "agent-123",
|
||||
OperationType: "inference",
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
Limit: &limit,
|
||||
})
|
||||
```
|
||||
|
||||
### Epochs
|
||||
|
||||
```go
|
||||
epochs, err := client.ListEpochs()
|
||||
epoch, err := client.GetEpoch(epochID)
|
||||
```
|
||||
|
||||
### Exports
|
||||
|
||||
```go
|
||||
export, err := client.CreateExport(&elydora.CreateExportRequest{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Format: "json",
|
||||
})
|
||||
|
||||
exports, err := client.ListExports()
|
||||
detail, err := client.GetExport(exportID)
|
||||
|
||||
// Download export file data
|
||||
data, err := client.DownloadExport(exportID)
|
||||
```
|
||||
|
||||
### JWKS
|
||||
|
||||
```go
|
||||
jwks, err := client.GetJWKS()
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```go
|
||||
// Check API health (no authentication required, does not need a client)
|
||||
health, err := elydora.Health("https://api.elydora.com")
|
||||
// health.Status, health.Version, health.ProtocolVersion, health.Timestamp
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```go
|
||||
// Genesis chain hash — initial prev_chain_hash for an agent's first operation
|
||||
elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
```
|
||||
|
||||
#### Status Enums
|
||||
|
||||
| Type | Constants |
|
||||
|------|-----------|
|
||||
| `AgentStatus` | `AgentStatusActive`, `AgentStatusFrozen`, `AgentStatusRevoked` |
|
||||
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
|
||||
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
|
||||
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` |
|
||||
| `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` |
|
||||
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeAgentFrozen`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
import "errors"
|
||||
|
||||
var apiErr *elydora.ElydoraError
|
||||
if errors.As(err, &apiErr) {
|
||||
fmt.Println(apiErr.Code) // e.g. "INVALID_SIGNATURE"
|
||||
fmt.Println(apiErr.Message) // Human-readable message
|
||||
fmt.Println(apiErr.StatusCode) // HTTP status code
|
||||
fmt.Println(apiErr.RequestID) // Request ID for support
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,64 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RegisterAgent registers a new AI agent within the organization.
|
||||
func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) {
|
||||
var result RegisterAgentResponse
|
||||
if err := c.doPost("/v1/agents/register", req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAgent retrieves an agent by ID.
|
||||
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
||||
var result GetAgentResponse
|
||||
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", agentID), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FreezeAgent freezes an agent, preventing it from submitting operations.
|
||||
func (c *Client) FreezeAgent(agentID, reason string) error {
|
||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{
|
||||
Reason: reason,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// RevokeKey revokes a specific key for an agent.
|
||||
func (c *Client) RevokeKey(agentID, kid, reason string) error {
|
||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", agentID), &RevokeAgentRequest{
|
||||
KID: kid,
|
||||
Reason: reason,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ListAgents retrieves all agents for the organization.
|
||||
func (c *Client) ListAgents() (*ListAgentsResponse, error) {
|
||||
var result ListAgentsResponse
|
||||
if err := c.doGet("/v1/agents", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UnfreezeAgent unfreezes a previously frozen agent.
|
||||
func (c *Client) UnfreezeAgent(agentID, reason string) error {
|
||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{
|
||||
Reason: reason,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// DeleteAgent permanently deletes an agent and all associated data.
|
||||
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
|
||||
var result DeleteAgentResponse
|
||||
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", agentID), nil, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package elydora
|
||||
|
||||
// QueryAudit queries the audit log with the given parameters.
|
||||
func (c *Client) QueryAudit(params *AuditQueryRequest) (*AuditQueryResponse, error) {
|
||||
var result AuditQueryResponse
|
||||
if err := c.doPost("/v1/audit/query", params, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegisterOption is a functional option for the Register function.
|
||||
type RegisterOption func(*AuthRegisterRequest)
|
||||
|
||||
// WithDisplayName sets the display_name on a registration request.
|
||||
func WithDisplayName(name string) RegisterOption {
|
||||
return func(req *AuthRegisterRequest) {
|
||||
req.DisplayName = name
|
||||
}
|
||||
}
|
||||
|
||||
// WithOrgName sets the org_name on a registration request.
|
||||
func WithOrgName(name string) RegisterOption {
|
||||
return func(req *AuthRegisterRequest) {
|
||||
req.OrgName = name
|
||||
}
|
||||
}
|
||||
|
||||
// Register creates a new user and organization.
|
||||
func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
req := &AuthRegisterRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(req)
|
||||
}
|
||||
|
||||
var result AuthRegisterResponse
|
||||
if err := authPost(baseURL, "/v1/auth/register", req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT token.
|
||||
func Login(baseURL, email, password string) (*AuthLoginResponse, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
req := &AuthLoginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
var result AuthLoginResponse
|
||||
if err := authPost(baseURL, "/v1/auth/login", req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// authPost is a helper for unauthenticated POST requests used by Register and Login.
|
||||
func authPost(baseURL, path string, body interface{}, result interface{}) error {
|
||||
u := baseURL + path
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("elydora: marshal request body: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("elydora: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("elydora: http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("elydora: read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("elydora: unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errResp errorResponse
|
||||
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
|
||||
return &ElydoraError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: errResp.Error.Code,
|
||||
Message: errResp.Error.Message,
|
||||
RequestID: errResp.Error.RequestID,
|
||||
Details: errResp.Error.Details,
|
||||
}
|
||||
}
|
||||
|
||||
return &ElydoraError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: string(respBody),
|
||||
}
|
||||
}
|
||||
|
||||
// GetMe retrieves the current authenticated user's profile.
|
||||
func (c *Client) GetMe() (*GetMeResponse, error) {
|
||||
var result GetMeResponse
|
||||
if err := c.doGet("/v1/auth/me", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// IssueApiToken creates a new API token with an optional TTL.
|
||||
func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenResponse, error) {
|
||||
var result IssueApiTokenResponse
|
||||
if err := c.doPost("/v1/auth/token", req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://api.elydora.com"
|
||||
defaultTTLMs = 30000
|
||||
defaultMaxRetries = 3
|
||||
)
|
||||
|
||||
// Config holds the configuration for creating a new Client.
|
||||
type Config struct {
|
||||
OrgID string
|
||||
AgentID string
|
||||
PrivateKey string // base64url-encoded 32-byte Ed25519 seed
|
||||
BaseURL string // defaults to "https://api.elydora.com"
|
||||
TTLMs int // defaults to 30000
|
||||
MaxRetries int // defaults to 3
|
||||
Token string // JWT token for authenticated requests
|
||||
}
|
||||
|
||||
// Client is the Elydora SDK client.
|
||||
type Client struct {
|
||||
orgID string
|
||||
agentID string
|
||||
privateKey string
|
||||
baseURL string
|
||||
ttlMs int
|
||||
maxRetries int
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Elydora Client with the given configuration.
|
||||
func NewClient(cfg *Config) (*Client, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("elydora: config must not be nil")
|
||||
}
|
||||
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
ttlMs := cfg.TTLMs
|
||||
if ttlMs <= 0 {
|
||||
ttlMs = defaultTTLMs
|
||||
}
|
||||
|
||||
maxRetries := cfg.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = defaultMaxRetries
|
||||
}
|
||||
|
||||
return &Client{
|
||||
orgID: cfg.OrgID,
|
||||
agentID: cfg.AgentID,
|
||||
privateKey: cfg.PrivateKey,
|
||||
baseURL: baseURL,
|
||||
ttlMs: ttlMs,
|
||||
maxRetries: maxRetries,
|
||||
token: cfg.Token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}, 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 JWT token used for authenticated API requests.
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.token = token
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins"
|
||||
)
|
||||
|
||||
const version = "1.1.0"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "install":
|
||||
cmdInstall(os.Args[2:])
|
||||
case "uninstall":
|
||||
cmdUninstall(os.Args[2:])
|
||||
case "status":
|
||||
cmdStatus(os.Args[2:])
|
||||
case "agents":
|
||||
cmdAgents()
|
||||
case "version", "--version", "-v":
|
||||
fmt.Printf("elydora %s\n", version)
|
||||
case "help", "--help", "-h":
|
||||
printUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1])
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintf(os.Stderr, `Elydora CLI — agent hook installer for tamper-evident audit logging.
|
||||
|
||||
Usage:
|
||||
elydora <command> [flags]
|
||||
|
||||
Commands:
|
||||
install Install Elydora audit hook for an agent
|
||||
uninstall Remove Elydora audit hook for an agent
|
||||
status Show installation status for an agent (or all agents)
|
||||
agents List all supported agents
|
||||
version Print version
|
||||
help Show this help
|
||||
|
||||
Run "elydora <command> -h" for details on each command.
|
||||
`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdInstall(args []string) {
|
||||
fs := flag.NewFlagSet("install", flag.ExitOnError)
|
||||
agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.")
|
||||
orgID := fs.String("org-id", "", "Organization ID (required)")
|
||||
agentID := fs.String("agent-id", "", "Agent ID (required)")
|
||||
privateKey := fs.String("private-key", "", "Base64url-encoded Ed25519 private key seed (required)")
|
||||
kid := fs.String("kid", "", "Key ID (defaults to <agent-id>-key-1)")
|
||||
token := fs.String("token", "", "JWT token for authenticated requests (optional)")
|
||||
baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
if *agent == "" || *orgID == "" || *agentID == "" || *privateKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, --agent-id, and --private-key are required.")
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *kid == "" {
|
||||
*kid = *agentID + "-key-1"
|
||||
}
|
||||
|
||||
plugin := plugins.NewPlugin(*agent)
|
||||
if plugin == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate guard script (PreToolUse — freeze enforcement)
|
||||
guardScriptPath, err := guardScriptPathForAgent(*agentID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
guardScript := plugins.GenerateGuardScript(*agent, *agentID)
|
||||
if err := os.MkdirAll(filepath.Dir(guardScriptPath), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating guard script directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(guardScriptPath, []byte(guardScript), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
||||
|
||||
config := plugins.InstallConfig{
|
||||
AgentName: *agent,
|
||||
OrgID: *orgID,
|
||||
AgentID: *agentID,
|
||||
PrivateKey: *privateKey,
|
||||
KID: *kid,
|
||||
Token: *token,
|
||||
BaseURL: *baseURL,
|
||||
GuardScriptPath: guardScriptPath,
|
||||
}
|
||||
|
||||
if err := plugin.Install(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdUninstall(args []string) {
|
||||
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
||||
agent := fs.String("agent", "", "Agent name (required)")
|
||||
agentID := fs.String("agent-id", "", "Agent ID (if omitted, scans ~/.elydora/*/config.json for matching agent_name)")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
if *agent == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --agent is required.")
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
plugin := plugins.NewPlugin(*agent)
|
||||
if plugin == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Resolve agentID: if not given, scan ~/.elydora/*/config.json for matching agent_name
|
||||
resolvedAgentID := *agentID
|
||||
if resolvedAgentID == "" {
|
||||
resolvedAgentID = findAgentIDByName(*agent)
|
||||
if resolvedAgentID == "" {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not find agent ID for %q in ~/.elydora/*/config.json\n", *agent)
|
||||
}
|
||||
}
|
||||
|
||||
if err := plugin.Uninstall(resolvedAgentID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Remove the per-agent directory
|
||||
if resolvedAgentID != "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
agentDir := filepath.Join(home, ".elydora", resolvedAgentID)
|
||||
if err := os.RemoveAll(agentDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to remove agent directory %s: %v\n", agentDir, err)
|
||||
} else {
|
||||
fmt.Printf(" Removed agent directory: %s\n", agentDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findAgentIDByName scans ~/.elydora/*/config.json for a config whose agent_name
|
||||
// matches the given name and returns the directory name (agent ID).
|
||||
func findAgentIDByName(agentName string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
elydoraDir := filepath.Join(home, ".elydora")
|
||||
entries, err := os.ReadDir(elydoraDir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
configPath := filepath.Join(elydoraDir, entry.Name(), "config.json")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
continue
|
||||
}
|
||||
if name, _ := config["agent_name"].(string); name == agentName {
|
||||
return entry.Name()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdStatus(args []string) {
|
||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||
agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
if *agent != "" {
|
||||
showAgentStatus(*agent)
|
||||
return
|
||||
}
|
||||
|
||||
// Show status for all agents
|
||||
names := sortedAgentNames()
|
||||
for _, name := range names {
|
||||
showAgentStatus(name)
|
||||
}
|
||||
}
|
||||
|
||||
func showAgentStatus(name string) {
|
||||
plugin := plugins.NewPlugin(name)
|
||||
if plugin == nil {
|
||||
fmt.Fprintf(os.Stderr, "Unknown agent: %s\n", name)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := plugin.Status()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " %s: error getting status: %v\n", name, err)
|
||||
return
|
||||
}
|
||||
|
||||
state := "not installed"
|
||||
if status.Installed {
|
||||
state = "installed"
|
||||
} else if status.HookConfigured && !status.HookScriptExists {
|
||||
state = "configured (hook script missing)"
|
||||
} else if !status.HookConfigured && status.HookScriptExists {
|
||||
state = "hook script exists (not configured)"
|
||||
}
|
||||
|
||||
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdAgents() {
|
||||
fmt.Println("Supported agents:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" %-14s %-20s %s\n", "ID", "Name", "Config")
|
||||
fmt.Printf(" %-14s %-20s %s\n", strings.Repeat("-", 14), strings.Repeat("-", 20), strings.Repeat("-", 30))
|
||||
|
||||
names := sortedAgentNames()
|
||||
for _, name := range names {
|
||||
entry := plugins.SupportedAgents[name]
|
||||
configPath := entry.ConfigDir + "/" + entry.ConfigFile // display only, not a filesystem path
|
||||
fmt.Printf(" %-14s %-20s %s\n", name, entry.Name, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedAgentNames() []string {
|
||||
names := make([]string, 0, len(plugins.SupportedAgents))
|
||||
for name := range plugins.SupportedAgents {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// guardScriptPathForAgent returns the path to ~/.elydora/<agentId>/guard.js.
|
||||
func guardScriptPathForAgent(agentId string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".elydora", agentId, "guard.js"), nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package plugins
|
||||
|
||||
// InstallConfig holds the configuration needed to install an agent plugin.
|
||||
type InstallConfig struct {
|
||||
AgentName string
|
||||
OrgID string
|
||||
AgentID string
|
||||
PrivateKey string
|
||||
KID string
|
||||
Token string
|
||||
BaseURL string
|
||||
HookScript string // absolute path to the generated hook script
|
||||
GuardScriptPath string // absolute path to the generated guard script (PreToolUse)
|
||||
}
|
||||
|
||||
// PluginStatus describes the current state of a plugin installation.
|
||||
type PluginStatus struct {
|
||||
Installed bool
|
||||
AgentName string
|
||||
DisplayName string
|
||||
HookConfigured bool
|
||||
HookScriptExists bool
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// AgentPlugin is the interface that every agent integration must implement.
|
||||
type AgentPlugin interface {
|
||||
Install(config InstallConfig) error
|
||||
Uninstall(agentID string) error
|
||||
Status() (PluginStatus, error)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClaudeCodePlugin manages the Elydora audit hook for Claude Code.
|
||||
// It merges a PostToolUse hook into ~/.claude/settings.json.
|
||||
type ClaudeCodePlugin struct{}
|
||||
|
||||
func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configDir, err := expandHome("~/.claude")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
hooks = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
guardEntry := map[string]interface{}{
|
||||
"hooks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "command",
|
||||
"command": "node " + guardPath,
|
||||
},
|
||||
},
|
||||
}
|
||||
preFiltered = append(preFiltered, guardEntry)
|
||||
hooks["PreToolUse"] = preFiltered
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
hookEntry := map[string]interface{}{
|
||||
"hooks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "command",
|
||||
"command": "node " + scriptPath,
|
||||
},
|
||||
},
|
||||
}
|
||||
postFiltered = append(postFiltered, hookEntry)
|
||||
hooks["PostToolUse"] = postFiltered
|
||||
|
||||
settings["hooks"] = hooks
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed Elydora hook for Claude Code at %s\n", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
|
||||
configDir, err := expandHome("~/.claude")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
fmt.Println("No Claude Code hooks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove PreToolUse Elydora entries
|
||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
if len(preFiltered) == 0 {
|
||||
delete(hooks, "PreToolUse")
|
||||
} else {
|
||||
hooks["PreToolUse"] = preFiltered
|
||||
}
|
||||
|
||||
// Remove PostToolUse Elydora entries
|
||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
if len(postFiltered) == 0 {
|
||||
delete(hooks, "PostToolUse")
|
||||
} else {
|
||||
hooks["PostToolUse"] = postFiltered
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
delete(settings, "hooks")
|
||||
} else {
|
||||
settings["hooks"] = hooks
|
||||
}
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
fmt.Println("Uninstalled Elydora hook for Claude Code.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ClaudeCodePlugin) Status() (PluginStatus, error) {
|
||||
configDir, err := expandHome("~/.claude")
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "claudecode",
|
||||
DisplayName: "Claude Code",
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
|
||||
// Check if hook is configured in settings and extract hook script path
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
|
||||
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
|
||||
status.HookConfigured = preConfigured && postConfigured
|
||||
|
||||
// Extract hook script path from the configured command
|
||||
scriptPath := extractElydoraScriptPath(hooks["PostToolUse"])
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// extractElydoraScriptPath extracts the script path from a hook array's Elydora command entry.
|
||||
func extractElydoraScriptPath(hookArray interface{}) string {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
// New format: { "hooks": [{ "type": "command", "command": "node /path/to/hook.js" }] }
|
||||
if innerHooks, ok := m["hooks"].([]interface{}); ok {
|
||||
for _, h := range innerHooks {
|
||||
if hm, ok := h.(map[string]interface{}); ok {
|
||||
if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return extractPathFromNodeCommand(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Old format: { "command": "node /path/to/hook.js" }
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return extractPathFromNodeCommand(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractPathFromNodeCommand extracts the file path from a "node /path/to/script.js" command.
|
||||
func extractPathFromNodeCommand(cmd string) string {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if strings.HasPrefix(cmd, "node ") {
|
||||
return strings.TrimSpace(cmd[5:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasElydoraEntry checks if a hook array (interface{}) contains an Elydora entry.
|
||||
func hasElydoraEntry(hookArray interface{}) bool {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isElydoraHookEntry checks if a hook entry (old or new format) is an Elydora hook.
|
||||
func isElydoraHookEntry(m map[string]interface{}) bool {
|
||||
// New format: { "matcher": {}, "hooks": [{ "type": "command", "command": "..." }] }
|
||||
if innerHooks, ok := m["hooks"].([]interface{}); ok {
|
||||
for _, h := range innerHooks {
|
||||
if hm, ok := h.(map[string]interface{}); ok {
|
||||
if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Old format: { "command": "..." }
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CopilotPlugin manages the Elydora audit hook for GitHub Copilot CLI.
|
||||
// It writes/merges into .github/hooks/hooks.json (project-relative) using
|
||||
// hooks.preToolUse[]/postToolUse[] with bash/powershell command fields.
|
||||
type CopilotPlugin struct{}
|
||||
|
||||
func (p *CopilotPlugin) configPath() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
return filepath.Join(cwd, ".github", "hooks", "hooks.json"), nil
|
||||
}
|
||||
|
||||
func (p *CopilotPlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure version field is set
|
||||
settings["version"] = float64(1)
|
||||
|
||||
// Ensure hooks object exists
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
hooks = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
preToolUse, _ := hooks["preToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isCopilotElydoraEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
guardEntry := map[string]interface{}{
|
||||
"type": "command",
|
||||
"bash": "node " + guardPath,
|
||||
"powershell": "node " + guardPath,
|
||||
"timeoutSec": float64(5),
|
||||
}
|
||||
preFiltered = append(preFiltered, guardEntry)
|
||||
hooks["preToolUse"] = preFiltered
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
postToolUse, _ := hooks["postToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isCopilotElydoraEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
hookEntry := map[string]interface{}{
|
||||
"type": "command",
|
||||
"bash": "node " + scriptPath,
|
||||
"powershell": "node " + scriptPath,
|
||||
"timeoutSec": float64(5),
|
||||
}
|
||||
postFiltered = append(postFiltered, hookEntry)
|
||||
hooks["postToolUse"] = postFiltered
|
||||
|
||||
settings["hooks"] = hooks
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed Elydora hook for Copilot CLI at %s\n", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CopilotPlugin) Uninstall(agentID string) error {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
fmt.Println("No Copilot CLI hooks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove preToolUse Elydora entries
|
||||
preToolUse, _ := hooks["preToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isCopilotElydoraEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
if len(preFiltered) == 0 {
|
||||
delete(hooks, "preToolUse")
|
||||
} else {
|
||||
hooks["preToolUse"] = preFiltered
|
||||
}
|
||||
|
||||
// Remove postToolUse Elydora entries
|
||||
postToolUse, _ := hooks["postToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isCopilotElydoraEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
if len(postFiltered) == 0 {
|
||||
delete(hooks, "postToolUse")
|
||||
} else {
|
||||
hooks["postToolUse"] = postFiltered
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
delete(settings, "hooks")
|
||||
} else {
|
||||
settings["hooks"] = hooks
|
||||
}
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
fmt.Println("Uninstalled Elydora hook for Copilot CLI.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CopilotPlugin) Status() (PluginStatus, error) {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "copilot",
|
||||
DisplayName: "Copilot CLI",
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
preConfigured := hasCopilotElydoraEntry(hooks["preToolUse"])
|
||||
postConfigured := hasCopilotElydoraEntry(hooks["postToolUse"])
|
||||
status.HookConfigured = preConfigured && postConfigured
|
||||
|
||||
// Extract hook script path from the configured command
|
||||
scriptPath := extractCopilotElydoraScriptPath(hooks["postToolUse"])
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// isCopilotElydoraEntry checks if a Copilot hook entry (bash/powershell fields) is an Elydora hook.
|
||||
func isCopilotElydoraEntry(m map[string]interface{}) bool {
|
||||
if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") {
|
||||
return true
|
||||
}
|
||||
if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasCopilotElydoraEntry checks if a Copilot hook array contains an Elydora entry.
|
||||
func hasCopilotElydoraEntry(hookArray interface{}) bool {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isCopilotElydoraEntry(m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractCopilotElydoraScriptPath extracts the script path from a Copilot hook array's Elydora command.
|
||||
func extractCopilotElydoraScriptPath(hookArray interface{}) string {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") {
|
||||
return extractPathFromNodeCommand(bash)
|
||||
}
|
||||
if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") {
|
||||
return extractPathFromNodeCommand(ps)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CursorPlugin manages the Elydora audit hook for Cursor.
|
||||
// It writes/merges into ~/.cursor/hooks.json using nested settings.hooks.postToolUse[].
|
||||
type CursorPlugin struct{}
|
||||
|
||||
func (p *CursorPlugin) configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".cursor", "hooks.json"), nil
|
||||
}
|
||||
|
||||
func (p *CursorPlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure hooks object exists
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
hooks = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
preToolUse, _ := hooks["preToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
guardEntry := map[string]interface{}{
|
||||
"command": "node " + guardPath,
|
||||
}
|
||||
preFiltered = append(preFiltered, guardEntry)
|
||||
hooks["preToolUse"] = preFiltered
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
postToolUse, _ := hooks["postToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
hookEntry := map[string]interface{}{
|
||||
"command": "node " + scriptPath,
|
||||
}
|
||||
postFiltered = append(postFiltered, hookEntry)
|
||||
hooks["postToolUse"] = postFiltered
|
||||
|
||||
settings["hooks"] = hooks
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed Elydora hook for Cursor at %s\n", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CursorPlugin) Uninstall(agentID string) error {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
fmt.Println("No Cursor hooks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove preToolUse Elydora entries
|
||||
preToolUse, _ := hooks["preToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
if len(preFiltered) == 0 {
|
||||
delete(hooks, "preToolUse")
|
||||
} else {
|
||||
hooks["preToolUse"] = preFiltered
|
||||
}
|
||||
|
||||
// Remove postToolUse Elydora entries
|
||||
postToolUse, _ := hooks["postToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
if len(postFiltered) == 0 {
|
||||
delete(hooks, "postToolUse")
|
||||
} else {
|
||||
hooks["postToolUse"] = postFiltered
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
delete(settings, "hooks")
|
||||
} else {
|
||||
settings["hooks"] = hooks
|
||||
}
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
fmt.Println("Uninstalled Elydora hook for Cursor.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CursorPlugin) Status() (PluginStatus, error) {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "cursor",
|
||||
DisplayName: "Cursor",
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
preConfigured := hasCursorElydoraEntry(hooks["preToolUse"])
|
||||
postConfigured := hasCursorElydoraEntry(hooks["postToolUse"])
|
||||
status.HookConfigured = preConfigured && postConfigured
|
||||
|
||||
// Extract hook script path from the configured command
|
||||
scriptPath := extractCursorElydoraScriptPath(hooks["postToolUse"])
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// extractCursorElydoraScriptPath extracts the script path from a Cursor hook array's Elydora command entry.
|
||||
func extractCursorElydoraScriptPath(hookArray interface{}) string {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return extractPathFromNodeCommand(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasCursorElydoraEntry checks if a Cursor hook array (flat format) contains an Elydora entry.
|
||||
func hasCursorElydoraEntry(hookArray interface{}) bool {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GeminiPlugin manages the Elydora audit hook for Gemini CLI.
|
||||
// It merges an AfterTool hook into ~/.gemini/settings.json.
|
||||
type GeminiPlugin struct{}
|
||||
|
||||
func (p *GeminiPlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configDir, err := expandHome("~/.gemini")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
hooks = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// --- BeforeTool (guard — freeze enforcement) ---
|
||||
beforeTool, _ := hooks["BeforeTool"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range beforeTool {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
guardEntry := map[string]interface{}{
|
||||
"hooks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "command",
|
||||
"command": "node " + guardPath,
|
||||
},
|
||||
},
|
||||
}
|
||||
preFiltered = append(preFiltered, guardEntry)
|
||||
hooks["BeforeTool"] = preFiltered
|
||||
|
||||
// --- AfterTool (audit logging) ---
|
||||
afterTool, _ := hooks["AfterTool"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range afterTool {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
hookEntry := map[string]interface{}{
|
||||
"hooks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "command",
|
||||
"command": "node " + scriptPath,
|
||||
},
|
||||
},
|
||||
}
|
||||
postFiltered = append(postFiltered, hookEntry)
|
||||
hooks["AfterTool"] = postFiltered
|
||||
|
||||
settings["hooks"] = hooks
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed Elydora hook for Gemini CLI at %s\n", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GeminiPlugin) Uninstall(agentID string) error {
|
||||
configDir, err := expandHome("~/.gemini")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
fmt.Println("No Gemini CLI hooks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove BeforeTool Elydora entries
|
||||
beforeTool, _ := hooks["BeforeTool"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range beforeTool {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
if len(preFiltered) == 0 {
|
||||
delete(hooks, "BeforeTool")
|
||||
} else {
|
||||
hooks["BeforeTool"] = preFiltered
|
||||
}
|
||||
|
||||
// Remove AfterTool Elydora entries
|
||||
afterTool, _ := hooks["AfterTool"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range afterTool {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
if len(postFiltered) == 0 {
|
||||
delete(hooks, "AfterTool")
|
||||
} else {
|
||||
hooks["AfterTool"] = postFiltered
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
delete(settings, "hooks")
|
||||
} else {
|
||||
settings["hooks"] = hooks
|
||||
}
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
fmt.Println("Uninstalled Elydora hook for Gemini CLI.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GeminiPlugin) Status() (PluginStatus, error) {
|
||||
configDir, err := expandHome("~/.gemini")
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "gemini",
|
||||
DisplayName: "Gemini CLI",
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
preConfigured := hasElydoraEntry(hooks["BeforeTool"])
|
||||
postConfigured := hasElydoraEntry(hooks["AfterTool"])
|
||||
status.HookConfigured = preConfigured && postConfigured
|
||||
|
||||
// Extract hook script path from the configured command
|
||||
scriptPath := extractElydoraScriptPath(hooks["AfterTool"])
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GenerateHookScript writes a self-contained Node.js hook script to disk.
|
||||
// The script reads config/key from ~/.elydora/<agentId>/ at runtime, persists
|
||||
// chain state, and performs fire-and-forget EOR submission with inline Ed25519 crypto.
|
||||
// It always exits 0 so it never blocks the host agent.
|
||||
func GenerateHookScript(destPath string, config InstallConfig) error {
|
||||
dir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create hook script directory: %w", err)
|
||||
}
|
||||
|
||||
// Write the agent config and key files for runtime reading
|
||||
if err := writeAgentConfig(config); err != nil {
|
||||
return fmt.Errorf("write agent config: %w", err)
|
||||
}
|
||||
|
||||
script := buildHookScript(config.AgentName, config.AgentID)
|
||||
if err := os.WriteFile(destPath, []byte(script), 0755); err != nil {
|
||||
return fmt.Errorf("write hook script: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAgentConfig persists the agent configuration and private key to
|
||||
// ~/.elydora/<agentId>/config.json and ~/.elydora/<agentId>/private.key so that
|
||||
// the generated hook script can read them at runtime without embedding secrets.
|
||||
func writeAgentConfig(config InstallConfig) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
agentDir := filepath.Join(home, ".elydora", config.AgentID)
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
return fmt.Errorf("create agent directory: %w", err)
|
||||
}
|
||||
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.elydora.com"
|
||||
}
|
||||
|
||||
configJSON := fmt.Sprintf(`{
|
||||
"org_id": %q,
|
||||
"agent_id": %q,
|
||||
"kid": %q,
|
||||
"base_url": %q,
|
||||
"token": %q,
|
||||
"agent_name": %q
|
||||
}
|
||||
`, config.OrgID, config.AgentID, config.KID, baseURL, config.Token, config.AgentName)
|
||||
|
||||
configPath := filepath.Join(agentDir, "config.json")
|
||||
// Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows.
|
||||
if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil {
|
||||
return fmt.Errorf("write agent config: %w", err)
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(agentDir, "private.key")
|
||||
// Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows.
|
||||
if err := os.WriteFile(keyPath, []byte(config.PrivateKey+"\n"), 0600); err != nil {
|
||||
return fmt.Errorf("write agent key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGuardScript returns a self-contained Node.js guard script that checks
|
||||
// whether the agent is frozen and blocks tool execution if so.
|
||||
// It caches the agent status locally (60s TTL) to avoid hitting the API on every
|
||||
// tool call, and fails open if the API is unreachable or config is missing.
|
||||
func GenerateGuardScript(agentName string, agentId string) string {
|
||||
return fmt.Sprintf(`#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elydora agent guard (PreToolUse) for: %s
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = %q;
|
||||
const AGENT_ID = %q;
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||
|
||||
// Consume stdin so the parent process doesn't block on a full pipe
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', () => {});
|
||||
|
||||
async function main() {
|
||||
// Read agent config
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return; // Can't read config — fail-open (allow)
|
||||
}
|
||||
|
||||
// Check cached status
|
||||
try {
|
||||
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
|
||||
const cache = JSON.parse(raw);
|
||||
const age = Date.now() - (cache.cached_at || 0);
|
||||
|
||||
if (age < CACHE_TTL_MS) {
|
||||
if (cache.status === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return; // Cache is fresh — use cached result
|
||||
}
|
||||
} catch {
|
||||
// No cache or invalid — need to check API
|
||||
}
|
||||
|
||||
// Fetch agent status from API
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
|
||||
const token = config.token || '';
|
||||
const agentId = config.agent_id;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
|
||||
headers: headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const agentStatus = (data.agent && data.agent.status) || 'active';
|
||||
|
||||
// Update cache
|
||||
try {
|
||||
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
|
||||
status: agentStatus,
|
||||
cached_at: Date.now(),
|
||||
}), 'utf-8');
|
||||
} catch {
|
||||
// Ignore cache write failures
|
||||
}
|
||||
|
||||
if (agentStatus === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API unreachable — fail-open (allow)
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
`, agentName, agentName, agentId)
|
||||
}
|
||||
|
||||
func buildHookScript(agentName string, agentId string) string {
|
||||
return fmt.Sprintf(`#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-contained Elydora audit hook for: %s
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = %q;
|
||||
const AGENT_ID = %q;
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
|
||||
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64urlEncode(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
function base64urlDecode(input) {
|
||||
return Buffer.from(input, 'base64url');
|
||||
}
|
||||
|
||||
function jcsCanonicalise(value) {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
|
||||
if (typeof value === 'string') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value).sort();
|
||||
const pairs = [];
|
||||
for (const key of keys) {
|
||||
if (value[key] !== undefined) {
|
||||
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
|
||||
}
|
||||
}
|
||||
return '{' + pairs.join(',') + '}';
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function sha256Base64url(data) {
|
||||
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
|
||||
return base64urlEncode(crypto.createHash('sha256').update(input).digest());
|
||||
}
|
||||
|
||||
function computePayloadHash(payload) {
|
||||
return sha256Base64url(jcsCanonicalise(payload));
|
||||
}
|
||||
|
||||
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) {
|
||||
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt;
|
||||
return sha256Base64url(input);
|
||||
}
|
||||
|
||||
const PKCS8_ED25519_PREFIX = Buffer.from([
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05,
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
]);
|
||||
|
||||
function signEd25519(privateKeyBase64url, data) {
|
||||
const seed = Buffer.from(privateKeyBase64url, 'base64url');
|
||||
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
|
||||
const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
||||
const signature = crypto.sign(null, data, keyObject);
|
||||
return base64urlEncode(signature);
|
||||
}
|
||||
|
||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
|
||||
function uuidv7() {
|
||||
const now = Date.now();
|
||||
const random = crypto.randomBytes(10);
|
||||
const bytes = Buffer.alloc(16);
|
||||
bytes[0] = (now / 2 ** 40) & 0xff;
|
||||
bytes[1] = (now / 2 ** 32) & 0xff;
|
||||
bytes[2] = (now / 2 ** 24) & 0xff;
|
||||
bytes[3] = (now / 2 ** 16) & 0xff;
|
||||
bytes[4] = (now / 2 ** 8) & 0xff;
|
||||
bytes[5] = now & 0xff;
|
||||
bytes[6] = 0x70 | (random[0] & 0x0f);
|
||||
bytes[7] = random[1];
|
||||
bytes[8] = 0x80 | (random[2] & 0x3f);
|
||||
bytes[9] = random[3];
|
||||
bytes[10] = random[4];
|
||||
bytes[11] = random[5];
|
||||
bytes[12] = random[6];
|
||||
bytes[13] = random[7];
|
||||
bytes[14] = random[8];
|
||||
bytes[15] = random[9];
|
||||
const hex = bytes.toString('hex');
|
||||
return (
|
||||
hex.slice(0, 8) + '-' +
|
||||
hex.slice(8, 12) + '-' +
|
||||
hex.slice(12, 16) + '-' +
|
||||
hex.slice(16, 20) + '-' +
|
||||
hex.slice(20, 32)
|
||||
);
|
||||
}
|
||||
|
||||
function generateNonce() {
|
||||
return base64urlEncode(crypto.randomBytes(16));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain state persistence (atomic writes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readChainState() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
|
||||
const state = JSON.parse(raw);
|
||||
return state.prev_chain_hash || ZERO_CHAIN_HASH;
|
||||
} catch {
|
||||
return ZERO_CHAIN_HASH;
|
||||
}
|
||||
}
|
||||
|
||||
function writeChainState(chainHash) {
|
||||
const tmpPath = CHAIN_STATE_PATH + '.tmp';
|
||||
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
|
||||
try {
|
||||
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||
} catch {
|
||||
// Windows fallback: rename can fail if target exists
|
||||
try {
|
||||
fs.unlinkSync(CHAIN_STATE_PATH);
|
||||
} catch {}
|
||||
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function logError(err) {
|
||||
try {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n';
|
||||
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
|
||||
} catch {
|
||||
// Silently ignore logging failures
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main hook logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Read JSON from stdin
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (!rawInput) return;
|
||||
|
||||
let hookData;
|
||||
try {
|
||||
hookData = JSON.parse(rawInput);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract tool_name and tool_input from hook data
|
||||
// Different agents may pass data in different shapes
|
||||
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
|
||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
|
||||
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
|
||||
|
||||
// Read agent config and private key
|
||||
let config;
|
||||
let privateKey;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
|
||||
} catch (err) {
|
||||
logError(new Error('Failed to read agent config/key: ' + err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = config.org_id;
|
||||
const agentId = config.agent_id;
|
||||
const kid = config.kid;
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
|
||||
const token = config.token || '';
|
||||
|
||||
// Read chain state
|
||||
const prevChainHash = readChainState();
|
||||
|
||||
// Build EOR
|
||||
const operationId = uuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const nonce = generateNonce();
|
||||
|
||||
const payload = {
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
};
|
||||
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
||||
|
||||
const eorWithoutSig = {
|
||||
op_version: '1.0',
|
||||
operation_id: operationId,
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
issued_at: issuedAt,
|
||||
ttl_ms: 30000,
|
||||
nonce: nonce,
|
||||
operation_type: 'ai.tool_use',
|
||||
subject: { session_id: sessionId },
|
||||
action: { tool: toolName },
|
||||
payload: payload,
|
||||
payload_hash: payloadHash,
|
||||
prev_chain_hash: prevChainHash,
|
||||
agent_pubkey_kid: kid,
|
||||
};
|
||||
|
||||
const canonical = jcsCanonicalise(eorWithoutSig);
|
||||
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
|
||||
|
||||
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
||||
|
||||
// POST to /v1/operations (5s timeout)
|
||||
// Only update local chain state on success to prevent desync
|
||||
const url = baseUrl + '/v1/operations';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(eor),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Server accepted — safe to advance local chain state
|
||||
writeChainState(chainHash);
|
||||
} else if (res.status === 400) {
|
||||
// Possible chain hash mismatch — try to resync from server response
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
||||
// Extract the expected hash from the error message
|
||||
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
||||
if (match) {
|
||||
writeChainState(match[1]);
|
||||
logError(new Error('Chain hash resynced to server: ' + match[1]));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
} catch {
|
||||
// Network error — don't advance chain state, will retry with same prev_chain_hash
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
`, agentName, agentName, agentId)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// expandHome replaces a leading ~ with the user's home directory.
|
||||
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) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// writeJSONFile writes a map to a JSON file with indentation.
|
||||
func writeJSONFile(path string, data map[string]interface{}) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
encoded, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal json: %w", err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
if err := os.WriteFile(path, encoded, 0644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hookScriptPath returns the default path for the hook script inside ~/.elydora/<agentId>/.
|
||||
func hookScriptPath(agentId string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".elydora", agentId, "hook.js"), nil
|
||||
}
|
||||
|
||||
// guardScriptPath returns the default path for the guard script inside ~/.elydora/<agentId>/.
|
||||
func guardScriptPath(agentId string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".elydora", agentId, "guard.js"), nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// KiroCliPlugin manages the Elydora audit hook for Kiro CLI.
|
||||
// It merges PreToolUse/PostToolUse hooks into ~/.kiro/settings.json.
|
||||
type KiroCliPlugin struct{}
|
||||
|
||||
func (p *KiroCliPlugin) configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".kiro", "settings.json"), nil
|
||||
}
|
||||
|
||||
func (p *KiroCliPlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
hooks = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
guardEntry := map[string]interface{}{
|
||||
"matcher": "*",
|
||||
"hooks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "command",
|
||||
"command": "node " + guardPath,
|
||||
"timeout_ms": float64(5000),
|
||||
},
|
||||
},
|
||||
}
|
||||
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,
|
||||
"timeout_ms": float64(5000),
|
||||
},
|
||||
},
|
||||
}
|
||||
postFiltered = append(postFiltered, hookEntry)
|
||||
hooks["PostToolUse"] = postFiltered
|
||||
|
||||
settings["hooks"] = hooks
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed Elydora hook for Kiro CLI at %s\n", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *KiroCliPlugin) Uninstall(agentID string) error {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
fmt.Println("No Kiro CLI hooks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove PreToolUse Elydora entries
|
||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
if len(preFiltered) == 0 {
|
||||
delete(hooks, "PreToolUse")
|
||||
} else {
|
||||
hooks["PreToolUse"] = preFiltered
|
||||
}
|
||||
|
||||
// Remove PostToolUse Elydora entries
|
||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
if len(postFiltered) == 0 {
|
||||
delete(hooks, "PostToolUse")
|
||||
} else {
|
||||
hooks["PostToolUse"] = postFiltered
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
delete(settings, "hooks")
|
||||
} else {
|
||||
settings["hooks"] = hooks
|
||||
}
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
fmt.Println("Uninstalled Elydora hook for Kiro CLI.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *KiroCliPlugin) Status() (PluginStatus, error) {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "kirocli",
|
||||
DisplayName: "Kiro CLI",
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
|
||||
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
|
||||
status.HookConfigured = preConfigured && postConfigured
|
||||
|
||||
// Extract hook script path from the configured command
|
||||
scriptPath := extractElydoraScriptPath(hooks["PostToolUse"])
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// KiroIdePlugin manages the Elydora audit hook for Kiro IDE.
|
||||
// It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory.
|
||||
type KiroIdePlugin struct{}
|
||||
|
||||
func (p *KiroIdePlugin) configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".kiro", "hooks", "elydora-audit.kiro.hook"), nil
|
||||
}
|
||||
|
||||
func (p *KiroIdePlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
hookFile, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(hookFile)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
content := buildKiroIdeHookFile(scriptPath, guardPath)
|
||||
if err := os.WriteFile(hookFile, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", hookFile, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installed Elydora hook for Kiro IDE at %s\n", hookFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildKiroIdeHookFile(scriptPath, guardPath string) string {
|
||||
hookConfig := map[string]interface{}{
|
||||
"name": "Elydora Audit",
|
||||
"hooks": map[string]interface{}{
|
||||
"pre_tool_use": map[string]interface{}{
|
||||
"command": "node " + guardPath,
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
"post_tool_use": map[string]interface{}{
|
||||
"command": "node " + scriptPath,
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded, _ := json.MarshalIndent(hookConfig, "", " ")
|
||||
return string(encoded) + "\n"
|
||||
}
|
||||
|
||||
func (p *KiroIdePlugin) Uninstall(agentID string) error {
|
||||
hookFile, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(hookFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove %s: %w", hookFile, err)
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Uninstalled Elydora hook for Kiro IDE.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *KiroIdePlugin) Status() (PluginStatus, error) {
|
||||
hookFile, err := p.configPath()
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "kiroide",
|
||||
DisplayName: "Kiro IDE",
|
||||
ConfigPath: hookFile,
|
||||
}
|
||||
|
||||
// Check if hook file exists and contains both pre_tool_use and post_tool_use
|
||||
data, err := os.ReadFile(hookFile)
|
||||
if err == nil {
|
||||
var config map[string]interface{}
|
||||
if json.Unmarshal(data, &config) == nil {
|
||||
hooks, _ := config["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
_, hasPre := hooks["pre_tool_use"]
|
||||
_, hasPost := hooks["post_tool_use"]
|
||||
status.HookConfigured = hasPre && hasPost
|
||||
|
||||
// Extract hook script path from the post_tool_use command
|
||||
if postHook, ok := hooks["post_tool_use"].(map[string]interface{}); ok {
|
||||
if cmd, _ := postHook["command"].(string); cmd != "" {
|
||||
scriptPath := extractPathFromNodeCommand(cmd)
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LettaPlugin manages the Elydora audit hook for Letta Code.
|
||||
// It merges PreToolUse/PostToolUse hooks into ~/.letta/settings.json.
|
||||
type LettaPlugin struct{}
|
||||
|
||||
func (p *LettaPlugin) configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".letta", "settings.json"), nil
|
||||
}
|
||||
|
||||
func (p *LettaPlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
hooks = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
guardEntry := map[string]interface{}{
|
||||
"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
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed Elydora hook for Letta Code at %s\n", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LettaPlugin) Uninstall(agentID string) error {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
fmt.Println("No Letta Code hooks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove PreToolUse Elydora entries
|
||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
if len(preFiltered) == 0 {
|
||||
delete(hooks, "PreToolUse")
|
||||
} else {
|
||||
hooks["PreToolUse"] = preFiltered
|
||||
}
|
||||
|
||||
// Remove PostToolUse Elydora entries
|
||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if isElydoraHookEntry(m) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
if len(postFiltered) == 0 {
|
||||
delete(hooks, "PostToolUse")
|
||||
} else {
|
||||
hooks["PostToolUse"] = postFiltered
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
delete(settings, "hooks")
|
||||
} else {
|
||||
settings["hooks"] = hooks
|
||||
}
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
fmt.Println("Uninstalled Elydora hook for Letta Code.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LettaPlugin) Status() (PluginStatus, error) {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "letta",
|
||||
DisplayName: "Letta Code",
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err != nil {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
|
||||
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
|
||||
status.HookConfigured = preConfigured && postConfigured
|
||||
|
||||
// Extract hook script path from the configured command
|
||||
scriptPath := extractElydoraScriptPath(hooks["PostToolUse"])
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenCodePlugin manages the Elydora audit hook for OpenCode.
|
||||
// It writes .opencode/plugins/elydora-audit.js in the home directory.
|
||||
type OpenCodePlugin struct{}
|
||||
|
||||
func (p *OpenCodePlugin) configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".opencode", "plugins", "elydora-audit.js"), nil
|
||||
}
|
||||
|
||||
func (p *OpenCodePlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
pluginFile, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(pluginFile)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
content := buildOpenCodePlugin(scriptPath, guardPath)
|
||||
if err := os.WriteFile(pluginFile, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", pluginFile, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installed Elydora hook for OpenCode at %s\n", pluginFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildOpenCodePlugin(scriptPath, guardPath string) string {
|
||||
return fmt.Sprintf(`// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
module.exports = {
|
||||
name: 'elydora-audit',
|
||||
version: '1.0.0',
|
||||
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
||||
|
||||
hooks: {
|
||||
preToolUse(context) {
|
||||
// Synchronous guard — blocks tool if agent is frozen
|
||||
try {
|
||||
const result = spawnSync('node', [%q], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
|
||||
return { blocked: true, reason: msg };
|
||||
}
|
||||
} catch {
|
||||
// Fail-open — allow if guard can't run
|
||||
}
|
||||
},
|
||||
|
||||
postToolUse(context) {
|
||||
try {
|
||||
const input = JSON.stringify({
|
||||
tool_name: context.toolName || context.tool_name || 'unknown',
|
||||
tool_input: context.toolInput || context.tool_input || {},
|
||||
session_id: context.sessionId || context.session_id || 'unknown',
|
||||
});
|
||||
|
||||
const child = spawn('node', [%q], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
child.unref();
|
||||
} catch {
|
||||
// Fire-and-forget — never block the developer
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
`, guardPath, scriptPath)
|
||||
}
|
||||
|
||||
func (p *OpenCodePlugin) Uninstall(agentID string) error {
|
||||
pluginFile, err := p.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(pluginFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove %s: %w", pluginFile, err)
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Uninstalled Elydora hook for OpenCode.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OpenCodePlugin) Status() (PluginStatus, error) {
|
||||
pluginFile, err := p.configPath()
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
|
||||
status := PluginStatus{
|
||||
AgentName: "opencode",
|
||||
DisplayName: "OpenCode",
|
||||
ConfigPath: pluginFile,
|
||||
}
|
||||
|
||||
if _, err := os.Stat(pluginFile); err == nil {
|
||||
status.HookConfigured = true
|
||||
|
||||
// Read the plugin file to extract the hook script path
|
||||
data, readErr := os.ReadFile(pluginFile)
|
||||
if readErr == nil {
|
||||
content := string(data)
|
||||
// Look for the elydora hook.js path in the plugin file content
|
||||
if idx := strings.Index(content, ".elydora"); idx >= 0 {
|
||||
// Extract path from the spawn('node', ['/path/to/hook.js']) pattern
|
||||
// Pragmatic: scan ~/.elydora/*/hook.js for existence
|
||||
home, homeErr := os.UserHomeDir()
|
||||
if homeErr == nil {
|
||||
entries, dirErr := os.ReadDir(filepath.Join(home, ".elydora"))
|
||||
if dirErr == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
hookPath := filepath.Join(home, ".elydora", entry.Name(), "hook.js")
|
||||
if _, statErr := os.Stat(hookPath); statErr == nil {
|
||||
status.HookScriptExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package plugins
|
||||
|
||||
// AgentRegistryEntry describes a supported agent integration.
|
||||
type AgentRegistryEntry struct {
|
||||
Name string
|
||||
ConfigDir string // ~ means home dir; no ~ means project-relative
|
||||
ConfigFile string
|
||||
}
|
||||
|
||||
// SupportedAgents is the canonical registry of all supported agent integrations.
|
||||
var SupportedAgents = map[string]AgentRegistryEntry{
|
||||
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
||||
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
||||
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
||||
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro", ConfigFile: "settings.json"},
|
||||
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
|
||||
"opencode": {Name: "OpenCode", ConfigDir: "~/.opencode/plugins", ConfigFile: "elydora-audit.js"},
|
||||
"copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},
|
||||
"letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"},
|
||||
}
|
||||
|
||||
// NewPlugin creates a plugin instance for the given agent name.
|
||||
// Returns nil if the agent is not supported.
|
||||
func NewPlugin(agentName string) AgentPlugin {
|
||||
switch agentName {
|
||||
case "claudecode":
|
||||
return &ClaudeCodePlugin{}
|
||||
case "cursor":
|
||||
return &CursorPlugin{}
|
||||
case "gemini":
|
||||
return &GeminiPlugin{}
|
||||
case "kirocli":
|
||||
return &KiroCliPlugin{}
|
||||
case "kiroide":
|
||||
return &KiroIdePlugin{}
|
||||
case "opencode":
|
||||
return &OpenCodePlugin{}
|
||||
case "copilot":
|
||||
return &CopilotPlugin{}
|
||||
case "letta":
|
||||
return &LettaPlugin{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sha256Base64url computes SHA-256 of the input string and returns base64url-encoded result.
|
||||
func sha256Base64url(data string) string {
|
||||
h := sha256.Sum256([]byte(data))
|
||||
return base64urlEncode(h[:])
|
||||
}
|
||||
|
||||
// sha256BytesBase64url computes SHA-256 of raw bytes and returns base64url-encoded result.
|
||||
func sha256BytesBase64url(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return base64urlEncode(h[:])
|
||||
}
|
||||
|
||||
// computePayloadHash computes the SHA-256 hash of the JCS-canonicalized payload.
|
||||
func computePayloadHash(payload interface{}) (string, error) {
|
||||
canonical, err := jcsCanonicalise(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jcs canonicalise payload: %w", err)
|
||||
}
|
||||
return sha256BytesBase64url([]byte(canonical)), nil
|
||||
}
|
||||
|
||||
// signEd25519 signs data with an Ed25519 private key seed (base64url-encoded 32 bytes).
|
||||
// Returns the signature as base64url.
|
||||
func signEd25519(privateKeyBase64url string, data []byte) (string, error) {
|
||||
seed, err := base64urlDecode(privateKeyBase64url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode private key: %w", err)
|
||||
}
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return "", fmt.Errorf("private key seed must be %d bytes, got %d", ed25519.SeedSize, len(seed))
|
||||
}
|
||||
key := ed25519.NewKeyFromSeed(seed)
|
||||
sig := ed25519.Sign(key, data)
|
||||
return base64urlEncode(sig), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JCS Canonicalization (RFC 8785)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// jcsCanonicalise produces a JCS-canonicalized JSON string from a Go value.
|
||||
// Object keys are sorted lexicographically, no whitespace is used, and numbers
|
||||
// follow ES2015 serialization rules.
|
||||
func jcsCanonicalise(value interface{}) (string, error) {
|
||||
var b strings.Builder
|
||||
if err := jcsWrite(&b, value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func jcsWrite(b *strings.Builder, value interface{}) error {
|
||||
if value == nil {
|
||||
b.WriteString("null")
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
if v {
|
||||
b.WriteString("true")
|
||||
} else {
|
||||
b.WriteString("false")
|
||||
}
|
||||
case json.Number:
|
||||
// Parse as float64 for ES2015-compatible serialization
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.WriteString(jcsFormatNumber(f))
|
||||
case float64:
|
||||
b.WriteString(jcsFormatNumber(v))
|
||||
case float32:
|
||||
b.WriteString(jcsFormatNumber(float64(v)))
|
||||
case int:
|
||||
b.WriteString(strconv.FormatInt(int64(v), 10))
|
||||
case int64:
|
||||
b.WriteString(strconv.FormatInt(v, 10))
|
||||
case string:
|
||||
jcsWriteString(b, v)
|
||||
case []interface{}:
|
||||
b.WriteByte('[')
|
||||
for i, elem := range v {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
if err := jcsWrite(b, elem); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.WriteByte(']')
|
||||
case map[string]interface{}:
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
b.WriteByte('{')
|
||||
first := true
|
||||
for _, k := range keys {
|
||||
val := v[k]
|
||||
if val == nil {
|
||||
// JCS includes null values but skips undefined; in Go there is no undefined
|
||||
// so we include null.
|
||||
}
|
||||
if !first {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
first = false
|
||||
jcsWriteString(b, k)
|
||||
b.WriteByte(':')
|
||||
if err := jcsWrite(b, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.WriteByte('}')
|
||||
default:
|
||||
// Fallback: marshal to JSON, then re-parse and canonicalize
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jcs: cannot marshal %T: %w", v, err)
|
||||
}
|
||||
var parsed interface{}
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&parsed); err != nil {
|
||||
return fmt.Errorf("jcs: cannot re-parse %T: %w", v, err)
|
||||
}
|
||||
return jcsWrite(b, parsed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jcsWriteString writes a JSON string with minimal escaping per the JSON spec.
|
||||
func jcsWriteString(b *strings.Builder, s string) {
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\b':
|
||||
b.WriteString(`\b`)
|
||||
case '\f':
|
||||
b.WriteString(`\f`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
if r < 0x20 {
|
||||
b.WriteString(fmt.Sprintf(`\u%04x`, r))
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
}
|
||||
|
||||
// jcsFormatNumber formats a float64 according to ES2015 Number serialization (JCS/RFC 8785).
|
||||
func jcsFormatNumber(f float64) string {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return "null"
|
||||
}
|
||||
if f == 0 {
|
||||
return "0"
|
||||
}
|
||||
// If the number is an integer that fits in int64, format without decimal point
|
||||
if f == math.Trunc(f) && !math.IsInf(f, 0) && math.Abs(f) < 1e20 {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
// Use the shortest representation that round-trips
|
||||
return strconv.FormatFloat(f, 'G', -1, 64)
|
||||
}
|
||||
|
||||
// signEOR signs an EOR struct. It computes the canonical representation (all fields except signature),
|
||||
// hashes it, and produces an Ed25519 signature.
|
||||
func signEOR(eor *EOR, privateKeyBase64url string) (string, error) {
|
||||
// Build canonical EOR without signature field
|
||||
canonical := map[string]interface{}{
|
||||
"op_version": eor.OpVersion,
|
||||
"operation_id": eor.OperationID,
|
||||
"org_id": eor.OrgID,
|
||||
"agent_id": eor.AgentID,
|
||||
"issued_at": eor.IssuedAt,
|
||||
"ttl_ms": eor.TTLMs,
|
||||
"nonce": eor.Nonce,
|
||||
"operation_type": eor.OperationType,
|
||||
"subject": eor.Subject,
|
||||
"action": eor.Action,
|
||||
"payload": eor.Payload,
|
||||
"payload_hash": eor.PayloadHash,
|
||||
"prev_chain_hash": eor.PrevChainHash,
|
||||
"agent_pubkey_kid": eor.AgentPubkeyKID,
|
||||
}
|
||||
canonicalStr, err := jcsCanonicalise(canonical)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("canonicalise eor: %w", err)
|
||||
}
|
||||
return signEd25519(privateKeyBase64url, []byte(canonicalStr))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package elydora
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ListEpochs lists all epochs for the organization.
|
||||
func (c *Client) ListEpochs() (*ListEpochsResponse, error) {
|
||||
var result ListEpochsResponse
|
||||
if err := c.doGet("/v1/epochs", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetEpoch retrieves a specific epoch by ID.
|
||||
func (c *Client) GetEpoch(epochID string) (*GetEpochResponse, error) {
|
||||
var result GetEpochResponse
|
||||
if err := c.doGet(fmt.Sprintf("/v1/epochs/%s", epochID), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package elydora
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ElydoraError represents an error returned by the Elydora API.
|
||||
type ElydoraError struct {
|
||||
StatusCode int
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ElydoraError) Error() string {
|
||||
return fmt.Sprintf("elydora: %s (code=%s, status=%d, request_id=%s)", e.Message, e.Code, e.StatusCode, e.RequestID)
|
||||
}
|
||||
|
||||
// errorResponse is the wire format for API error responses.
|
||||
type errorResponse struct {
|
||||
Error struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CreateExport creates a new compliance export job.
|
||||
func (c *Client) CreateExport(params *CreateExportRequest) (*CreateExportResponse, error) {
|
||||
var result CreateExportResponse
|
||||
if err := c.doPost("/v1/exports", params, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ListExports lists all exports for the organization.
|
||||
func (c *Client) ListExports() (*ListExportsResponse, error) {
|
||||
var result ListExportsResponse
|
||||
if err := c.doGet("/v1/exports", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetExport retrieves a specific export by ID.
|
||||
func (c *Client) GetExport(exportID string) (*GetExportResponse, error) {
|
||||
var result GetExportResponse
|
||||
if err := c.doGet(fmt.Sprintf("/v1/exports/%s", exportID), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DownloadExport downloads the raw file data for a completed export.
|
||||
func (c *Client) DownloadExport(exportID string) ([]byte, error) {
|
||||
u := c.baseURL + fmt.Sprintf("/v1/exports/%s/download", exportID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: create request: %w", err)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var errResp errorResponse
|
||||
if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil && errResp.Error.Code != "" {
|
||||
return nil, &ElydoraError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: errResp.Error.Code,
|
||||
Message: errResp.Error.Message,
|
||||
RequestID: errResp.Error.RequestID,
|
||||
Details: errResp.Error.Details,
|
||||
}
|
||||
}
|
||||
return nil, &ElydoraError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,62 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Health checks the health of the Elydora API. This is a public endpoint
|
||||
// that does not require authentication.
|
||||
func Health(baseURL string) (*HealthResponse, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/health", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 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 nil, fmt.Errorf("elydora: read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
var result HealthResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("elydora: unmarshal response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
var errResp errorResponse
|
||||
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
|
||||
return nil, &ElydoraError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: errResp.Error.Code,
|
||||
Message: errResp.Error.Message,
|
||||
RequestID: errResp.Error.RequestID,
|
||||
Details: errResp.Error.Details,
|
||||
}
|
||||
}
|
||||
|
||||
return nil, &ElydoraError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: string(respBody),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package elydora
|
||||
|
||||
// GetJWKS retrieves the JSON Web Key Set from the Elydora API.
|
||||
func (c *Client) GetJWKS() (*JWKSResponse, error) {
|
||||
var result JWKSResponse
|
||||
if err := c.doGet("/.well-known/elydora/jwks.json", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package elydora
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GenesisChainHash is the initial chain hash for an agent's first operation.
|
||||
// It is the base64url encoding of 32 zero bytes, matching the backend.
|
||||
const GenesisChainHash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
// CreateOperation builds a signed EOR from the given parameters.
|
||||
// It generates a UUIDv7 operation ID, a nonce, computes the payload hash and chain hash,
|
||||
// and signs the entire record with the client's private key.
|
||||
func (c *Client) CreateOperation(params *CreateOperationParams) (*EOR, error) {
|
||||
if params == nil {
|
||||
return nil, fmt.Errorf("elydora: params must not be nil")
|
||||
}
|
||||
|
||||
operationID, err := generateUUIDv7()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: generate operation id: %w", err)
|
||||
}
|
||||
|
||||
nonce, err := generateNonce()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: generate nonce: %w", err)
|
||||
}
|
||||
|
||||
issuedAt := nowUnixMs()
|
||||
|
||||
payloadHash, err := computePayloadHash(params.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: compute payload hash: %w", err)
|
||||
}
|
||||
|
||||
prevChainHash := params.PrevChainHash
|
||||
if prevChainHash == "" {
|
||||
prevChainHash = GenesisChainHash
|
||||
}
|
||||
|
||||
kid := params.KID
|
||||
if kid == "" && c.agentID != "" {
|
||||
kid = c.agentID + "-key-1"
|
||||
}
|
||||
|
||||
eor := &EOR{
|
||||
OpVersion: "1.0",
|
||||
OperationID: operationID,
|
||||
OrgID: c.orgID,
|
||||
AgentID: c.agentID,
|
||||
IssuedAt: issuedAt,
|
||||
TTLMs: int64(c.ttlMs),
|
||||
Nonce: nonce,
|
||||
OperationType: params.OperationType,
|
||||
Subject: params.Subject,
|
||||
Action: params.Action,
|
||||
Payload: params.Payload,
|
||||
PayloadHash: payloadHash,
|
||||
PrevChainHash: prevChainHash,
|
||||
AgentPubkeyKID: kid,
|
||||
}
|
||||
|
||||
signature, err := signEOR(eor, c.privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elydora: sign eor: %w", err)
|
||||
}
|
||||
eor.Signature = signature
|
||||
|
||||
return eor, nil
|
||||
}
|
||||
|
||||
// SubmitOperation submits a signed EOR to the Elydora API.
|
||||
func (c *Client) SubmitOperation(eor *EOR) (*SubmitOperationResponse, error) {
|
||||
var result SubmitOperationResponse
|
||||
if err := c.doPost("/v1/operations", eor, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetOperation retrieves an operation by ID.
|
||||
func (c *Client) GetOperation(operationID string) (*GetOperationResponse, error) {
|
||||
var result GetOperationResponse
|
||||
if err := c.doGet(fmt.Sprintf("/v1/operations/%s", operationID), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// VerifyOperation verifies the integrity of an operation.
|
||||
func (c *Client) VerifyOperation(operationID string) (*VerifyOperationResponse, error) {
|
||||
var result VerifyOperationResponse
|
||||
if err := c.doPost(fmt.Sprintf("/v1/operations/%s/verify", operationID), nil, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package elydora
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AgentStatus string
|
||||
|
||||
const (
|
||||
AgentStatusActive AgentStatus = "active"
|
||||
AgentStatusFrozen AgentStatus = "frozen"
|
||||
AgentStatusRevoked AgentStatus = "revoked"
|
||||
)
|
||||
|
||||
type KeyStatus string
|
||||
|
||||
const (
|
||||
KeyStatusActive KeyStatus = "active"
|
||||
KeyStatusRetired KeyStatus = "retired"
|
||||
KeyStatusRevoked KeyStatus = "revoked"
|
||||
)
|
||||
|
||||
type ExportStatus string
|
||||
|
||||
const (
|
||||
ExportStatusQueued ExportStatus = "queued"
|
||||
ExportStatusRunning ExportStatus = "running"
|
||||
ExportStatusDone ExportStatus = "done"
|
||||
ExportStatusFailed ExportStatus = "failed"
|
||||
)
|
||||
|
||||
type RbacRole string
|
||||
|
||||
const (
|
||||
RbacRoleOrgOwner RbacRole = "org_owner"
|
||||
RbacRoleSecurityAdmin RbacRole = "security_admin"
|
||||
RbacRoleComplianceAuditor RbacRole = "compliance_auditor"
|
||||
RbacRoleReadonlyInvestigator RbacRole = "readonly_investigator"
|
||||
RbacRoleIntegrationEngineer RbacRole = "integration_engineer"
|
||||
)
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorCodeInvalidSignature ErrorCode = "INVALID_SIGNATURE"
|
||||
ErrorCodeUnknownAgent ErrorCode = "UNKNOWN_AGENT"
|
||||
ErrorCodeKeyRevoked ErrorCode = "KEY_REVOKED"
|
||||
ErrorCodeAgentFrozen ErrorCode = "AGENT_FROZEN"
|
||||
ErrorCodeTTLExpired ErrorCode = "TTL_EXPIRED"
|
||||
ErrorCodeReplayDetected ErrorCode = "REPLAY_DETECTED"
|
||||
ErrorCodePrevHashMismatch ErrorCode = "PREV_HASH_MISMATCH"
|
||||
ErrorCodePayloadTooLarge ErrorCode = "PAYLOAD_TOO_LARGE"
|
||||
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
|
||||
ErrorCodeInternalError ErrorCode = "INTERNAL_ERROR"
|
||||
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
|
||||
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrorCodeValidationError ErrorCode = "VALIDATION_ERROR"
|
||||
)
|
||||
|
||||
type ExportFormat string
|
||||
|
||||
const (
|
||||
ExportFormatJSON ExportFormat = "json"
|
||||
ExportFormatPDF ExportFormat = "pdf"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Agent struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ResponsibleEntity string `json:"responsible_entity"`
|
||||
Status AgentStatus `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgentKey struct {
|
||||
KID string `json:"kid"`
|
||||
AgentID string `json:"agent_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Status KeyStatus `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
RetiredAt *int64 `json:"retired_at"`
|
||||
}
|
||||
|
||||
type Operation struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
SeqNo int64 `json:"seq_no"`
|
||||
OperationType string `json:"operation_type"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
TTLMs int64 `json:"ttl_ms"`
|
||||
Nonce string `json:"nonce"`
|
||||
Subject string `json:"subject"`
|
||||
Action string `json:"action"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
PrevChainHash string `json:"prev_chain_hash"`
|
||||
ChainHash string `json:"chain_hash"`
|
||||
AgentPubkeyKID string `json:"agent_pubkey_kid"`
|
||||
Signature string `json:"signature"`
|
||||
R2PayloadKey *string `json:"r2_payload_key"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Receipt struct {
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
OperationID string `json:"operation_id"`
|
||||
R2ReceiptKey string `json:"r2_receipt_key"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Epoch struct {
|
||||
EpochID string `json:"epoch_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
RootHash string `json:"root_hash"`
|
||||
LeafCount int64 `json:"leaf_count"`
|
||||
R2EpochKey string `json:"r2_epoch_key"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
OrgID string `json:"org_id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
UserID string `json:"user_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role RbacRole `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Export struct {
|
||||
ExportID string `json:"export_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Status ExportStatus `json:"status"`
|
||||
QueryParams string `json:"query_params"`
|
||||
R2ExportKey *string `json:"r2_export_key"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt *int64 `json:"completed_at"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EOR is the Elydora Operation Record — the fundamental unit of auditable activity.
|
||||
type EOR struct {
|
||||
OpVersion string `json:"op_version"`
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
TTLMs int64 `json:"ttl_ms"`
|
||||
Nonce string `json:"nonce"`
|
||||
OperationType string `json:"operation_type"`
|
||||
Subject interface{} `json:"subject"`
|
||||
Action interface{} `json:"action"`
|
||||
Payload interface{} `json:"payload"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
PrevChainHash string `json:"prev_chain_hash"`
|
||||
AgentPubkeyKID string `json:"agent_pubkey_kid"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// EAR is the Elydora Acknowledgment Receipt — server-issued receipt confirming acceptance.
|
||||
type EAR struct {
|
||||
ReceiptVersion string `json:"receipt_version"`
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
OperationID string `json:"operation_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ServerReceivedAt int64 `json:"server_received_at"`
|
||||
SeqNo int64 `json:"seq_no"`
|
||||
ChainHash string `json:"chain_hash"`
|
||||
QueueMessageID string `json:"queue_message_id"`
|
||||
ReceiptHash string `json:"receipt_hash"`
|
||||
ElydoraKID string `json:"elydora_kid"`
|
||||
ElydoraSignature string `json:"elydora_signature"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API request/response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RegisterAgentKeyInput struct {
|
||||
KID string `json:"kid"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
}
|
||||
|
||||
type RegisterAgentRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
ResponsibleEntity string `json:"responsible_entity,omitempty"`
|
||||
Keys []RegisterAgentKeyInput `json:"keys"`
|
||||
}
|
||||
|
||||
type RegisterAgentResponse struct {
|
||||
Agent Agent `json:"agent"`
|
||||
Keys []AgentKey `json:"keys"`
|
||||
}
|
||||
|
||||
type GetAgentResponse struct {
|
||||
Agent Agent `json:"agent"`
|
||||
Keys []AgentKey `json:"keys"`
|
||||
}
|
||||
|
||||
type FreezeAgentRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type RevokeAgentRequest struct {
|
||||
KID string `json:"kid"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type UnfreezeAgentRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type ListAgentsResponse struct {
|
||||
Agents []Agent `json:"agents"`
|
||||
}
|
||||
|
||||
type DeleteAgentResponse struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type GetMeResponse struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type IssueApiTokenRequest struct {
|
||||
TTLSeconds *int `json:"ttl_seconds"`
|
||||
}
|
||||
|
||||
type IssueApiTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type SubmitOperationResponse struct {
|
||||
Receipt EAR `json:"receipt"`
|
||||
}
|
||||
|
||||
type GetOperationResponse struct {
|
||||
Operation Operation `json:"operation"`
|
||||
Receipt *Receipt `json:"receipt,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyOperationChecks struct {
|
||||
Signature bool `json:"signature"`
|
||||
Chain bool `json:"chain"`
|
||||
Receipt bool `json:"receipt"`
|
||||
Merkle *bool `json:"merkle,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyOperationResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Checks VerifyOperationChecks `json:"checks"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type AuditQueryRequest struct {
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
OperationType string `json:"operation_type,omitempty"`
|
||||
StartTime *int64 `json:"start_time,omitempty"`
|
||||
EndTime *int64 `json:"end_time,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type AuditQueryResponse struct {
|
||||
Operations []Operation `json:"operations"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
type GetEpochResponse struct {
|
||||
Epoch Epoch `json:"epoch"`
|
||||
Anchor *EpochAnchor `json:"anchor,omitempty"`
|
||||
}
|
||||
|
||||
type EpochAnchor struct {
|
||||
TSAToken string `json:"tsa_token,omitempty"`
|
||||
}
|
||||
|
||||
type ListEpochsResponse struct {
|
||||
Epochs []Epoch `json:"epochs"`
|
||||
}
|
||||
|
||||
type CreateExportRequest struct {
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
OperationType string `json:"operation_type,omitempty"`
|
||||
Format ExportFormat `json:"format"`
|
||||
}
|
||||
|
||||
type CreateExportResponse struct {
|
||||
Export Export `json:"export"`
|
||||
}
|
||||
|
||||
type GetExportResponse struct {
|
||||
Export Export `json:"export"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type ListExportsResponse struct {
|
||||
Exports []Export `json:"exports"`
|
||||
}
|
||||
|
||||
type JWK struct {
|
||||
KTY string `json:"kty"`
|
||||
CRV string `json:"crv,omitempty"`
|
||||
X string `json:"x,omitempty"`
|
||||
KID string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
}
|
||||
|
||||
type JWKSResponse struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
type AuthRegisterRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
OrgName string `json:"org_name,omitempty"`
|
||||
}
|
||||
|
||||
type AuthRegisterResponse struct {
|
||||
User User `json:"user"`
|
||||
Organization Organization `json:"organization"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type AuthLoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type AuthLoginResponse struct {
|
||||
User User `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateOperation params (SDK-specific)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CreateOperationParams struct {
|
||||
OperationType string
|
||||
Subject map[string]interface{}
|
||||
Action map[string]interface{}
|
||||
Payload interface{}
|
||||
PrevChainHash string
|
||||
KID string
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// base64urlEncode encodes bytes to base64url without padding (RFC 4648 section 5).
|
||||
func base64urlEncode(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// base64urlDecode decodes a base64url string (with or without padding).
|
||||
func base64urlDecode(s string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// generateNonce generates a 16-byte cryptographically random nonce, base64url encoded.
|
||||
func generateNonce() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64urlEncode(b), nil
|
||||
}
|
||||
|
||||
// generateUUIDv7 generates a UUIDv7 (time-ordered) as a string.
|
||||
func generateUUIDv7() (string, error) {
|
||||
now := time.Now()
|
||||
ms := now.UnixMilli()
|
||||
|
||||
var uuid [16]byte
|
||||
|
||||
// Timestamp: 48 bits in big-endian
|
||||
binary.BigEndian.PutUint16(uuid[0:2], uint16(ms>>32))
|
||||
binary.BigEndian.PutUint16(uuid[2:4], uint16(ms>>16))
|
||||
binary.BigEndian.PutUint16(uuid[4:6], uint16(ms))
|
||||
|
||||
// Fill the rest with random data
|
||||
if _, err := io.ReadFull(rand.Reader, uuid[6:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set version 7 (bits 48-51)
|
||||
uuid[6] = (uuid[6] & 0x0F) | 0x70
|
||||
// Set variant 10 (bits 64-65)
|
||||
uuid[8] = (uuid[8] & 0x3F) | 0x80
|
||||
|
||||
return formatUUID(uuid), nil
|
||||
}
|
||||
|
||||
// formatUUID formats a 16-byte UUID as a canonical string.
|
||||
func formatUUID(uuid [16]byte) string {
|
||||
buf := make([]byte, 36)
|
||||
hex := "0123456789abcdef"
|
||||
positions := [16]int{0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34}
|
||||
for i, p := range positions {
|
||||
buf[p] = hex[uuid[i]>>4]
|
||||
buf[p+1] = hex[uuid[i]&0x0F]
|
||||
}
|
||||
buf[8] = '-'
|
||||
buf[13] = '-'
|
||||
buf[18] = '-'
|
||||
buf[23] = '-'
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// nowUnixMs returns the current time as Unix milliseconds.
|
||||
func nowUnixMs() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"preToolUse": [],
|
||||
"postToolUse": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,5 @@
|
||||
src/
|
||||
tsconfig.json
|
||||
.gitignore
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,268 @@
|
||||
# Elydora Node SDK
|
||||
|
||||
Official Node.js/TypeScript SDK for the [Elydora](https://elydora.com) tamper-evident audit platform. Build cryptographically verifiable audit trails for AI agent operations.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @elydora/sdk
|
||||
```
|
||||
|
||||
Requires Node.js 18+ (uses built-in `crypto` module with Ed25519 support).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { ElydoraClient } from '@elydora/sdk';
|
||||
|
||||
// Authenticate
|
||||
const auth = await ElydoraClient.login('https://api.elydora.com', 'user@example.com', 'password');
|
||||
|
||||
// Create client
|
||||
const client = new ElydoraClient({
|
||||
orgId: auth.user.org_id,
|
||||
agentId: 'my-agent-id',
|
||||
privateKey: '<base64url-encoded-32-byte-ed25519-seed>',
|
||||
baseUrl: 'https://api.elydora.com',
|
||||
});
|
||||
client.setToken(auth.token);
|
||||
|
||||
// Create and submit an operation
|
||||
const eor = client.createOperation({
|
||||
operationType: 'data.access',
|
||||
subject: { user_id: 'u-123', resource: 'patient-record' },
|
||||
action: { type: 'read', scope: 'full' },
|
||||
payload: { record_id: 'rec-456' },
|
||||
});
|
||||
|
||||
const { receipt } = await client.submitOperation(eor);
|
||||
console.log('Operation submitted:', receipt.operation_id);
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
The SDK includes a CLI for installing audit hooks into AI coding agents.
|
||||
|
||||
```bash
|
||||
npx elydora install \
|
||||
--agent claudecode \
|
||||
--org_id org-123 \
|
||||
--agent_id agent-456 \
|
||||
--private_key <key> \
|
||||
--kid agent-456-key-v1
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `elydora install` | Install Elydora audit hook for a coding agent |
|
||||
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
|
||||
| `elydora status` | Show installation status for all agents |
|
||||
| `elydora agents` | List supported coding agents |
|
||||
|
||||
### Supported Agents
|
||||
|
||||
| Agent | Key |
|
||||
|-------|-----|
|
||||
| Claude Code | `claudecode` |
|
||||
| Cursor | `cursor` |
|
||||
| Gemini CLI | `gemini` |
|
||||
| Kiro CLI | `kirocli` |
|
||||
| Kiro IDE | `kiroide` |
|
||||
| OpenCode | `opencode` |
|
||||
| Copilot CLI | `copilot` |
|
||||
| Letta Code | `letta` |
|
||||
|
||||
## API Reference
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
const client = new ElydoraClient({
|
||||
orgId: string, // Organization ID
|
||||
agentId: string, // Agent ID
|
||||
privateKey: string, // Base64url-encoded Ed25519 seed
|
||||
baseUrl?: string, // API base URL (default: https://api.elydora.com)
|
||||
ttlMs?: number, // Operation TTL in ms (default: 30000)
|
||||
maxRetries?: number, // Max retries on transient failures (default: 3)
|
||||
kid?: string, // Key ID (default: {agentId}-key-v1)
|
||||
});
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```typescript
|
||||
// Register a new user and organization
|
||||
const reg = await ElydoraClient.register(baseUrl, email, password, displayName?, orgName?);
|
||||
|
||||
// Login and receive a JWT
|
||||
const auth = await ElydoraClient.login(baseUrl, email, password);
|
||||
|
||||
// Set token on client
|
||||
client.setToken(auth.token);
|
||||
|
||||
// Get current authenticated user profile
|
||||
const { user } = await client.getMe();
|
||||
|
||||
// Issue a new API token (with optional TTL in seconds)
|
||||
const { token, expires_at } = await client.issueApiToken(3600);
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
```typescript
|
||||
// Create a signed EOR locally (synchronous, no network call)
|
||||
const eor = client.createOperation({
|
||||
operationType: 'inference',
|
||||
subject: { model: 'gpt-4' },
|
||||
action: { type: 'completion' },
|
||||
payload: { prompt: 'Hello' },
|
||||
});
|
||||
|
||||
// Submit to API
|
||||
const { receipt } = await client.submitOperation(eor);
|
||||
|
||||
// Retrieve an operation
|
||||
const op = await client.getOperation(operationId);
|
||||
|
||||
// Verify integrity (signature, chain, receipt, merkle)
|
||||
const result = await client.verifyOperation(operationId);
|
||||
```
|
||||
|
||||
### Agent Management
|
||||
|
||||
```typescript
|
||||
// Register a new agent
|
||||
const agent = await client.registerAgent({
|
||||
agent_id: 'my-agent',
|
||||
display_name: 'My Agent',
|
||||
responsible_entity: 'team@example.com',
|
||||
keys: [{ kid: 'key-v1', public_key: '<base64url>', algorithm: 'ed25519' }],
|
||||
});
|
||||
|
||||
// Get agent details
|
||||
const details = await client.getAgent(agentId);
|
||||
|
||||
// Freeze an agent
|
||||
await client.freezeAgent(agentId, 'security review');
|
||||
|
||||
// Revoke a key
|
||||
await client.revokeKey(agentId, kid, 'key rotation');
|
||||
|
||||
// List all agents in the organization
|
||||
const { agents } = await client.listAgents();
|
||||
|
||||
// Unfreeze a previously frozen agent
|
||||
await client.unfreezeAgent(agentId, 'review complete');
|
||||
|
||||
// Delete an agent permanently
|
||||
const { deleted } = await client.deleteAgent(agentId);
|
||||
```
|
||||
|
||||
### Audit
|
||||
|
||||
```typescript
|
||||
const results = await client.queryAudit({
|
||||
agent_id: 'agent-123',
|
||||
operation_type: 'inference',
|
||||
start_time: Date.now() - 86400000,
|
||||
end_time: Date.now(),
|
||||
limit: 50,
|
||||
});
|
||||
```
|
||||
|
||||
### Epochs
|
||||
|
||||
```typescript
|
||||
const { epochs } = await client.listEpochs();
|
||||
const { epoch } = await client.getEpoch(epochId);
|
||||
```
|
||||
|
||||
### Exports
|
||||
|
||||
```typescript
|
||||
const { export: exp } = await client.createExport({
|
||||
start_time: Date.now() - 86400000,
|
||||
end_time: Date.now(),
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
const { exports } = await client.listExports();
|
||||
const { export: detail, download_url } = await client.getExport(exportId);
|
||||
|
||||
// Download export file data
|
||||
const data = await client.downloadExport(exportId);
|
||||
```
|
||||
|
||||
### JWKS
|
||||
|
||||
```typescript
|
||||
const { keys } = await client.getJWKS();
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```typescript
|
||||
// Check API health (no authentication required)
|
||||
const health = await client.health();
|
||||
// health.status, health.version, health.protocol_version, health.timestamp
|
||||
```
|
||||
|
||||
### Client State
|
||||
|
||||
```typescript
|
||||
// Get the current chain hash (useful for debugging/inspection)
|
||||
const chainHash = client.getChainHash();
|
||||
|
||||
// Get the Ed25519 public key derived from the configured private key
|
||||
const publicKey = client.getPublicKey();
|
||||
```
|
||||
|
||||
### Crypto Utilities
|
||||
|
||||
The SDK exports low-level cryptographic primitives for advanced use:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
jcsCanonicalise, // RFC 8785 JSON Canonicalization
|
||||
sha256Base64url, // SHA-256 hash as base64url
|
||||
computeChainHash, // Chain hash computation
|
||||
computePayloadHash, // Payload hash computation
|
||||
signEd25519, // Ed25519 signing
|
||||
derivePublicKey, // Derive public key from private seed
|
||||
ZERO_CHAIN_HASH, // Genesis chain hash constant
|
||||
} from '@elydora/sdk';
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```typescript
|
||||
import {
|
||||
uuidv7, // Generate a UUIDv7 (time-ordered, RFC 9562)
|
||||
generateNonce, // Generate a 16-byte random nonce (base64url)
|
||||
base64urlEncode, // Encode Buffer/Uint8Array to base64url (no padding)
|
||||
base64urlDecode, // Decode base64url string to Buffer
|
||||
} from '@elydora/sdk';
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { ElydoraError } from '@elydora/sdk';
|
||||
|
||||
try {
|
||||
await client.submitOperation(eor);
|
||||
} catch (err) {
|
||||
if (err instanceof ElydoraError) {
|
||||
console.error(err.code); // e.g. 'INVALID_SIGNATURE'
|
||||
console.error(err.message); // Human-readable message
|
||||
console.error(err.statusCode); // HTTP status code
|
||||
console.error(err.requestId); // Request ID for support
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Generated
+51
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@elydora/sdk",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@elydora/sdk",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
|
||||
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@elydora/sdk",
|
||||
"version": "1.1.0",
|
||||
"description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"elydora": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/postbuild.mjs",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const cliPath = resolve(__dirname, '..', 'dist', 'cli.js');
|
||||
|
||||
const content = await readFile(cliPath, 'utf-8');
|
||||
if (!content.startsWith('#!/')) {
|
||||
await writeFile(cliPath, '#!/usr/bin/env node\n' + content, 'utf-8');
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { parseArgs } from 'node:util';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { derivePublicKey } from './crypto.js';
|
||||
import { SUPPORTED_AGENTS } from './plugins/registry.js';
|
||||
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
|
||||
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
|
||||
import { claudecodePlugin } from './plugins/claudecode.js';
|
||||
import { cursorPlugin } from './plugins/cursor.js';
|
||||
import { geminiPlugin } from './plugins/gemini.js';
|
||||
import { kirocliPlugin } from './plugins/kirocli.js';
|
||||
import { kiroidePlugin } from './plugins/kiroide.js';
|
||||
import { opencodePlugin } from './plugins/opencode.js';
|
||||
import { copilotPlugin } from './plugins/copilot.js';
|
||||
import { lettaPlugin } from './plugins/letta.js';
|
||||
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
|
||||
const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
||||
['claudecode', claudecodePlugin],
|
||||
['cursor', cursorPlugin],
|
||||
['gemini', geminiPlugin],
|
||||
['kirocli', kirocliPlugin],
|
||||
['kiroide', kiroidePlugin],
|
||||
['opencode', opencodePlugin],
|
||||
['copilot', copilotPlugin],
|
||||
['letta', lettaPlugin],
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function die(message: string): never {
|
||||
console.error(`Error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
|
||||
|
||||
Usage:
|
||||
elydora install --agent <name> --org_id <id> --agent_id <id> --private_key <key> --kid <kid> [--token <jwt>] [--base_url <url>]
|
||||
elydora uninstall --agent <name> [--agent_id <id>]
|
||||
elydora status
|
||||
elydora agents
|
||||
|
||||
Commands:
|
||||
install Install Elydora audit hook for a coding agent
|
||||
uninstall Remove Elydora audit hook for a coding agent
|
||||
status Show installation status for all agents
|
||||
agents List supported coding agents
|
||||
|
||||
Supported agents: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function cmdInstall(args: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
org_id: { type: 'string' },
|
||||
agent_id: { type: 'string' },
|
||||
private_key: { type: 'string' },
|
||||
kid: { type: 'string' },
|
||||
token: { type: 'string' },
|
||||
base_url: { type: 'string' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const agentName = values.agent;
|
||||
if (!agentName) die('--agent is required');
|
||||
if (!SUPPORTED_AGENTS.has(agentName)) {
|
||||
die(`Unknown agent "${agentName}". Supported: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}`);
|
||||
}
|
||||
|
||||
const orgId = values.org_id;
|
||||
if (!orgId) die('--org_id is required');
|
||||
|
||||
const agentId = values.agent_id;
|
||||
if (!agentId) die('--agent_id is required');
|
||||
|
||||
const privateKey = values.private_key;
|
||||
if (!privateKey) die('--private_key is required');
|
||||
|
||||
const kid = values.kid;
|
||||
if (!kid) die('--kid is required');
|
||||
|
||||
const token = values.token;
|
||||
const baseUrl = values.base_url ?? 'https://api.elydora.com';
|
||||
|
||||
// Validate private key by deriving public key
|
||||
let publicKey: string;
|
||||
try {
|
||||
publicKey = derivePublicKey(privateKey);
|
||||
} catch {
|
||||
die('Invalid private key — could not derive public key');
|
||||
}
|
||||
|
||||
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`);
|
||||
|
||||
// Create ~/.elydora/{agentId}/ directory
|
||||
const agentDir = path.join(ELYDORA_DIR, agentId);
|
||||
await fsp.mkdir(agentDir, { recursive: true });
|
||||
|
||||
// Write agent config
|
||||
const agentConfigPath = path.join(agentDir, 'config.json');
|
||||
const agentConfig = {
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
kid,
|
||||
base_url: baseUrl,
|
||||
...(token ? { token } : {}),
|
||||
agent_name: agentName,
|
||||
};
|
||||
await fsp.writeFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n', 'utf-8');
|
||||
if (process.platform !== 'win32') {
|
||||
await fsp.chmod(agentConfigPath, 0o600);
|
||||
}
|
||||
console.log(` Agent config: ${agentConfigPath}`);
|
||||
|
||||
// Write private key (chmod 600)
|
||||
const keyPath = path.join(agentDir, 'private.key');
|
||||
await fsp.writeFile(keyPath, privateKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') {
|
||||
await fsp.chmod(keyPath, 0o600);
|
||||
}
|
||||
console.log(` Private key: ${keyPath}`);
|
||||
|
||||
// Generate and write hook script (PostToolUse — audit logging)
|
||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||
const hookScript = generateHookScript(agentName, agentId);
|
||||
await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
console.log(` Hook script: ${hookScriptPath}`);
|
||||
|
||||
// Generate and write guard script (PreToolUse — freeze enforcement)
|
||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||
const guardScript = generateGuardScript(agentName, agentId);
|
||||
await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 });
|
||||
console.log(` Guard script: ${guardScriptPath}`);
|
||||
|
||||
// Install agent-specific config hook
|
||||
const plugin = PLUGINS.get(agentName)!;
|
||||
const installConfig: InstallConfig = {
|
||||
agentName,
|
||||
orgId,
|
||||
agentId,
|
||||
privateKey,
|
||||
kid,
|
||||
token,
|
||||
baseUrl,
|
||||
hookScriptPath,
|
||||
guardScriptPath,
|
||||
};
|
||||
|
||||
await plugin.install(installConfig);
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(agentName)!;
|
||||
console.log(`\nElydora audit hook installed for ${entry.name}.`);
|
||||
}
|
||||
|
||||
async function cmdUninstall(args: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
agent_id: { type: 'string' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const agentName = values.agent;
|
||||
if (!agentName) die('--agent is required');
|
||||
if (!SUPPORTED_AGENTS.has(agentName)) {
|
||||
die(`Unknown agent "${agentName}". Supported: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}`);
|
||||
}
|
||||
|
||||
let agentId = values.agent_id;
|
||||
|
||||
// If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name
|
||||
if (!agentId) {
|
||||
try {
|
||||
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
|
||||
try {
|
||||
const raw = await fsp.readFile(cfgPath, 'utf-8');
|
||||
const cfg = JSON.parse(raw);
|
||||
if (cfg.agent_name === agentName) {
|
||||
agentId = entry.name;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable configs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ELYDORA_DIR may not exist
|
||||
}
|
||||
if (!agentId) {
|
||||
die(`No installed agent found for "${agentName}". Use --agent_id to specify explicitly.`);
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = PLUGINS.get(agentName)!;
|
||||
const registryEntry = SUPPORTED_AGENTS.get(agentName)!;
|
||||
|
||||
// Uninstall agent-specific config
|
||||
await plugin.uninstall(agentId);
|
||||
|
||||
// Remove entire agent directory
|
||||
const agentDir = path.join(ELYDORA_DIR, agentId);
|
||||
try {
|
||||
await fsp.rm(agentDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
|
||||
console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`);
|
||||
}
|
||||
|
||||
async function cmdStatus(): Promise<void> {
|
||||
console.log('Elydora Agent Status\n');
|
||||
|
||||
let anyInstalled = false;
|
||||
|
||||
// Scan ~/.elydora/*/config.json to discover installed agents
|
||||
const installedAgents: Array<{ agentId: string; agentName: string; configPath: string }> = [];
|
||||
try {
|
||||
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
|
||||
try {
|
||||
const raw = await fsp.readFile(cfgPath, 'utf-8');
|
||||
const cfg = JSON.parse(raw);
|
||||
if (cfg.agent_name && cfg.agent_id) {
|
||||
installedAgents.push({ agentId: entry.name, agentName: cfg.agent_name, configPath: cfgPath });
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable configs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ELYDORA_DIR may not exist
|
||||
}
|
||||
|
||||
for (const [name, plugin] of PLUGINS) {
|
||||
const st = await plugin.status();
|
||||
const statusIcon = st.installed ? '[installed]' : '[not installed]';
|
||||
|
||||
// Find matching installed agent(s) for this plugin
|
||||
const matching = installedAgents.filter((a) => a.agentName === name);
|
||||
|
||||
console.log(` ${st.displayName} (${name}) ${statusIcon}`);
|
||||
if (st.installed || st.hookConfigured || st.hookScriptExists) {
|
||||
console.log(` Hook config: ${st.hookConfigured ? 'yes' : 'no'}`);
|
||||
console.log(` Hook script: ${st.hookScriptExists ? 'yes' : 'no'}`);
|
||||
console.log(` Config path: ${st.configPath}`);
|
||||
for (const m of matching) {
|
||||
console.log(` Agent ID: ${m.agentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (st.installed) anyInstalled = true;
|
||||
}
|
||||
|
||||
if (!anyInstalled) {
|
||||
console.log('\nNo agents installed. Run "elydora install --agent <name>" to get started.');
|
||||
}
|
||||
}
|
||||
|
||||
function cmdAgents(): void {
|
||||
console.log('Supported Coding Agents:\n');
|
||||
for (const [key, entry] of SUPPORTED_AGENTS) {
|
||||
console.log(` ${key.padEnd(12)} ${entry.name}`);
|
||||
}
|
||||
console.log('\nUse "elydora install --agent <name>" to install an audit hook.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
switch (command) {
|
||||
case 'install':
|
||||
await cmdInstall(commandArgs);
|
||||
break;
|
||||
case 'uninstall':
|
||||
await cmdUninstall(commandArgs);
|
||||
break;
|
||||
case 'status':
|
||||
await cmdStatus();
|
||||
break;
|
||||
case 'agents':
|
||||
cmdAgents();
|
||||
break;
|
||||
default:
|
||||
die(`Unknown command "${command}". Run "elydora --help" for usage.`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import type {
|
||||
ElydoraClientConfig,
|
||||
CreateOperationParams,
|
||||
EOR,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
UnfreezeAgentResponse,
|
||||
DeleteAgentResponse,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
ListEpochsResponse,
|
||||
GetEpochResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
ListExportsResponse,
|
||||
GetExportResponse,
|
||||
GetMeResponse,
|
||||
IssueApiTokenResponse,
|
||||
JWKSResponse,
|
||||
HealthResponse,
|
||||
AuthRegisterResponse,
|
||||
AuthLoginResponse,
|
||||
ErrorResponse,
|
||||
} from './types.js';
|
||||
import { ElydoraError } from './errors.js';
|
||||
import {
|
||||
jcsCanonicalise,
|
||||
computePayloadHash,
|
||||
computeChainHash,
|
||||
signEd25519,
|
||||
derivePublicKey,
|
||||
ZERO_CHAIN_HASH,
|
||||
} from './crypto.js';
|
||||
import { uuidv7, generateNonce } from './utils.js';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.elydora.com';
|
||||
const DEFAULT_TTL_MS = 30_000;
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
|
||||
export class ElydoraClient {
|
||||
private readonly orgId: string;
|
||||
private readonly agentId: string;
|
||||
private readonly privateKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxRetries: number;
|
||||
private readonly kid: string;
|
||||
private prevChainHash: string;
|
||||
private token: string | undefined;
|
||||
|
||||
constructor(config: ElydoraClientConfig) {
|
||||
this.orgId = config.orgId;
|
||||
this.agentId = config.agentId;
|
||||
this.privateKey = config.privateKey;
|
||||
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
|
||||
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
||||
this.kid = config.kid ?? this.agentId + '-key-v1';
|
||||
this.prevChainHash = ZERO_CHAIN_HASH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the JWT token used for authenticated API calls.
|
||||
*/
|
||||
setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current chain hash (useful for debugging/inspection).
|
||||
*/
|
||||
getChainHash(): string {
|
||||
return this.prevChainHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key derived from the configured private key.
|
||||
*/
|
||||
getPublicKey(): string {
|
||||
return derivePublicKey(this.privateKey);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth (static methods — no instance needed)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static async register(
|
||||
baseUrl: string,
|
||||
email: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
orgName?: string,
|
||||
): Promise<AuthRegisterResponse> {
|
||||
const url = `${baseUrl.replace(/\/+$/, '')}/v1/auth/register`;
|
||||
const body: Record<string, unknown> = { email, password };
|
||||
if (displayName !== undefined) body.display_name = displayName;
|
||||
if (orgName !== undefined) body.org_name = orgName;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return handleResponse<AuthRegisterResponse>(res);
|
||||
}
|
||||
|
||||
static async login(
|
||||
baseUrl: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthLoginResponse> {
|
||||
const url = `${baseUrl.replace(/\/+$/, '')}/v1/auth/login`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
return handleResponse<AuthLoginResponse>(res);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth (instance methods — require token)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getMe(): Promise<GetMeResponse> {
|
||||
return this.request<GetMeResponse>('GET', '/v1/auth/me');
|
||||
}
|
||||
|
||||
async issueApiToken(ttlSeconds?: number | null): Promise<IssueApiTokenResponse> {
|
||||
return this.request<IssueApiTokenResponse>('POST', '/v1/auth/token', { ttl_seconds: ttlSeconds ?? null });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Agent management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async registerAgent(request: RegisterAgentRequest): Promise<RegisterAgentResponse> {
|
||||
return this.request<RegisterAgentResponse>('POST', '/v1/agents/register', request);
|
||||
}
|
||||
|
||||
async getAgent(agentId: string): Promise<GetAgentResponse> {
|
||||
return this.request<GetAgentResponse>('GET', `/v1/agents/${encodeURIComponent(agentId)}`);
|
||||
}
|
||||
|
||||
async freezeAgent(agentId: string, reason: string): Promise<void> {
|
||||
await this.request<unknown>('POST', `/v1/agents/${encodeURIComponent(agentId)}/freeze`, { reason });
|
||||
}
|
||||
|
||||
async listAgents(): Promise<ListAgentsResponse> {
|
||||
return this.request<ListAgentsResponse>('GET', '/v1/agents');
|
||||
}
|
||||
|
||||
async unfreezeAgent(agentId: string, reason: string): Promise<UnfreezeAgentResponse> {
|
||||
return this.request<UnfreezeAgentResponse>('POST', `/v1/agents/${encodeURIComponent(agentId)}/unfreeze`, { reason });
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<DeleteAgentResponse> {
|
||||
return this.request<DeleteAgentResponse>('DELETE', `/v1/agents/${encodeURIComponent(agentId)}`);
|
||||
}
|
||||
|
||||
async revokeKey(agentId: string, kid: string, reason: string): Promise<void> {
|
||||
await this.request<unknown>('POST', `/v1/agents/${encodeURIComponent(agentId)}/revoke`, { kid, reason });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Operations (core)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construct and sign an Elydora Operation Record (EOR) locally.
|
||||
*
|
||||
* 1. Generate UUIDv7 for operation_id
|
||||
* 2. Generate random nonce (16 bytes, base64url)
|
||||
* 3. Compute payload_hash (SHA-256 of JCS-canonicalized payload)
|
||||
* 4. Compute chain_hash from prev_chain_hash
|
||||
* 5. Construct canonical EOR (minus signature)
|
||||
* 6. Sign JCS-canonicalized EOR with Ed25519
|
||||
* 7. Update internal prev_chain_hash
|
||||
*/
|
||||
createOperation(params: CreateOperationParams): EOR {
|
||||
const operationId = uuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const nonce = generateNonce();
|
||||
const payload = params.payload ?? null;
|
||||
|
||||
// Compute payload hash
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
|
||||
// Compute chain hash
|
||||
const chainHash = computeChainHash(
|
||||
this.prevChainHash,
|
||||
payloadHash,
|
||||
operationId,
|
||||
issuedAt,
|
||||
);
|
||||
|
||||
// Construct the EOR without signature for signing
|
||||
const eorWithoutSig: Omit<EOR, 'signature'> = {
|
||||
op_version: '1.0',
|
||||
operation_id: operationId,
|
||||
org_id: this.orgId,
|
||||
agent_id: this.agentId,
|
||||
issued_at: issuedAt,
|
||||
ttl_ms: this.ttlMs,
|
||||
nonce,
|
||||
operation_type: params.operationType,
|
||||
subject: params.subject,
|
||||
action: params.action,
|
||||
payload,
|
||||
payload_hash: payloadHash,
|
||||
prev_chain_hash: this.prevChainHash,
|
||||
agent_pubkey_kid: this.kid,
|
||||
};
|
||||
|
||||
// Sign the JCS-canonicalized EOR (without signature field)
|
||||
const canonical = jcsCanonicalise(eorWithoutSig);
|
||||
const signature = signEd25519(this.privateKey, Buffer.from(canonical, 'utf-8'));
|
||||
|
||||
// Update internal chain state
|
||||
this.prevChainHash = chainHash;
|
||||
|
||||
return {
|
||||
...eorWithoutSig,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
async submitOperation(eor: EOR): Promise<SubmitOperationResponse> {
|
||||
return this.request<SubmitOperationResponse>('POST', '/v1/operations', eor);
|
||||
}
|
||||
|
||||
async getOperation(operationId: string): Promise<GetOperationResponse> {
|
||||
return this.request<GetOperationResponse>('GET', `/v1/operations/${encodeURIComponent(operationId)}`);
|
||||
}
|
||||
|
||||
async verifyOperation(operationId: string): Promise<VerifyOperationResponse> {
|
||||
return this.request<VerifyOperationResponse>('POST', `/v1/operations/${encodeURIComponent(operationId)}/verify`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Audit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async queryAudit(params: AuditQueryRequest): Promise<AuditQueryResponse> {
|
||||
return this.request<AuditQueryResponse>('POST', '/v1/audit/query', params);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Epochs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async listEpochs(): Promise<ListEpochsResponse> {
|
||||
return this.request<ListEpochsResponse>('GET', '/v1/epochs');
|
||||
}
|
||||
|
||||
async getEpoch(epochId: string): Promise<GetEpochResponse> {
|
||||
return this.request<GetEpochResponse>('GET', `/v1/epochs/${encodeURIComponent(epochId)}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Exports
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async createExport(params: CreateExportRequest): Promise<CreateExportResponse> {
|
||||
return this.request<CreateExportResponse>('POST', '/v1/exports', params);
|
||||
}
|
||||
|
||||
async listExports(): Promise<ListExportsResponse> {
|
||||
return this.request<ListExportsResponse>('GET', '/v1/exports');
|
||||
}
|
||||
|
||||
async getExport(exportId: string): Promise<GetExportResponse> {
|
||||
return this.request<GetExportResponse>('GET', `/v1/exports/${encodeURIComponent(exportId)}`);
|
||||
}
|
||||
|
||||
async downloadExport(exportId: string): Promise<ArrayBuffer> {
|
||||
const url = `${this.baseUrl}/v1/exports/${encodeURIComponent(exportId)}/download`;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: 'GET', headers });
|
||||
|
||||
if (!res.ok) {
|
||||
return handleResponse<never>(res);
|
||||
}
|
||||
|
||||
return res.arrayBuffer();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JWKS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getJWKS(): Promise<JWKSResponse> {
|
||||
const url = `${this.baseUrl}/.well-known/elydora/jwks.json`;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
return handleResponse<JWKSResponse>(res);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Health
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async health(): Promise<HealthResponse> {
|
||||
const url = `${this.baseUrl}/v1/health`;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
return handleResponse<HealthResponse>(res);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal HTTP helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Only retry on 429 or 5xx
|
||||
if (attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
|
||||
const retryAfter = res.headers.get('Retry-After');
|
||||
const delayMs = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
: Math.min(1000 * 2 ** attempt, 10_000);
|
||||
await sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
return await handleResponse<T>(res);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
// Only retry on network errors, not API errors
|
||||
if (err instanceof ElydoraError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (attempt < this.maxRetries) {
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 10_000));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Request failed after retries');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (res.ok) {
|
||||
// 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
let errorBody: ErrorResponse | undefined;
|
||||
try {
|
||||
errorBody = (await res.json()) as ErrorResponse;
|
||||
} catch {
|
||||
// Response body was not valid JSON
|
||||
}
|
||||
|
||||
if (errorBody?.error) {
|
||||
throw new ElydoraError(
|
||||
res.status,
|
||||
errorBody.error.code,
|
||||
errorBody.error.message,
|
||||
errorBody.error.request_id,
|
||||
errorBody.error.details,
|
||||
);
|
||||
}
|
||||
|
||||
throw new ElydoraError(
|
||||
res.status,
|
||||
'INTERNAL_ERROR',
|
||||
`HTTP ${res.status}: ${res.statusText}`,
|
||||
'unknown',
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { base64urlEncode } from './utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JCS Canonicalization (RFC 8785)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Canonicalize a JSON value according to JCS (RFC 8785).
|
||||
*
|
||||
* - Object keys sorted lexicographically by UTF-16 code units
|
||||
* - No whitespace
|
||||
* - Numbers serialized using ES2015 Number serialization
|
||||
* - Strings serialized with minimal escaping per JSON spec
|
||||
*/
|
||||
export function jcsCanonicalise(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean' || typeof value === 'number') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const elements = value.map((v) => jcsCanonicalise(v));
|
||||
return '[' + elements.join(',') + ']';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const pairs: string[] = [];
|
||||
for (const key of keys) {
|
||||
if (obj[key] !== undefined) {
|
||||
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(obj[key]));
|
||||
}
|
||||
}
|
||||
return '{' + pairs.join(',') + '}';
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SHA-256
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute SHA-256 of a UTF-8 string or buffer and return base64url.
|
||||
*/
|
||||
export function sha256Base64url(data: string | Buffer): string {
|
||||
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
|
||||
const hash = crypto.createHash('sha256').update(input).digest();
|
||||
return base64urlEncode(hash);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the chain hash for an operation.
|
||||
*
|
||||
* chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + issued_at)
|
||||
*
|
||||
* All inputs concatenated as UTF-8 strings separated by '|'.
|
||||
*/
|
||||
export function computeChainHash(
|
||||
prevChainHash: string,
|
||||
payloadHash: string,
|
||||
operationId: string,
|
||||
issuedAt: number,
|
||||
): string {
|
||||
const input = `${prevChainHash}|${payloadHash}|${operationId}|${issuedAt}`;
|
||||
return sha256Base64url(input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of JCS-canonicalized payload, returned as base64url.
|
||||
*/
|
||||
export function computePayloadHash(
|
||||
payload: Record<string, unknown> | string | null,
|
||||
): string {
|
||||
const canonical = jcsCanonicalise(payload);
|
||||
return sha256Base64url(canonical);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 signing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PKCS8 prefix for wrapping a 32-byte Ed25519 seed
|
||||
const PKCS8_ED25519_PREFIX = Buffer.from([
|
||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||
0x02, 0x01, 0x00, // INTEGER 0
|
||||
0x30, 0x05, // SEQUENCE (5 bytes)
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
|
||||
0x04, 0x22, // OCTET STRING (34 bytes)
|
||||
0x04, 0x20, // OCTET STRING (32 bytes)
|
||||
]);
|
||||
|
||||
/**
|
||||
* Import a base64url-encoded 32-byte Ed25519 seed as a Node.js KeyObject.
|
||||
*/
|
||||
function importPrivateKey(privateKeyBase64url: string): crypto.KeyObject {
|
||||
const seed = Buffer.from(privateKeyBase64url, 'base64url');
|
||||
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
|
||||
return crypto.createPrivateKey({
|
||||
key: pkcs8,
|
||||
format: 'der',
|
||||
type: 'pkcs8',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign data with Ed25519 using a base64url-encoded 32-byte seed.
|
||||
*
|
||||
* @returns base64url-encoded 64-byte signature
|
||||
*/
|
||||
export function signEd25519(privateKeyBase64url: string, data: Buffer): string {
|
||||
const keyObject = importPrivateKey(privateKeyBase64url);
|
||||
const signature = crypto.sign(null, data, keyObject);
|
||||
return base64urlEncode(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Ed25519 public key from a base64url-encoded 32-byte seed.
|
||||
*
|
||||
* @returns base64url-encoded 32-byte public key
|
||||
*/
|
||||
export function derivePublicKey(privateKeyBase64url: string): string {
|
||||
const keyObject = importPrivateKey(privateKeyBase64url);
|
||||
const publicKeyObject = crypto.createPublicKey(keyObject);
|
||||
const rawPublicKey = publicKeyObject.export({ type: 'spki', format: 'der' });
|
||||
// The raw 32-byte public key is the last 32 bytes of the SPKI DER encoding
|
||||
const publicKeyBytes = rawPublicKey.subarray(rawPublicKey.length - 32);
|
||||
return base64urlEncode(publicKeyBytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zero chain hash (initial prev_chain_hash for the first operation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The initial chain hash value: base64url encoding of 32 zero bytes.
|
||||
* Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
*/
|
||||
export const ZERO_CHAIN_HASH: string = base64urlEncode(Buffer.alloc(32, 0));
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ErrorCode } from './types.js';
|
||||
|
||||
export class ElydoraError extends Error {
|
||||
public readonly code: ErrorCode;
|
||||
public readonly requestId: string;
|
||||
public readonly statusCode: number;
|
||||
public readonly details?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
statusCode: number,
|
||||
code: ErrorCode,
|
||||
message: string,
|
||||
requestId: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ElydoraError';
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export { ElydoraClient } from './client.js';
|
||||
export { ElydoraError } from './errors.js';
|
||||
|
||||
export {
|
||||
jcsCanonicalise,
|
||||
sha256Base64url,
|
||||
computeChainHash,
|
||||
computePayloadHash,
|
||||
signEd25519,
|
||||
derivePublicKey,
|
||||
ZERO_CHAIN_HASH,
|
||||
} from './crypto.js';
|
||||
|
||||
export {
|
||||
uuidv7,
|
||||
generateNonce,
|
||||
base64urlEncode,
|
||||
base64urlDecode,
|
||||
} from './utils.js';
|
||||
|
||||
export type {
|
||||
// Enums
|
||||
AgentStatus,
|
||||
KeyStatus,
|
||||
ExportStatus,
|
||||
RbacRole,
|
||||
ErrorCode,
|
||||
|
||||
// Entities
|
||||
Agent,
|
||||
AgentKey,
|
||||
Operation,
|
||||
Receipt,
|
||||
Epoch,
|
||||
Organization,
|
||||
User,
|
||||
Export,
|
||||
|
||||
// Protocol
|
||||
EOR,
|
||||
EAR,
|
||||
|
||||
// API request/response
|
||||
ElydoraClientConfig,
|
||||
CreateOperationParams,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
UnfreezeAgentResponse,
|
||||
DeleteAgentResponse,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
GetEpochResponse,
|
||||
ListEpochsResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
ListExportsResponse,
|
||||
GetMeResponse,
|
||||
IssueApiTokenResponse,
|
||||
JWK,
|
||||
JWKSResponse,
|
||||
HealthResponse,
|
||||
AuthRegisterResponse,
|
||||
AuthLoginResponse,
|
||||
ErrorResponse,
|
||||
} from './types.js';
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface InstallConfig {
|
||||
readonly agentName: string;
|
||||
readonly orgId: string;
|
||||
readonly agentId: string;
|
||||
readonly privateKey: string;
|
||||
readonly kid: string;
|
||||
readonly token?: string;
|
||||
readonly baseUrl: string;
|
||||
readonly hookScriptPath: string;
|
||||
readonly guardScriptPath: string;
|
||||
}
|
||||
|
||||
export interface PluginStatus {
|
||||
readonly installed: boolean;
|
||||
readonly agentName: string;
|
||||
readonly displayName: string;
|
||||
readonly hookConfigured: boolean;
|
||||
readonly hookScriptExists: boolean;
|
||||
readonly configPath: string;
|
||||
}
|
||||
|
||||
export interface AgentPlugin {
|
||||
install(config: InstallConfig): Promise<void>;
|
||||
uninstall(agentId?: string): Promise<void>;
|
||||
status(): Promise<PluginStatus>;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'claudecode';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const claudecodePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'copilot';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return path.join(process.cwd(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((h) => {
|
||||
if (typeof h.bash === 'string' && isElydoraCommand(h.bash, agentId)) return false;
|
||||
if (typeof h.powershell === 'string' && isElydoraCommand(h.powershell, agentId)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const copilotPlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = { version: 1 };
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
settings.version = 1;
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
type: 'command',
|
||||
bash: buildHookCommand(config.guardScriptPath),
|
||||
powershell: buildHookCommand(config.guardScriptPath),
|
||||
timeoutSec: 5,
|
||||
});
|
||||
hooks.preToolUse = preFiltered;
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
type: 'command',
|
||||
bash: buildHookCommand(config.hookScriptPath),
|
||||
powershell: buildHookCommand(config.hookScriptPath),
|
||||
timeoutSec: 5,
|
||||
});
|
||||
hooks.postToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const check = (arr: unknown) =>
|
||||
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
||||
(h) =>
|
||||
(typeof h.bash === 'string' && h.bash.includes('elydora')) ||
|
||||
(typeof h.powershell === 'string' && h.powershell.includes('elydora')),
|
||||
);
|
||||
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
||||
|
||||
// Extract hook script path from postToolUse bash/powershell command
|
||||
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
|
||||
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
|
||||
const cmd = (h.bash || h.powershell) as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'cursor';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
// .cursor is project-relative, but for global install use home dir
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter(
|
||||
(h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId),
|
||||
);
|
||||
}
|
||||
|
||||
export const cursorPlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({ command: buildHookCommand(config.guardScriptPath) });
|
||||
hooks.preToolUse = preFiltered;
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({ command: buildHookCommand(config.hookScriptPath) });
|
||||
hooks.postToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
settings.version = 1;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.preToolUse)) {
|
||||
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.postToolUse)) {
|
||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const check = (arr: unknown) =>
|
||||
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
||||
|
||||
// Extract hook script path from postToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
|
||||
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'gemini';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') return !isElydoraCommand(entry.command, agentId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const geminiPlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- BeforeTool (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.BeforeTool)) {
|
||||
hooks.BeforeTool = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.BeforeTool = preFiltered;
|
||||
|
||||
// --- AfterTool (audit logging) ---
|
||||
if (!Array.isArray(hooks.AfterTool)) {
|
||||
hooks.AfterTool = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.AfterTool = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.BeforeTool)) {
|
||||
hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.AfterTool)) {
|
||||
hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.BeforeTool) && checkArr(hooks.AfterTool);
|
||||
|
||||
// Extract hook script path from AfterTool command
|
||||
if (hookConfigured && Array.isArray(hooks.AfterTool)) {
|
||||
for (const e of hooks.AfterTool as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Generates a self-contained Node.js hook script that embeds all crypto logic
|
||||
* inline so it has zero runtime dependency on the SDK.
|
||||
*/
|
||||
|
||||
export function generateHookScript(agentName: string, agentId: string): string {
|
||||
return `#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-contained Elydora audit hook for: ${agentName}
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const AGENT_NAME = ${JSON.stringify(agentName)};
|
||||
const AGENT_ID = ${JSON.stringify(agentId)};
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
|
||||
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64urlEncode(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
function jcsCanonicalise(value) {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
|
||||
if (typeof value === 'string') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value).sort();
|
||||
const pairs = [];
|
||||
for (const key of keys) {
|
||||
if (value[key] !== undefined) {
|
||||
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
|
||||
}
|
||||
}
|
||||
return '{' + pairs.join(',') + '}';
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function sha256Base64url(data) {
|
||||
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
|
||||
return base64urlEncode(crypto.createHash('sha256').update(input).digest());
|
||||
}
|
||||
|
||||
function computePayloadHash(payload) {
|
||||
return sha256Base64url(jcsCanonicalise(payload));
|
||||
}
|
||||
|
||||
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) {
|
||||
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt;
|
||||
return sha256Base64url(input);
|
||||
}
|
||||
|
||||
const PKCS8_ED25519_PREFIX = Buffer.from([
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05,
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
]);
|
||||
|
||||
function signEd25519(privateKeyBase64url, data) {
|
||||
const seed = Buffer.from(privateKeyBase64url, 'base64url');
|
||||
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
|
||||
const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
||||
const signature = crypto.sign(null, data, keyObject);
|
||||
return base64urlEncode(signature);
|
||||
}
|
||||
|
||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
|
||||
function uuidv7() {
|
||||
const now = Date.now();
|
||||
const random = crypto.randomBytes(10);
|
||||
const bytes = Buffer.alloc(16);
|
||||
bytes[0] = (now / 2 ** 40) & 0xff;
|
||||
bytes[1] = (now / 2 ** 32) & 0xff;
|
||||
bytes[2] = (now / 2 ** 24) & 0xff;
|
||||
bytes[3] = (now / 2 ** 16) & 0xff;
|
||||
bytes[4] = (now / 2 ** 8) & 0xff;
|
||||
bytes[5] = now & 0xff;
|
||||
bytes[6] = 0x70 | (random[0] & 0x0f);
|
||||
bytes[7] = random[1];
|
||||
bytes[8] = 0x80 | (random[2] & 0x3f);
|
||||
bytes[9] = random[3];
|
||||
bytes[10] = random[4];
|
||||
bytes[11] = random[5];
|
||||
bytes[12] = random[6];
|
||||
bytes[13] = random[7];
|
||||
bytes[14] = random[8];
|
||||
bytes[15] = random[9];
|
||||
const hex = bytes.toString('hex');
|
||||
return (
|
||||
hex.slice(0, 8) + '-' +
|
||||
hex.slice(8, 12) + '-' +
|
||||
hex.slice(12, 16) + '-' +
|
||||
hex.slice(16, 20) + '-' +
|
||||
hex.slice(20, 32)
|
||||
);
|
||||
}
|
||||
|
||||
function generateNonce() {
|
||||
return base64urlEncode(crypto.randomBytes(16));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain state persistence (atomic writes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readChainState() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
|
||||
const state = JSON.parse(raw);
|
||||
return state.prev_chain_hash || ZERO_CHAIN_HASH;
|
||||
} catch {
|
||||
return ZERO_CHAIN_HASH;
|
||||
}
|
||||
}
|
||||
|
||||
function writeChainState(chainHash) {
|
||||
const tmpPath = CHAIN_STATE_PATH + '.tmp';
|
||||
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
|
||||
try {
|
||||
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||
} catch (err) {
|
||||
if (err.code === 'EPERM' || err.code === 'EEXIST') {
|
||||
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch { /* ignore */ }
|
||||
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function logError(err) {
|
||||
try {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\\n';
|
||||
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
|
||||
} catch {
|
||||
// Silently ignore logging failures
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main hook logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Read JSON from stdin
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (!rawInput) return;
|
||||
|
||||
let hookData;
|
||||
try {
|
||||
hookData = JSON.parse(rawInput);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract tool_name and tool_input from hook data
|
||||
// Different agents may pass data in different shapes
|
||||
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
|
||||
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
|
||||
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
|
||||
|
||||
// Read agent config and private key
|
||||
let config;
|
||||
let privateKey;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
|
||||
} catch (err) {
|
||||
logError(new Error('Failed to read agent config/key: ' + err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = config.org_id;
|
||||
const agentId = config.agent_id;
|
||||
const kid = config.kid;
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
|
||||
const token = config.token || '';
|
||||
|
||||
// Read chain state
|
||||
const prevChainHash = readChainState();
|
||||
|
||||
// Build EOR
|
||||
const operationId = uuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const nonce = generateNonce();
|
||||
|
||||
const payload = {
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
};
|
||||
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
||||
|
||||
const eorWithoutSig = {
|
||||
op_version: '1.0',
|
||||
operation_id: operationId,
|
||||
org_id: orgId,
|
||||
agent_id: agentId,
|
||||
issued_at: issuedAt,
|
||||
ttl_ms: 30000,
|
||||
nonce: nonce,
|
||||
operation_type: 'ai.tool_use',
|
||||
subject: { session_id: sessionId },
|
||||
action: { tool: toolName },
|
||||
payload: payload,
|
||||
payload_hash: payloadHash,
|
||||
prev_chain_hash: prevChainHash,
|
||||
agent_pubkey_kid: kid,
|
||||
};
|
||||
|
||||
const canonical = jcsCanonicalise(eorWithoutSig);
|
||||
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
|
||||
|
||||
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
||||
|
||||
// POST to /v1/operations (5s timeout)
|
||||
// Only update local chain state on success to prevent desync
|
||||
const url = baseUrl + '/v1/operations';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(eor),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Server accepted — safe to advance local chain state
|
||||
writeChainState(chainHash);
|
||||
} else if (res.status === 400) {
|
||||
// Possible chain hash mismatch — try to resync from server response
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
||||
// Extract the expected hash from the error message
|
||||
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
||||
if (match) {
|
||||
writeChainState(match[1]);
|
||||
logError(new Error('Chain hash resynced to server: ' + match[1]));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
} catch {
|
||||
// Network error — don't advance chain state, will retry with same prev_chain_hash
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a lightweight PreToolUse guard script that checks if the agent
|
||||
* is frozen and blocks tool execution if so.
|
||||
*
|
||||
* - Caches the agent status locally (60s TTL) to avoid hitting the API on every tool call
|
||||
* - Fails open: if the API is unreachable, tool execution is allowed
|
||||
* - If frozen: exits with code 1 to block the tool
|
||||
*/
|
||||
export function generateGuardScript(agentName: string, agentId: string): string {
|
||||
return `#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elydora agent guard (PreToolUse) for: ${agentName}
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = ${JSON.stringify(agentName)};
|
||||
const AGENT_ID = ${JSON.stringify(agentId)};
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||
|
||||
// Discard stdin immediately so the process can exit cleanly
|
||||
process.stdin.destroy();
|
||||
|
||||
async function main() {
|
||||
// Read agent config
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return; // Can't read config — fail-open (allow)
|
||||
}
|
||||
|
||||
// Check cached status
|
||||
try {
|
||||
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
|
||||
const cache = JSON.parse(raw);
|
||||
const age = Date.now() - (cache.cached_at || 0);
|
||||
|
||||
if (age < CACHE_TTL_MS) {
|
||||
if (cache.status === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
return; // Cache is fresh — use cached result
|
||||
}
|
||||
} catch {
|
||||
// No cache or invalid — need to check API
|
||||
}
|
||||
|
||||
// Fetch agent status from API
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
|
||||
const token = config.token || '';
|
||||
const agentId = config.agent_id;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
|
||||
headers: headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const agentStatus = (data.agent && data.agent.status) || 'active';
|
||||
|
||||
// Update cache
|
||||
try {
|
||||
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
|
||||
status: agentStatus,
|
||||
cached_at: Date.now(),
|
||||
}), 'utf-8');
|
||||
} catch {
|
||||
// Ignore cache write failures
|
||||
}
|
||||
|
||||
if (agentStatus === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API unreachable — fail-open (allow)
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'kirocli';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const kirocliPlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath), timeout_ms: 5000 }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath), timeout_ms: 5000 }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'kiroide';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
// .kiro/hooks is project-relative, but for global install use home dir
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
export const kiroidePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
const hookConfig = {
|
||||
name: 'Elydora Audit',
|
||||
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
||||
version: '1.0.0',
|
||||
hooks: {
|
||||
pre_tool_use: {
|
||||
command: `node "${config.guardScriptPath}"`,
|
||||
timeout_ms: 5000,
|
||||
},
|
||||
post_tool_use: {
|
||||
command: `node "${config.hookScriptPath}"`,
|
||||
timeout_ms: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(_agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use);
|
||||
|
||||
// Extract hook script path from post_tool_use command
|
||||
if (hookConfigured && config.hooks.post_tool_use) {
|
||||
const cmd = config.hooks.post_tool_use.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'letta';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const lettaPlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'opencode';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
|
||||
return `// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
export const ElydoraAuditPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// Guard — blocks tool if agent is frozen
|
||||
try {
|
||||
const result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
if (result.status === 2) {
|
||||
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
|
||||
// Signal denial by setting a blocked flag
|
||||
if (output && typeof output === 'object') {
|
||||
output.blocked = true;
|
||||
output.reason = msg;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fail-open — allow if guard can't run
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
try {
|
||||
const toolName = input?.tool || 'unknown';
|
||||
const toolInput = output?.args || input?.args || {};
|
||||
const sessionId = ctx?.project?.name || 'unknown';
|
||||
|
||||
const data = JSON.stringify({
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
});
|
||||
|
||||
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.stdin.write(data);
|
||||
child.stdin.end();
|
||||
child.unref();
|
||||
} catch {
|
||||
// Fire-and-forget — never block the developer
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export const opencodePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath)
|
||||
.replace('__ELYDORA_HOOK_SCRIPT_PATH__', config.hookScriptPath.replace(/\\/g, '\\\\'))
|
||||
.replace('__ELYDORA_GUARD_SCRIPT_PATH__', config.guardScriptPath.replace(/\\/g, '\\\\'));
|
||||
|
||||
await fsp.writeFile(configPath, script, 'utf-8');
|
||||
},
|
||||
|
||||
async uninstall(_agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
hookConfigured = true;
|
||||
|
||||
// Extract hook script path from plugin script content
|
||||
// The plugin script contains a spawn call like: spawn('node', ['<path>'])
|
||||
const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/);
|
||||
if (match) {
|
||||
hookScriptPath = match[1];
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface AgentRegistryEntry {
|
||||
readonly name: string;
|
||||
readonly configDir: string;
|
||||
readonly configFile: string;
|
||||
}
|
||||
|
||||
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
|
||||
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
|
||||
['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }],
|
||||
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro', configFile: 'settings.json' }],
|
||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
|
||||
['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }],
|
||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||
]);
|
||||
@@ -0,0 +1,330 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums / status types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AgentStatus = 'active' | 'frozen' | 'revoked';
|
||||
export type KeyStatus = 'active' | 'retired' | 'revoked';
|
||||
export type ExportStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||
export type RbacRole =
|
||||
| 'org_owner'
|
||||
| 'security_admin'
|
||||
| 'compliance_auditor'
|
||||
| 'readonly_investigator'
|
||||
| 'integration_engineer';
|
||||
export type ErrorCode =
|
||||
| 'INVALID_SIGNATURE'
|
||||
| 'UNKNOWN_AGENT'
|
||||
| 'KEY_REVOKED'
|
||||
| 'AGENT_FROZEN'
|
||||
| 'TTL_EXPIRED'
|
||||
| 'REPLAY_DETECTED'
|
||||
| 'PREV_HASH_MISMATCH'
|
||||
| 'PAYLOAD_TOO_LARGE'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INTERNAL_ERROR'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'NOT_FOUND'
|
||||
| 'VALIDATION_ERROR';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Agent {
|
||||
readonly agent_id: string;
|
||||
readonly org_id: string;
|
||||
readonly display_name: string;
|
||||
readonly responsible_entity: string;
|
||||
readonly status: AgentStatus;
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
export interface AgentKey {
|
||||
readonly kid: string;
|
||||
readonly agent_id: string;
|
||||
readonly public_key: string;
|
||||
readonly algorithm: 'ed25519';
|
||||
readonly status: KeyStatus;
|
||||
readonly created_at: number;
|
||||
readonly retired_at: number | null;
|
||||
}
|
||||
|
||||
export interface Operation {
|
||||
readonly operation_id: string;
|
||||
readonly org_id: string;
|
||||
readonly agent_id: string;
|
||||
readonly seq_no: number;
|
||||
readonly operation_type: string;
|
||||
readonly issued_at: number;
|
||||
readonly ttl_ms: number;
|
||||
readonly nonce: string;
|
||||
readonly subject: string;
|
||||
readonly action: string;
|
||||
readonly payload_hash: string;
|
||||
readonly prev_chain_hash: string;
|
||||
readonly chain_hash: string;
|
||||
readonly agent_pubkey_kid: string;
|
||||
readonly signature: string;
|
||||
readonly r2_payload_key: string | null;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
export interface Receipt {
|
||||
readonly receipt_id: string;
|
||||
readonly operation_id: string;
|
||||
readonly r2_receipt_key: string;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
export interface Epoch {
|
||||
readonly epoch_id: string;
|
||||
readonly org_id: string;
|
||||
readonly start_time: number;
|
||||
readonly end_time: number;
|
||||
readonly root_hash: string;
|
||||
readonly leaf_count: number;
|
||||
readonly r2_epoch_key: string;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
readonly org_id: string;
|
||||
readonly name: string;
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
readonly user_id: string;
|
||||
readonly org_id: string;
|
||||
readonly email: string;
|
||||
readonly display_name: string;
|
||||
readonly role: RbacRole;
|
||||
readonly status: 'active' | 'suspended';
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
export interface Export {
|
||||
readonly export_id: string;
|
||||
readonly org_id: string;
|
||||
readonly status: ExportStatus;
|
||||
readonly query_params: string;
|
||||
readonly r2_export_key: string | null;
|
||||
readonly created_at: number;
|
||||
readonly completed_at: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EOR {
|
||||
readonly op_version: '1.0';
|
||||
readonly operation_id: string;
|
||||
readonly org_id: string;
|
||||
readonly agent_id: string;
|
||||
readonly issued_at: number;
|
||||
readonly ttl_ms: number;
|
||||
readonly nonce: string;
|
||||
readonly operation_type: string;
|
||||
readonly subject: Record<string, unknown>;
|
||||
readonly action: Record<string, unknown>;
|
||||
readonly payload: Record<string, unknown> | string | null;
|
||||
readonly payload_hash: string;
|
||||
readonly prev_chain_hash: string;
|
||||
readonly agent_pubkey_kid: string;
|
||||
readonly signature: string;
|
||||
}
|
||||
|
||||
export interface EAR {
|
||||
readonly receipt_version: string;
|
||||
readonly receipt_id: string;
|
||||
readonly operation_id: string;
|
||||
readonly org_id: string;
|
||||
readonly agent_id: string;
|
||||
readonly server_received_at: number;
|
||||
readonly seq_no: number;
|
||||
readonly chain_hash: string;
|
||||
readonly queue_message_id: string;
|
||||
readonly receipt_hash: string;
|
||||
readonly elydora_kid: string;
|
||||
readonly elydora_signature: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API request/response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegisterAgentRequest {
|
||||
readonly agent_id: string;
|
||||
readonly display_name?: string;
|
||||
readonly responsible_entity?: string;
|
||||
readonly keys: ReadonlyArray<{
|
||||
readonly kid: string;
|
||||
readonly public_key: string;
|
||||
readonly algorithm: 'ed25519';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RegisterAgentResponse {
|
||||
readonly agent: Agent;
|
||||
readonly keys: AgentKey[];
|
||||
}
|
||||
|
||||
export interface GetAgentResponse {
|
||||
readonly agent: Agent;
|
||||
readonly keys: AgentKey[];
|
||||
}
|
||||
|
||||
export interface SubmitOperationResponse {
|
||||
readonly receipt: EAR;
|
||||
}
|
||||
|
||||
export interface GetOperationResponse {
|
||||
readonly operation: Operation;
|
||||
readonly receipt?: Receipt;
|
||||
}
|
||||
|
||||
export interface VerifyOperationResponse {
|
||||
readonly valid: boolean;
|
||||
readonly checks: {
|
||||
readonly signature: boolean;
|
||||
readonly chain: boolean;
|
||||
readonly receipt: boolean;
|
||||
readonly merkle?: boolean;
|
||||
};
|
||||
readonly errors?: string[];
|
||||
}
|
||||
|
||||
export interface AuditQueryRequest {
|
||||
readonly org_id?: string;
|
||||
readonly agent_id?: string;
|
||||
readonly operation_type?: string;
|
||||
readonly start_time?: number;
|
||||
readonly end_time?: number;
|
||||
readonly cursor?: string;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditQueryResponse {
|
||||
readonly operations: Operation[];
|
||||
readonly cursor?: string;
|
||||
readonly total_count: number;
|
||||
}
|
||||
|
||||
export interface GetEpochResponse {
|
||||
readonly epoch: Epoch;
|
||||
readonly anchor?: {
|
||||
readonly tsa_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListEpochsResponse {
|
||||
readonly epochs: Epoch[];
|
||||
}
|
||||
|
||||
export interface CreateExportRequest {
|
||||
readonly start_time: number;
|
||||
readonly end_time: number;
|
||||
readonly agent_id?: string;
|
||||
readonly operation_type?: string;
|
||||
readonly format: 'json' | 'pdf';
|
||||
}
|
||||
|
||||
export interface CreateExportResponse {
|
||||
readonly export: Export;
|
||||
}
|
||||
|
||||
export interface GetExportResponse {
|
||||
readonly export: Export;
|
||||
readonly download_url?: string;
|
||||
}
|
||||
|
||||
export interface ListExportsResponse {
|
||||
readonly exports: Export[];
|
||||
}
|
||||
|
||||
export interface ListAgentsResponse {
|
||||
readonly agents: Agent[];
|
||||
}
|
||||
|
||||
export interface UnfreezeAgentResponse {
|
||||
readonly agent: Agent;
|
||||
}
|
||||
|
||||
export interface DeleteAgentResponse {
|
||||
readonly deleted: boolean;
|
||||
}
|
||||
|
||||
export interface GetMeResponse {
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export interface IssueApiTokenResponse {
|
||||
readonly token: string;
|
||||
readonly expires_at: number | null;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
readonly status: string;
|
||||
readonly version: string;
|
||||
readonly protocol_version: string;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export interface JWK {
|
||||
readonly kty: string;
|
||||
readonly crv?: string;
|
||||
readonly x?: string;
|
||||
readonly kid: string;
|
||||
readonly use: string;
|
||||
readonly alg: string;
|
||||
}
|
||||
|
||||
export interface JWKSResponse {
|
||||
readonly keys: JWK[];
|
||||
}
|
||||
|
||||
export interface AuthRegisterResponse {
|
||||
readonly user: User;
|
||||
readonly organization: Organization;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface AuthLoginResponse {
|
||||
readonly user: User;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
readonly error: {
|
||||
readonly code: ErrorCode;
|
||||
readonly message: string;
|
||||
readonly request_id: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ElydoraClientConfig {
|
||||
readonly orgId: string;
|
||||
readonly agentId: string;
|
||||
readonly privateKey: string;
|
||||
readonly baseUrl?: string;
|
||||
readonly ttlMs?: number;
|
||||
readonly maxRetries?: number;
|
||||
readonly kid?: string;
|
||||
}
|
||||
|
||||
export interface CreateOperationParams {
|
||||
readonly operationType: string;
|
||||
readonly subject: Record<string, unknown>;
|
||||
readonly action: Record<string, unknown>;
|
||||
readonly payload?: Record<string, unknown> | string | null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Generate a UUIDv7 (time-ordered UUID per RFC 9562).
|
||||
*
|
||||
* Layout (128 bits):
|
||||
* 48-bit Unix timestamp (ms) | 4-bit version (7) | 12-bit rand_a
|
||||
* 2-bit variant (10) | 62-bit rand_b
|
||||
*/
|
||||
export function uuidv7(): string {
|
||||
const now = Date.now();
|
||||
const random = crypto.randomBytes(10);
|
||||
|
||||
// Bytes 0-5: 48-bit timestamp (ms)
|
||||
const bytes = Buffer.alloc(16);
|
||||
bytes[0] = (now / 2 ** 40) & 0xff;
|
||||
bytes[1] = (now / 2 ** 32) & 0xff;
|
||||
bytes[2] = (now / 2 ** 24) & 0xff;
|
||||
bytes[3] = (now / 2 ** 16) & 0xff;
|
||||
bytes[4] = (now / 2 ** 8) & 0xff;
|
||||
bytes[5] = now & 0xff;
|
||||
|
||||
// Bytes 6-7: version (0111) + 12 bits random
|
||||
bytes[6] = 0x70 | (random[0]! & 0x0f);
|
||||
bytes[7] = random[1]!;
|
||||
|
||||
// Bytes 8-15: variant (10) + 62 bits random
|
||||
bytes[8] = 0x80 | (random[2]! & 0x3f);
|
||||
bytes[9] = random[3]!;
|
||||
bytes[10] = random[4]!;
|
||||
bytes[11] = random[5]!;
|
||||
bytes[12] = random[6]!;
|
||||
bytes[13] = random[7]!;
|
||||
bytes[14] = random[8]!;
|
||||
bytes[15] = random[9]!;
|
||||
|
||||
const hex = bytes.toString('hex');
|
||||
return (
|
||||
hex.slice(0, 8) + '-' +
|
||||
hex.slice(8, 12) + '-' +
|
||||
hex.slice(12, 16) + '-' +
|
||||
hex.slice(16, 20) + '-' +
|
||||
hex.slice(20, 32)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random nonce: 16 random bytes, base64url encoded.
|
||||
*/
|
||||
export function generateNonce(): string {
|
||||
const bytes = crypto.randomBytes(16);
|
||||
return base64urlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a Buffer or Uint8Array to base64url (no padding, RFC 4648 section 5).
|
||||
*/
|
||||
export function base64urlEncode(data: Buffer | Uint8Array): string {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64url string (no padding) to a Buffer.
|
||||
*/
|
||||
export function base64urlDecode(input: string): Buffer {
|
||||
return Buffer.from(input, 'base64url');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
.eggs/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
*.whl
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,307 @@
|
||||
# Elydora Python SDK
|
||||
|
||||
Official Python SDK for the [Elydora](https://elydora.com) tamper-evident audit platform. Build cryptographically verifiable audit trails for AI agent operations.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install elydora
|
||||
```
|
||||
|
||||
Requires Python 3.9+.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from elydora import ElydoraClient
|
||||
|
||||
# Authenticate
|
||||
auth = ElydoraClient.login("https://api.elydora.com", "user@example.com", "password")
|
||||
|
||||
# Create client
|
||||
client = ElydoraClient(
|
||||
org_id=auth["user"]["org_id"],
|
||||
agent_id="my-agent-id",
|
||||
private_key="<base64url-encoded-ed25519-seed>",
|
||||
base_url="https://api.elydora.com",
|
||||
token=auth["token"],
|
||||
)
|
||||
|
||||
# Create and submit an operation
|
||||
eor = client.create_operation(
|
||||
operation_type="data.access",
|
||||
subject={"user_id": "u-123", "resource": "patient-record"},
|
||||
action={"type": "read", "scope": "full"},
|
||||
payload={"record_id": "rec-456"},
|
||||
)
|
||||
response = client.submit_operation(eor)
|
||||
print("Receipt:", response["receipt"]["receipt_id"])
|
||||
```
|
||||
|
||||
## Async Support
|
||||
|
||||
```python
|
||||
from elydora import AsyncElydoraClient
|
||||
|
||||
async def main():
|
||||
client = AsyncElydoraClient(
|
||||
org_id="org-123",
|
||||
agent_id="agent-456",
|
||||
private_key="<base64url-encoded-ed25519-seed>",
|
||||
token="<jwt-token>",
|
||||
)
|
||||
|
||||
eor = client.create_operation(
|
||||
operation_type="inference",
|
||||
subject={"model": "gpt-4"},
|
||||
action={"type": "completion"},
|
||||
)
|
||||
response = await client.submit_operation(eor)
|
||||
await client.close()
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
The SDK includes a CLI for installing audit hooks into AI coding agents.
|
||||
|
||||
```bash
|
||||
elydora install \
|
||||
--agent claudecode \
|
||||
--org_id org-123 \
|
||||
--agent_id agent-456 \
|
||||
--private_key <key> \
|
||||
--kid agent-456-key-v1
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `elydora install` | Install Elydora audit hook for a coding agent |
|
||||
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
|
||||
| `elydora status` | Show installation status for all agents |
|
||||
| `elydora agents` | List supported coding agents |
|
||||
|
||||
### Supported Agents
|
||||
|
||||
| Agent | Key |
|
||||
|-------|-----|
|
||||
| Claude Code | `claudecode` |
|
||||
| Copilot CLI | `copilot` |
|
||||
| Cursor | `cursor` |
|
||||
| Gemini CLI | `gemini` |
|
||||
| Kiro CLI | `kirocli` |
|
||||
| Kiro IDE | `kiroide` |
|
||||
| Letta Code | `letta` |
|
||||
| OpenCode | `opencode` |
|
||||
|
||||
## API Reference
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
client = ElydoraClient(
|
||||
org_id="org-123", # Organization ID
|
||||
agent_id="agent-456", # Agent ID
|
||||
private_key="<seed>", # Base64url-encoded Ed25519 seed
|
||||
base_url="https://...", # API base URL (default: https://api.elydora.com)
|
||||
ttl_ms=30000, # Operation TTL in ms (default: 30000)
|
||||
max_retries=3, # Max retries on transient failures (default: 3)
|
||||
token="<jwt>", # Optional JWT bearer token
|
||||
)
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```python
|
||||
# Register a new user and organization
|
||||
reg = ElydoraClient.register(base_url, email, password, display_name=None, org_name=None)
|
||||
|
||||
# Login and receive a JWT
|
||||
auth = ElydoraClient.login(base_url, email, password)
|
||||
|
||||
# Get current authenticated user profile
|
||||
me = client.get_me()
|
||||
|
||||
# Issue a new API token (with optional TTL in seconds)
|
||||
token_resp = client.issue_token(ttl_seconds=3600)
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
```python
|
||||
# Create a signed EOR locally (no network call)
|
||||
eor = client.create_operation(
|
||||
operation_type="inference",
|
||||
subject={"model": "gpt-4"},
|
||||
action={"type": "completion"},
|
||||
payload={"prompt": "Hello"},
|
||||
)
|
||||
|
||||
# Submit to API
|
||||
response = client.submit_operation(eor)
|
||||
|
||||
# Retrieve an operation
|
||||
op = client.get_operation(operation_id)
|
||||
|
||||
# Verify integrity
|
||||
result = client.verify_operation(operation_id)
|
||||
```
|
||||
|
||||
### Agent Management
|
||||
|
||||
```python
|
||||
# Register a new agent
|
||||
agent = client.register_agent({
|
||||
"agent_id": "my-agent",
|
||||
"display_name": "My Agent",
|
||||
"responsible_entity": "team@example.com",
|
||||
"keys": [{"kid": "key-v1", "public_key": "<base64url>", "algorithm": "ed25519"}],
|
||||
})
|
||||
|
||||
# Get agent details
|
||||
details = client.get_agent(agent_id)
|
||||
|
||||
# Freeze an agent
|
||||
client.freeze_agent(agent_id, reason="security review")
|
||||
|
||||
# Revoke a key
|
||||
client.revoke_key(agent_id, kid, reason="key rotation")
|
||||
|
||||
# List all agents in the organization
|
||||
agents_resp = client.list_agents()
|
||||
|
||||
# Unfreeze a previously frozen agent
|
||||
client.unfreeze_agent(agent_id, reason="review complete")
|
||||
|
||||
# Delete an agent permanently
|
||||
deleted_resp = client.delete_agent(agent_id)
|
||||
```
|
||||
|
||||
### Audit
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
results = client.query_audit(
|
||||
agent_id="agent-123",
|
||||
operation_type="inference",
|
||||
start_time=int(time.time() * 1000) - 86400000,
|
||||
end_time=int(time.time() * 1000),
|
||||
limit=50,
|
||||
)
|
||||
```
|
||||
|
||||
### Epochs
|
||||
|
||||
```python
|
||||
epochs = client.list_epochs()
|
||||
epoch = client.get_epoch(epoch_id)
|
||||
```
|
||||
|
||||
### Exports
|
||||
|
||||
```python
|
||||
export = client.create_export(
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
format="json",
|
||||
)
|
||||
|
||||
exports = client.list_exports()
|
||||
detail = client.get_export(export_id)
|
||||
|
||||
# Download export file data
|
||||
data = client.download_export(export_id)
|
||||
```
|
||||
|
||||
### JWKS
|
||||
|
||||
```python
|
||||
jwks = client.get_jwks()
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```python
|
||||
# Check API health (no authentication required)
|
||||
health = client.health()
|
||||
# health["status"], health["version"], health["protocol_version"], health["timestamp"]
|
||||
```
|
||||
|
||||
### Crypto Functions
|
||||
|
||||
The SDK exports low-level cryptographic primitives for advanced use:
|
||||
|
||||
```python
|
||||
from elydora import (
|
||||
jcs_canonicalize, # RFC 8785 JSON Canonicalization
|
||||
sha256_base64url, # SHA-256 hash as base64url
|
||||
compute_chain_hash, # Chain hash computation
|
||||
compute_payload_hash, # Payload hash (SHA-256 of JCS-canonicalized payload)
|
||||
sign_ed25519, # Ed25519 signing
|
||||
sign_eor, # Sign an EOR dict
|
||||
get_public_key_base64url, # Derive public key from private seed
|
||||
)
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```python
|
||||
from elydora import (
|
||||
base64url_encode, # Encode bytes to base64url (no padding)
|
||||
base64url_decode, # Decode base64url string to bytes
|
||||
generate_nonce, # Generate 16-byte random nonce (base64url)
|
||||
generate_uuidv7, # Generate UUIDv7 (time-ordered, RFC 9562)
|
||||
)
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
|
||||
All types are `TypedDict` classes for structural typing:
|
||||
|
||||
```python
|
||||
from elydora import (
|
||||
# Protocol types
|
||||
EOR, # Elydora Operation Record
|
||||
EAR, # Elydora Acknowledgment Receipt
|
||||
|
||||
# Entity types
|
||||
Agent, AgentKey, Operation, Receipt, Epoch, Export, Organization, User,
|
||||
|
||||
# API response types
|
||||
RegisterAgentResponse, GetAgentResponse, ListAgentsResponse,
|
||||
DeleteAgentResponse, SubmitOperationResponse, GetOperationResponse,
|
||||
VerifyOperationResponse, AuditQueryResponse, GetEpochResponse,
|
||||
ListEpochsResponse, CreateExportResponse, GetExportResponse,
|
||||
ListExportsResponse, JWKSResponse, AuthRegisterResponse,
|
||||
AuthLoginResponse, GetMeResponse, IssueTokenResponse, HealthResponse,
|
||||
|
||||
# Request types
|
||||
RegisterAgentRequest,
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from elydora import ElydoraError
|
||||
|
||||
try:
|
||||
client.submit_operation(eor)
|
||||
except ElydoraError as e:
|
||||
print(e.code) # e.g. "INVALID_SIGNATURE"
|
||||
print(e.message) # Human-readable message
|
||||
print(e.status_code) # HTTP status code
|
||||
print(e.request_id) # Request ID for support
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [requests](https://pypi.org/project/requests/) - Sync HTTP client
|
||||
- [aiohttp](https://pypi.org/project/aiohttp/) - Async HTTP client
|
||||
- [cryptography](https://pypi.org/project/cryptography/) - Ed25519 signing
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Elydora Python SDK — tamper-evident audit trail for AI agents."""
|
||||
|
||||
from .client import ElydoraClient
|
||||
from .async_client import AsyncElydoraClient
|
||||
from .errors import ElydoraError
|
||||
from .crypto import (
|
||||
compute_chain_hash,
|
||||
compute_payload_hash,
|
||||
jcs_canonicalize,
|
||||
sha256_base64url,
|
||||
sign_ed25519,
|
||||
sign_eor,
|
||||
get_public_key_base64url,
|
||||
)
|
||||
from .utils import base64url_encode, base64url_decode, generate_nonce, generate_uuidv7
|
||||
from .types import (
|
||||
EOR,
|
||||
EAR,
|
||||
Agent,
|
||||
AgentKey,
|
||||
Operation,
|
||||
Receipt,
|
||||
Epoch,
|
||||
Export,
|
||||
Organization,
|
||||
User,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
DeleteAgentResponse,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryResponse,
|
||||
GetEpochResponse,
|
||||
ListEpochsResponse,
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
ListExportsResponse,
|
||||
JWKSResponse,
|
||||
AuthRegisterResponse,
|
||||
AuthLoginResponse,
|
||||
GetMeResponse,
|
||||
IssueTokenResponse,
|
||||
HealthResponse,
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
"ElydoraClient",
|
||||
"AsyncElydoraClient",
|
||||
"ElydoraError",
|
||||
"compute_chain_hash",
|
||||
"compute_payload_hash",
|
||||
"jcs_canonicalize",
|
||||
"sha256_base64url",
|
||||
"sign_ed25519",
|
||||
"sign_eor",
|
||||
"get_public_key_base64url",
|
||||
"base64url_encode",
|
||||
"base64url_decode",
|
||||
"generate_nonce",
|
||||
"generate_uuidv7",
|
||||
"EOR",
|
||||
"EAR",
|
||||
"Agent",
|
||||
"AgentKey",
|
||||
"Operation",
|
||||
"Receipt",
|
||||
"Epoch",
|
||||
"Export",
|
||||
"Organization",
|
||||
"User",
|
||||
"RegisterAgentRequest",
|
||||
"RegisterAgentResponse",
|
||||
"GetAgentResponse",
|
||||
"ListAgentsResponse",
|
||||
"DeleteAgentResponse",
|
||||
"SubmitOperationResponse",
|
||||
"GetOperationResponse",
|
||||
"VerifyOperationResponse",
|
||||
"AuditQueryResponse",
|
||||
"GetEpochResponse",
|
||||
"ListEpochsResponse",
|
||||
"CreateExportResponse",
|
||||
"GetExportResponse",
|
||||
"ListExportsResponse",
|
||||
"JWKSResponse",
|
||||
"AuthRegisterResponse",
|
||||
"AuthLoginResponse",
|
||||
"GetMeResponse",
|
||||
"IssueTokenResponse",
|
||||
"HealthResponse",
|
||||
]
|
||||
@@ -0,0 +1,413 @@
|
||||
"""Asynchronous Elydora client using aiohttp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
AuthRegisterResponse,
|
||||
CreateExportResponse,
|
||||
DeleteAgentResponse,
|
||||
EOR,
|
||||
GetAgentResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
GetMeResponse,
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueTokenResponse,
|
||||
JWKSResponse,
|
||||
ListAgentsResponse,
|
||||
ListEpochsResponse,
|
||||
ListExportsResponse,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
SubmitOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
)
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
class AsyncElydoraClient:
|
||||
"""Asynchronous client for the Elydora API.
|
||||
|
||||
Args:
|
||||
org_id: Organization identifier.
|
||||
agent_id: Agent identifier.
|
||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||
base_url: API base URL.
|
||||
ttl_ms: Time-to-live for operations in milliseconds.
|
||||
max_retries: Maximum number of retries on transient failures.
|
||||
token: Optional JWT bearer token for authenticated endpoints.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
org_id: str,
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
*,
|
||||
base_url: str = "https://api.elydora.com",
|
||||
ttl_ms: int = 30000,
|
||||
max_retries: int = 3,
|
||||
token: Optional[str] = None,
|
||||
) -> None:
|
||||
self.org_id = org_id
|
||||
self.agent_id = agent_id
|
||||
self.private_key = private_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.ttl_ms = ttl_ms
|
||||
self.max_retries = max_retries
|
||||
self.token = token
|
||||
|
||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||
self._kid = ""
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
def set_kid(self, kid: str) -> None:
|
||||
"""Set the key ID used for signing operations."""
|
||||
self._kid = kid
|
||||
|
||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||
"""Set the previous chain hash for the next operation."""
|
||||
self._prev_chain_hash = prev_chain_hash
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Internal HTTP helpers
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Any = None,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
hdrs = headers or self._headers()
|
||||
session = await self._get_session()
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
async with session.request(
|
||||
method, url, json=json_body, params=params, headers=hdrs, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
return await self._handle_response(resp)
|
||||
except ElydoraError:
|
||||
raise
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as exc:
|
||||
last_exc = exc
|
||||
if attempt < self.max_retries - 1:
|
||||
await asyncio.sleep(min(2 ** attempt, 8))
|
||||
continue
|
||||
raise
|
||||
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
@staticmethod
|
||||
async def _handle_response(resp: aiohttp.ClientResponse) -> Any:
|
||||
if resp.status >= 400:
|
||||
try:
|
||||
body = await resp.json()
|
||||
except Exception:
|
||||
text = await resp.text()
|
||||
raise ElydoraError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=text or "Unknown error",
|
||||
status_code=resp.status,
|
||||
)
|
||||
err = body.get("error", {})
|
||||
raise ElydoraError(
|
||||
code=err.get("code", "INTERNAL_ERROR"),
|
||||
message=err.get("message", ""),
|
||||
request_id=err.get("request_id", ""),
|
||||
details=err.get("details"),
|
||||
status_code=resp.status,
|
||||
)
|
||||
if resp.status == 204:
|
||||
return None
|
||||
return await resp.json()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Auth (static methods)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
async def register(
|
||||
base_url: str,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: Optional[str] = None,
|
||||
org_name: Optional[str] = None,
|
||||
) -> AuthRegisterResponse:
|
||||
"""Register a new user and organization."""
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||
body: Dict[str, Any] = {"email": email, "password": password}
|
||||
if display_name is not None:
|
||||
body["display_name"] = display_name
|
||||
if org_name is not None:
|
||||
body["org_name"] = org_name
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
return await AsyncElydoraClient._handle_response(resp)
|
||||
|
||||
@staticmethod
|
||||
async def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||
"""Authenticate and receive a JWT."""
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||
body = {"email": email, "password": password}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
return await AsyncElydoraClient._handle_response(resp)
|
||||
|
||||
async def get_me(self) -> GetMeResponse:
|
||||
"""Get the current user's profile."""
|
||||
return await self._request("GET", "/v1/auth/me")
|
||||
|
||||
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse:
|
||||
"""Issue an API token with an optional TTL."""
|
||||
body: Dict[str, Any] = {}
|
||||
if ttl_seconds is not None:
|
||||
body["ttl_seconds"] = ttl_seconds
|
||||
return await self._request("POST", "/v1/auth/token", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Agent management
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
return await self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
async def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
"""Retrieve agent details and keys."""
|
||||
return await self._request("GET", f"/v1/agents/{agent_id}")
|
||||
|
||||
async def freeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Freeze an agent."""
|
||||
await self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||
|
||||
async def list_agents(self) -> ListAgentsResponse:
|
||||
"""List all agents for the organization."""
|
||||
return await self._request("GET", "/v1/agents")
|
||||
|
||||
async def unfreeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Unfreeze an agent."""
|
||||
await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
|
||||
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
"""Delete an agent."""
|
||||
return await self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||
|
||||
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
"""Revoke an agent's key."""
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/v1/agents/{agent_id}/revoke",
|
||||
json_body={"kid": kid, "reason": reason},
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Operations (CORE)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def create_operation(
|
||||
self,
|
||||
operation_type: str,
|
||||
subject: Dict[str, Any],
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None] = None,
|
||||
) -> EOR:
|
||||
"""Build and sign an Elydora Operation Record (EOR).
|
||||
|
||||
This is synchronous because it only performs local crypto operations.
|
||||
Call submit_operation() with the returned EOR to submit it.
|
||||
"""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
nonce = generate_nonce()
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(
|
||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
||||
)
|
||||
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": self.org_id,
|
||||
"agent_id": self.agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": self.ttl_ms,
|
||||
"nonce": nonce,
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": self._prev_chain_hash,
|
||||
"agent_pubkey_kid": self._kid,
|
||||
"signature": "",
|
||||
}
|
||||
|
||||
eor["signature"] = sign_eor(eor, self.private_key)
|
||||
|
||||
self._prev_chain_hash = chain_hash
|
||||
|
||||
return eor # type: ignore[return-value]
|
||||
|
||||
async def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||
"""Submit a signed EOR to the server."""
|
||||
return await self._request("POST", "/v1/operations", json_body=eor)
|
||||
|
||||
async def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||
"""Retrieve an operation by ID."""
|
||||
return await self._request("GET", f"/v1/operations/{operation_id}")
|
||||
|
||||
async def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||
"""Verify an operation's integrity."""
|
||||
return await self._request("POST", f"/v1/operations/{operation_id}/verify")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Audit
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def query_audit(
|
||||
self,
|
||||
*,
|
||||
org_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None,
|
||||
cursor: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AuditQueryResponse:
|
||||
"""Query the tamper-evident audit log."""
|
||||
body: Dict[str, Any] = {}
|
||||
if org_id is not None:
|
||||
body["org_id"] = org_id
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
if start_time is not None:
|
||||
body["start_time"] = start_time
|
||||
if end_time is not None:
|
||||
body["end_time"] = end_time
|
||||
if cursor is not None:
|
||||
body["cursor"] = cursor
|
||||
if limit is not None:
|
||||
body["limit"] = limit
|
||||
return await self._request("POST", "/v1/audit/query", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Epochs
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def list_epochs(self) -> ListEpochsResponse:
|
||||
"""List all epochs for the organization."""
|
||||
return await self._request("GET", "/v1/epochs")
|
||||
|
||||
async def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||
"""Retrieve an epoch root record."""
|
||||
return await self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Exports
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def create_export(
|
||||
self,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
format: str = "json",
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
) -> CreateExportResponse:
|
||||
"""Create a compliance export job."""
|
||||
body: Dict[str, Any] = {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
}
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
return await self._request("POST", "/v1/exports", json_body=body)
|
||||
|
||||
async def list_exports(self) -> ListExportsResponse:
|
||||
"""List all exports for the organization."""
|
||||
return await self._request("GET", "/v1/exports")
|
||||
|
||||
async def get_export(self, export_id: str) -> GetExportResponse:
|
||||
"""Retrieve export status and download URL."""
|
||||
return await self._request("GET", f"/v1/exports/{export_id}")
|
||||
|
||||
async def download_export(self, export_id: str) -> bytes:
|
||||
"""Download an export file as raw bytes."""
|
||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
await self._handle_response(resp)
|
||||
return await resp.read()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JWKS
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def get_jwks(self) -> JWKSResponse:
|
||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
return await self._handle_response(resp)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Health
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def health(self) -> HealthResponse:
|
||||
"""Check API health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
return await self._handle_response(resp)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Elydora CLI — install, uninstall, and manage agent audit hooks.
|
||||
|
||||
Entry point: ``elydora`` console script (see pyproject.toml).
|
||||
Uses only stdlib argparse — zero external dependencies for the CLI itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Dict, Type
|
||||
|
||||
from .crypto import get_public_key_base64url
|
||||
from .plugins.base import AgentPlugin, InstallConfig
|
||||
from .plugins.registry import SUPPORTED_AGENTS, get_agent_names
|
||||
from .plugins.hook_template import generate_guard_script
|
||||
from .plugins.claudecode import ClaudeCodePlugin
|
||||
from .plugins.copilot import CopilotPlugin
|
||||
from .plugins.cursor import CursorPlugin
|
||||
from .plugins.gemini import GeminiPlugin
|
||||
from .plugins.kirocli import KiroCliPlugin
|
||||
from .plugins.kiroide import KiroIdePlugin
|
||||
from .plugins.letta import LettaPlugin
|
||||
from .plugins.opencode import OpenCodePlugin
|
||||
|
||||
|
||||
PLUGIN_MAP: Dict[str, Type[AgentPlugin]] = {
|
||||
"claudecode": ClaudeCodePlugin,
|
||||
"copilot": CopilotPlugin,
|
||||
"cursor": CursorPlugin,
|
||||
"gemini": GeminiPlugin,
|
||||
"kirocli": KiroCliPlugin,
|
||||
"kiroide": KiroIdePlugin,
|
||||
"letta": LettaPlugin,
|
||||
"opencode": OpenCodePlugin,
|
||||
}
|
||||
|
||||
|
||||
def _get_plugin(agent_name: str) -> AgentPlugin:
|
||||
"""Instantiate the plugin for the given agent name."""
|
||||
cls = PLUGIN_MAP.get(agent_name)
|
||||
if cls is None:
|
||||
print(f"Error: Unknown agent '{agent_name}'.", file=sys.stderr)
|
||||
print(f"Supported agents: {', '.join(get_agent_names())}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return cls()
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'install' subcommand."""
|
||||
agent_name: str = args.agent
|
||||
|
||||
# Derive public key to verify the private key is valid
|
||||
try:
|
||||
pub = get_public_key_base64url(args.private_key)
|
||||
except Exception as exc:
|
||||
print(f"Error: Invalid private key — {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Verified key pair (public key: {pub[:16]}...)")
|
||||
|
||||
# Create per-agent directory under ~/.elydora/{agent_id}/
|
||||
agent_dir = os.path.join(os.path.expanduser("~"), ".elydora", args.agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Generate and write the guard script
|
||||
guard_script_path = os.path.join(agent_dir, "guard.py")
|
||||
guard_script = generate_guard_script(agent_name, args.agent_id)
|
||||
with open(guard_script_path, "w", encoding="utf-8") as f:
|
||||
f.write(guard_script)
|
||||
try:
|
||||
import stat
|
||||
os.chmod(guard_script_path, os.stat(guard_script_path).st_mode | stat.S_IEXEC)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
print(f" Guard script: {guard_script_path}")
|
||||
|
||||
config: InstallConfig = {
|
||||
"org_id": args.org_id,
|
||||
"agent_id": args.agent_id,
|
||||
"agent_name": agent_name,
|
||||
"private_key": args.private_key,
|
||||
"kid": args.kid,
|
||||
"base_url": args.base_url,
|
||||
"guard_script_path": guard_script_path,
|
||||
}
|
||||
if args.token:
|
||||
config["token"] = args.token
|
||||
|
||||
plugin = _get_plugin(agent_name)
|
||||
plugin.install(config)
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'uninstall' subcommand."""
|
||||
plugin = _get_plugin(args.agent)
|
||||
agent_id = getattr(args, "agent_id", None) or ""
|
||||
plugin.uninstall(agent_id=agent_id)
|
||||
|
||||
# Determine which agent directory to remove
|
||||
elydora_dir = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
agent_id = getattr(args, "agent_id", None)
|
||||
|
||||
if not agent_id:
|
||||
# Scan ~/.elydora/*/config.json for matching agent_name
|
||||
pattern = os.path.join(elydora_dir, "*", "config.json")
|
||||
for config_path in glob.glob(pattern):
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
if cfg.get("agent_name") == args.agent:
|
||||
agent_id = cfg.get("agent_id")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if agent_id:
|
||||
agent_dir = os.path.join(elydora_dir, agent_id)
|
||||
if os.path.isdir(agent_dir):
|
||||
shutil.rmtree(agent_dir)
|
||||
print(f" Removed agent directory: {agent_dir}")
|
||||
else:
|
||||
# Fallback: try to remove legacy guard script location
|
||||
guard_script_path = os.path.join(
|
||||
elydora_dir, "hooks", f"{args.agent}-guard.py"
|
||||
)
|
||||
if os.path.exists(guard_script_path):
|
||||
os.remove(guard_script_path)
|
||||
print(f" Removed guard script: {guard_script_path}")
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'status' subcommand."""
|
||||
print("Elydora Agent Hook Status")
|
||||
print("=" * 40)
|
||||
for name in get_agent_names():
|
||||
plugin = PLUGIN_MAP[name]()
|
||||
st = plugin.status()
|
||||
marker = "[installed]" if st["installed"] else "[not installed]"
|
||||
display = SUPPORTED_AGENTS[name]["name"]
|
||||
print(f" {display:20s} {marker:16s} {st['details']}")
|
||||
|
||||
|
||||
def cmd_agents(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'agents' subcommand."""
|
||||
print("Supported agents:")
|
||||
for name in get_agent_names():
|
||||
info = SUPPORTED_AGENTS[name]
|
||||
print(f" {name:15s} {info['name']:20s} (hook: {info['hook_event']})")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argparse parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="elydora",
|
||||
description="Elydora — tamper-evident audit trail for AI agents",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version="%(prog)s 1.0.0"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# install
|
||||
install_parser = subparsers.add_parser("install", help="Install audit hook for an agent")
|
||||
install_parser.add_argument("--agent", required=True, help="Agent name (e.g. claudecode, cursor)")
|
||||
install_parser.add_argument("--org_id", required=True, help="Organization ID")
|
||||
install_parser.add_argument("--agent_id", required=True, help="Agent ID")
|
||||
install_parser.add_argument("--private_key", required=True, help="Base64url-encoded Ed25519 private key seed")
|
||||
install_parser.add_argument("--kid", required=True, help="Key ID")
|
||||
install_parser.add_argument("--token", default="", help="JWT bearer token (optional)")
|
||||
install_parser.add_argument("--base_url", default="https://api.elydora.com", help="API base URL")
|
||||
|
||||
# uninstall
|
||||
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall audit hook for an agent")
|
||||
uninstall_parser.add_argument("--agent", required=True, help="Agent name")
|
||||
uninstall_parser.add_argument("--agent_id", default=None, help="Agent ID (if omitted, scans config files for matching agent name)")
|
||||
|
||||
# status
|
||||
subparsers.add_parser("status", help="Show installation status of all agents")
|
||||
|
||||
# agents
|
||||
subparsers.add_parser("agents", help="List supported agents")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command is None:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
handlers = {
|
||||
"install": cmd_install,
|
||||
"uninstall": cmd_uninstall,
|
||||
"status": cmd_status,
|
||||
"agents": cmd_agents,
|
||||
}
|
||||
|
||||
handler = handlers.get(args.command)
|
||||
if handler is None:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Synchronous Elydora client using the requests library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
AuthRegisterResponse,
|
||||
CreateExportResponse,
|
||||
DeleteAgentResponse,
|
||||
EOR,
|
||||
GetAgentResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
GetMeResponse,
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueTokenResponse,
|
||||
JWKSResponse,
|
||||
ListAgentsResponse,
|
||||
ListEpochsResponse,
|
||||
ListExportsResponse,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
SubmitOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
)
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
class ElydoraClient:
|
||||
"""Synchronous client for the Elydora API.
|
||||
|
||||
Args:
|
||||
org_id: Organization identifier.
|
||||
agent_id: Agent identifier.
|
||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||
base_url: API base URL.
|
||||
ttl_ms: Time-to-live for operations in milliseconds.
|
||||
max_retries: Maximum number of retries on transient failures.
|
||||
token: Optional JWT bearer token for authenticated endpoints.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
org_id: str,
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
*,
|
||||
base_url: str = "https://api.elydora.com",
|
||||
ttl_ms: int = 30000,
|
||||
max_retries: int = 3,
|
||||
token: Optional[str] = None,
|
||||
) -> None:
|
||||
self.org_id = org_id
|
||||
self.agent_id = agent_id
|
||||
self.private_key = private_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.ttl_ms = ttl_ms
|
||||
self.max_retries = max_retries
|
||||
self.token = token
|
||||
|
||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||
self._kid = ""
|
||||
self._session = requests.Session()
|
||||
|
||||
def set_kid(self, kid: str) -> None:
|
||||
"""Set the key ID used for signing operations."""
|
||||
self._kid = kid
|
||||
|
||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||
"""Set the previous chain hash for the next operation."""
|
||||
self._prev_chain_hash = prev_chain_hash
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Internal HTTP helpers
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Any = None,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
hdrs = headers or self._headers()
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
resp = self._session.request(
|
||||
method, url, json=json_body, params=params, headers=hdrs, timeout=30
|
||||
)
|
||||
# Retry on 429 or 5xx
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
if attempt < self.max_retries - 1:
|
||||
retry_after = resp.headers.get("Retry-After")
|
||||
if retry_after and retry_after.isdigit():
|
||||
delay = int(retry_after)
|
||||
else:
|
||||
delay = min(2 ** attempt, 8)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
return self._handle_response(resp)
|
||||
except ElydoraError:
|
||||
raise
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
|
||||
last_exc = exc
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(min(2 ** attempt, 8))
|
||||
continue
|
||||
raise
|
||||
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
@staticmethod
|
||||
def _handle_response(resp: requests.Response) -> Any:
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
raise ElydoraError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=resp.text or "Unknown error",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
err = body.get("error", {})
|
||||
raise ElydoraError(
|
||||
code=err.get("code", "INTERNAL_ERROR"),
|
||||
message=err.get("message", resp.text),
|
||||
request_id=err.get("request_id", ""),
|
||||
details=err.get("details"),
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
if resp.status_code == 204:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Auth (static methods)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def register(
|
||||
base_url: str,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: Optional[str] = None,
|
||||
org_name: Optional[str] = None,
|
||||
) -> AuthRegisterResponse:
|
||||
"""Register a new user and organization."""
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||
body: Dict[str, Any] = {"email": email, "password": password}
|
||||
if display_name is not None:
|
||||
body["display_name"] = display_name
|
||||
if org_name is not None:
|
||||
body["org_name"] = org_name
|
||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
||||
return ElydoraClient._handle_response(resp)
|
||||
|
||||
@staticmethod
|
||||
def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||
"""Authenticate and receive a JWT."""
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||
body = {"email": email, "password": password}
|
||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
||||
return ElydoraClient._handle_response(resp)
|
||||
|
||||
def get_me(self) -> GetMeResponse:
|
||||
"""Get the current user's profile."""
|
||||
return self._request("GET", "/v1/auth/me")
|
||||
|
||||
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse:
|
||||
"""Issue an API token with an optional TTL."""
|
||||
body: Dict[str, Any] = {}
|
||||
if ttl_seconds is not None:
|
||||
body["ttl_seconds"] = ttl_seconds
|
||||
return self._request("POST", "/v1/auth/token", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Agent management
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
return self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
"""Retrieve agent details and keys."""
|
||||
return self._request("GET", f"/v1/agents/{agent_id}")
|
||||
|
||||
def freeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Freeze an agent."""
|
||||
self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||
|
||||
def list_agents(self) -> ListAgentsResponse:
|
||||
"""List all agents for the organization."""
|
||||
return self._request("GET", "/v1/agents")
|
||||
|
||||
def unfreeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Unfreeze an agent."""
|
||||
self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
|
||||
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
"""Delete an agent."""
|
||||
return self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||
|
||||
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
"""Revoke an agent's key."""
|
||||
self._request(
|
||||
"POST",
|
||||
f"/v1/agents/{agent_id}/revoke",
|
||||
json_body={"kid": kid, "reason": reason},
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Operations (CORE)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def create_operation(
|
||||
self,
|
||||
operation_type: str,
|
||||
subject: Dict[str, Any],
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None] = None,
|
||||
) -> EOR:
|
||||
"""Build and sign an Elydora Operation Record (EOR).
|
||||
|
||||
This does NOT submit the operation to the server. Call submit_operation()
|
||||
with the returned EOR to submit it.
|
||||
"""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
nonce = generate_nonce()
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(
|
||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
||||
)
|
||||
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": self.org_id,
|
||||
"agent_id": self.agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": self.ttl_ms,
|
||||
"nonce": nonce,
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": self._prev_chain_hash,
|
||||
"agent_pubkey_kid": self._kid,
|
||||
"signature": "",
|
||||
}
|
||||
|
||||
# Sign the EOR (signature field excluded from canonical form)
|
||||
eor["signature"] = sign_eor(eor, self.private_key)
|
||||
|
||||
# Update chain state
|
||||
self._prev_chain_hash = chain_hash
|
||||
|
||||
return eor # type: ignore[return-value]
|
||||
|
||||
def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||
"""Submit a signed EOR to the server."""
|
||||
return self._request("POST", "/v1/operations", json_body=eor)
|
||||
|
||||
def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||
"""Retrieve an operation by ID."""
|
||||
return self._request("GET", f"/v1/operations/{operation_id}")
|
||||
|
||||
def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||
"""Verify an operation's integrity."""
|
||||
return self._request("POST", f"/v1/operations/{operation_id}/verify")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Audit
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def query_audit(
|
||||
self,
|
||||
*,
|
||||
org_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None,
|
||||
cursor: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AuditQueryResponse:
|
||||
"""Query the tamper-evident audit log."""
|
||||
body: Dict[str, Any] = {}
|
||||
if org_id is not None:
|
||||
body["org_id"] = org_id
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
if start_time is not None:
|
||||
body["start_time"] = start_time
|
||||
if end_time is not None:
|
||||
body["end_time"] = end_time
|
||||
if cursor is not None:
|
||||
body["cursor"] = cursor
|
||||
if limit is not None:
|
||||
body["limit"] = limit
|
||||
return self._request("POST", "/v1/audit/query", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Epochs
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def list_epochs(self) -> ListEpochsResponse:
|
||||
"""List all epochs for the organization."""
|
||||
return self._request("GET", "/v1/epochs")
|
||||
|
||||
def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||
"""Retrieve an epoch root record."""
|
||||
return self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Exports
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def create_export(
|
||||
self,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
format: str = "json",
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
) -> CreateExportResponse:
|
||||
"""Create a compliance export job."""
|
||||
body: Dict[str, Any] = {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
}
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
return self._request("POST", "/v1/exports", json_body=body)
|
||||
|
||||
def list_exports(self) -> ListExportsResponse:
|
||||
"""List all exports for the organization."""
|
||||
return self._request("GET", "/v1/exports")
|
||||
|
||||
def get_export(self, export_id: str) -> GetExportResponse:
|
||||
"""Retrieve export status and download URL."""
|
||||
return self._request("GET", f"/v1/exports/{export_id}")
|
||||
|
||||
def download_export(self, export_id: str) -> bytes:
|
||||
"""Download an export file as raw bytes."""
|
||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
||||
resp = self._session.get(url, headers=self._headers(), timeout=30)
|
||||
if resp.status_code >= 400:
|
||||
self._handle_response(resp)
|
||||
return resp.content
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JWKS
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def get_jwks(self) -> JWKSResponse:
|
||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
||||
resp = self._session.get(url, timeout=30)
|
||||
return self._handle_response(resp)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Health
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def health(self) -> HealthResponse:
|
||||
"""Check API health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health"
|
||||
resp = self._session.get(url, timeout=30)
|
||||
return self._handle_response(resp)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Cryptographic utilities: Ed25519 signing, SHA-256, chain hash, JCS canonicalization.
|
||||
|
||||
Mirrors the backend implementation in ElydoraBackend/src/utils/crypto.ts exactly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from .utils import base64url_decode, base64url_encode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SHA-256
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sha256_base64url(data: Union[str, bytes]) -> str:
|
||||
"""Compute SHA-256 of data and return base64url-encoded hash."""
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
digest = hashlib.sha256(data).digest()
|
||||
return base64url_encode(digest)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JCS Canonicalization (RFC 8785)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _jcs_serialize_number(value: Union[int, float]) -> str:
|
||||
"""Serialize a number per JCS / ES2015 Number serialization rules."""
|
||||
if isinstance(value, bool):
|
||||
# bool is a subclass of int in Python; handle before int check
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
# float
|
||||
if math.isnan(value) or math.isinf(value):
|
||||
return "null"
|
||||
if value == 0.0:
|
||||
# Handle -0.0 -> "0"
|
||||
return "0"
|
||||
# Use Python's repr which matches ES2015 for normal floats,
|
||||
# but we need to ensure no trailing zeros beyond what's needed.
|
||||
# json.dumps handles this correctly per the JSON spec.
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def jcs_canonicalize(value: Any) -> str:
|
||||
"""Canonicalize a value according to JCS (RFC 8785).
|
||||
|
||||
- Object keys sorted lexicographically
|
||||
- No whitespace
|
||||
- Numbers serialized using ES2015 rules
|
||||
- Strings serialized with minimal JSON escaping
|
||||
- undefined values in objects are omitted
|
||||
"""
|
||||
if value is None:
|
||||
return "null"
|
||||
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return _jcs_serialize_number(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
if isinstance(value, list):
|
||||
elements = [jcs_canonicalize(v) for v in value]
|
||||
return "[" + ",".join(elements) + "]"
|
||||
|
||||
if isinstance(value, dict):
|
||||
keys = sorted(value.keys())
|
||||
pairs = []
|
||||
for key in keys:
|
||||
v = value[key]
|
||||
if v is not None or key in value:
|
||||
# Include keys with None values (maps to JSON null),
|
||||
# but skip keys that don't exist (which won't happen with dict iteration)
|
||||
pairs.append(json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(v))
|
||||
return "{" + ",".join(pairs) + "}"
|
||||
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_payload_hash(payload: Any) -> str:
|
||||
"""Compute SHA-256 hash of JCS-canonicalized payload, base64url encoded."""
|
||||
canonical = jcs_canonicalize(payload)
|
||||
return sha256_base64url(canonical)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chain hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_chain_hash(
|
||||
prev_chain_hash: str,
|
||||
payload_hash: str,
|
||||
operation_id: str,
|
||||
issued_at: int,
|
||||
) -> str:
|
||||
"""Compute chain hash: SHA-256(prev|payload_hash|op_id|issued_at) as base64url.
|
||||
|
||||
Matches the backend formula exactly:
|
||||
chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + str(issued_at))
|
||||
"""
|
||||
input_str = f"{prev_chain_hash}|{payload_hash}|{operation_id}|{issued_at}"
|
||||
return sha256_base64url(input_str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ed25519 signing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
||||
"""Sign data with Ed25519 private key (32-byte seed, base64url encoded).
|
||||
|
||||
Returns a base64url-encoded 64-byte signature.
|
||||
"""
|
||||
seed = base64url_decode(private_key_base64url)
|
||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
signature = key.sign(data)
|
||||
return base64url_encode(signature)
|
||||
|
||||
|
||||
def get_public_key_base64url(private_key_base64url: str) -> str:
|
||||
"""Derive the Ed25519 public key from the private key seed.
|
||||
|
||||
Returns base64url-encoded 32-byte public key.
|
||||
"""
|
||||
seed = base64url_decode(private_key_base64url)
|
||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
pub = key.public_key()
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
raw_bytes = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
return base64url_encode(raw_bytes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EOR signing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sign_eor(eor_dict: Dict[str, Any], private_key_base64url: str) -> str:
|
||||
"""Sign an EOR by canonicalizing all fields except 'signature', then signing.
|
||||
|
||||
Returns the base64url-encoded Ed25519 signature.
|
||||
"""
|
||||
# Build the signable object: all EOR fields except 'signature'
|
||||
signable = {k: v for k, v in eor_dict.items() if k != "signature"}
|
||||
canonical = jcs_canonicalize(signable)
|
||||
return sign_ed25519(private_key_base64url, canonical.encode("utf-8"))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Elydora SDK error types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class ElydoraError(Exception):
|
||||
"""Error returned by the Elydora API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
request_id: str = "",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
status_code: int = 0,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.request_id = request_id
|
||||
self.details = details or {}
|
||||
self.status_code = status_code
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"ElydoraError(code={self.code!r}, message={self.message!r}, "
|
||||
f"request_id={self.request_id!r})"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Elydora agent plugins for AI coding tools."""
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Abstract base class for agent plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict
|
||||
else:
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class InstallConfig(TypedDict, total=False):
|
||||
org_id: str
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
private_key: str
|
||||
kid: str
|
||||
token: str
|
||||
base_url: str
|
||||
guard_script_path: str
|
||||
|
||||
|
||||
class PluginStatus(TypedDict):
|
||||
installed: bool
|
||||
agent: str
|
||||
details: str
|
||||
|
||||
|
||||
class AgentPlugin(ABC):
|
||||
"""Base class for all agent plugins."""
|
||||
|
||||
@abstractmethod
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
"""Install the Elydora hook for this agent."""
|
||||
|
||||
@abstractmethod
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
"""Remove the Elydora hook for this agent."""
|
||||
|
||||
@abstractmethod
|
||||
def status(self) -> PluginStatus:
|
||||
"""Check whether the Elydora hook is installed for this agent."""
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Claude Code plugin — merges PostToolUse hook into ~/.claude/settings.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
SETTINGS_PATH = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
class ClaudeCodePlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Claude Code."""
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(config.get("private_key", ""))
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
# Write the hook script
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
private_key=config.get("private_key", ""),
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
hook_path = self._hook_path_for(agent_id)
|
||||
with open(hook_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
try:
|
||||
os.chmod(hook_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
# Merge into Claude Code settings
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
|
||||
# --- PreToolUse (guard — freeze enforcement) ---
|
||||
pre_tool_use = hooks.setdefault("PreToolUse", [])
|
||||
pre_tool_use[:] = [h for h in pre_tool_use if not _is_elydora_hook(h)]
|
||||
if guard_script_path:
|
||||
pre_tool_use.append({
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": f'"{python_exe}" {guard_script_path}',
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
# --- PostToolUse (audit logging) ---
|
||||
post_tool_use = hooks.setdefault("PostToolUse", [])
|
||||
|
||||
# Remove any existing Elydora hook entry
|
||||
post_tool_use[:] = [h for h in post_tool_use if not _is_elydora_hook(h)]
|
||||
|
||||
post_tool_use.append({
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": hook_path,
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
print(f"Elydora hook installed for Claude Code.")
|
||||
print(f" Hook script: {hook_path}")
|
||||
print(f" Settings: {SETTINGS_PATH}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
# Remove hook entries from settings
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
changed = False
|
||||
|
||||
# Remove PreToolUse entries
|
||||
pre_tool_use = hooks.get("PreToolUse", [])
|
||||
pre_filtered = [h for h in pre_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(pre_filtered) != len(pre_tool_use):
|
||||
hooks["PreToolUse"] = pre_filtered
|
||||
if not pre_filtered:
|
||||
del hooks["PreToolUse"]
|
||||
changed = True
|
||||
|
||||
# Remove PostToolUse entries
|
||||
post_tool_use = hooks.get("PostToolUse", [])
|
||||
post_filtered = [h for h in post_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(post_filtered) != len(post_tool_use):
|
||||
hooks["PostToolUse"] = post_filtered
|
||||
if not post_filtered:
|
||||
del hooks["PostToolUse"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
if not hooks:
|
||||
del settings["hooks"]
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Claude Code.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
hook_exists = len(hook_files) > 0
|
||||
|
||||
settings_configured = False
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
pre_tool_use = hooks.get("PreToolUse", [])
|
||||
post_tool_use = hooks.get("PostToolUse", [])
|
||||
pre_configured = any(_is_elydora_hook(h) for h in pre_tool_use)
|
||||
post_configured = any(_is_elydora_hook(h) for h in post_tool_use)
|
||||
settings_configured = pre_configured and post_configured
|
||||
|
||||
installed = hook_exists and settings_configured
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook script exists but not configured in settings"
|
||||
elif settings_configured:
|
||||
details = "Configured in settings but hook script missing"
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="claudecode", details=details)
|
||||
|
||||
|
||||
def _is_elydora_hook(entry: dict, agent_id: str = "") -> bool:
|
||||
# Collect all command strings from the entry
|
||||
commands = []
|
||||
inner_hooks = entry.get("hooks")
|
||||
if isinstance(inner_hooks, list):
|
||||
commands.extend(
|
||||
h.get("command", "")
|
||||
for h in inner_hooks
|
||||
if isinstance(h, dict)
|
||||
)
|
||||
else:
|
||||
commands.append(entry.get("command", ""))
|
||||
|
||||
for cmd in commands:
|
||||
cmd_lower = cmd.lower()
|
||||
if "elydora" not in cmd_lower:
|
||||
continue
|
||||
# If agent_id is specified, only match hooks for that specific agent
|
||||
if agent_id and agent_id in cmd:
|
||||
return True
|
||||
if not agent_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: str, data: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Copilot CLI plugin — merges preToolUse/postToolUse hooks into .github/hooks/hooks.json (project-relative)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def _settings_path() -> str:
|
||||
return os.path.join(os.getcwd(), ".github", "hooks", "hooks.json")
|
||||
|
||||
|
||||
class CopilotPlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Copilot CLI."""
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(config.get("private_key", ""))
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
private_key=config.get("private_key", ""),
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
hook_path = self._hook_path_for(agent_id)
|
||||
with open(hook_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
try:
|
||||
os.chmod(hook_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
settings_path = _settings_path()
|
||||
settings = _load_json(settings_path)
|
||||
settings["version"] = 1
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
|
||||
# --- preToolUse (guard — freeze enforcement, camelCase) ---
|
||||
pre_tool_use = hooks.setdefault("preToolUse", [])
|
||||
pre_tool_use[:] = [h for h in pre_tool_use if not _is_elydora_hook(h)]
|
||||
if guard_script_path:
|
||||
pre_tool_use.append({
|
||||
"type": "command",
|
||||
"bash": f'"{python_exe}" {guard_script_path}',
|
||||
"powershell": f'"{python_exe}" {guard_script_path}',
|
||||
"timeoutSec": 5,
|
||||
})
|
||||
|
||||
# --- postToolUse (audit logging, camelCase) ---
|
||||
post_tool_use = hooks.setdefault("postToolUse", [])
|
||||
|
||||
post_tool_use[:] = [h for h in post_tool_use if not _is_elydora_hook(h)]
|
||||
|
||||
post_tool_use.append({
|
||||
"type": "command",
|
||||
"bash": hook_path,
|
||||
"powershell": hook_path,
|
||||
"timeoutSec": 5,
|
||||
})
|
||||
|
||||
_save_json(settings_path, settings)
|
||||
print(f"Elydora hook installed for Copilot CLI.")
|
||||
print(f" Hook script: {hook_path}")
|
||||
print(f" Settings: {settings_path}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
settings_path = _settings_path()
|
||||
if os.path.exists(settings_path):
|
||||
settings = _load_json(settings_path)
|
||||
hooks = settings.get("hooks", {})
|
||||
changed = False
|
||||
|
||||
# Remove preToolUse entries
|
||||
pre_tool_use = hooks.get("preToolUse", [])
|
||||
pre_filtered = [h for h in pre_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(pre_filtered) != len(pre_tool_use):
|
||||
hooks["preToolUse"] = pre_filtered
|
||||
if not pre_filtered:
|
||||
del hooks["preToolUse"]
|
||||
changed = True
|
||||
|
||||
# Remove postToolUse entries
|
||||
post_tool_use = hooks.get("postToolUse", [])
|
||||
post_filtered = [h for h in post_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(post_filtered) != len(post_tool_use):
|
||||
hooks["postToolUse"] = post_filtered
|
||||
if not post_filtered:
|
||||
del hooks["postToolUse"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
if not hooks:
|
||||
del settings["hooks"]
|
||||
_save_json(settings_path, settings)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Copilot CLI.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
hook_exists = len(hook_files) > 0
|
||||
|
||||
settings_configured = False
|
||||
settings_path = _settings_path()
|
||||
if os.path.exists(settings_path):
|
||||
settings = _load_json(settings_path)
|
||||
hooks = settings.get("hooks", {})
|
||||
pre_tool_use = hooks.get("preToolUse", [])
|
||||
post_tool_use = hooks.get("postToolUse", [])
|
||||
pre_configured = any(_is_elydora_hook(h) for h in pre_tool_use)
|
||||
post_configured = any(_is_elydora_hook(h) for h in post_tool_use)
|
||||
settings_configured = pre_configured and post_configured
|
||||
|
||||
installed = hook_exists and settings_configured
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook script exists but not configured in hooks.json"
|
||||
elif settings_configured:
|
||||
details = "Configured in hooks.json but hook script missing"
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="copilot", details=details)
|
||||
|
||||
|
||||
def _is_elydora_hook(hook: dict, agent_id: str = "") -> bool:
|
||||
# Check both bash and powershell fields for Copilot's hook format
|
||||
for field in ("bash", "powershell", "command"):
|
||||
cmd = hook.get(field, "")
|
||||
if not isinstance(cmd, str):
|
||||
continue
|
||||
if "elydora" not in cmd.lower():
|
||||
continue
|
||||
if agent_id and agent_id in cmd:
|
||||
return True
|
||||
if not agent_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: str, data: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Cursor plugin — writes/merges postToolUse hook into ~/.cursor/hooks.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
SETTINGS_PATH = os.path.join(os.path.expanduser("~"), ".cursor", "hooks.json")
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
class CursorPlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Cursor."""
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(config.get("private_key", ""))
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
private_key=config.get("private_key", ""),
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
hook_path = self._hook_path_for(agent_id)
|
||||
with open(hook_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
try:
|
||||
os.chmod(hook_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
|
||||
# --- preToolUse (guard — freeze enforcement, camelCase) ---
|
||||
pre_tool_use = hooks.setdefault("preToolUse", [])
|
||||
pre_tool_use[:] = [h for h in pre_tool_use if not _is_elydora_hook(h)]
|
||||
if guard_script_path:
|
||||
pre_tool_use.append({
|
||||
"command": f'"{python_exe}" {guard_script_path}',
|
||||
})
|
||||
|
||||
# --- postToolUse (audit logging, camelCase) ---
|
||||
post_tool_use = hooks.setdefault("postToolUse", [])
|
||||
|
||||
post_tool_use[:] = [h for h in post_tool_use if not _is_elydora_hook(h)]
|
||||
|
||||
post_tool_use.append({
|
||||
"command": hook_path,
|
||||
})
|
||||
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
print(f"Elydora hook installed for Cursor.")
|
||||
print(f" Hook script: {hook_path}")
|
||||
print(f" Settings: {SETTINGS_PATH}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
changed = False
|
||||
|
||||
# Remove preToolUse entries
|
||||
pre_tool_use = hooks.get("preToolUse", [])
|
||||
pre_filtered = [h for h in pre_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(pre_filtered) != len(pre_tool_use):
|
||||
hooks["preToolUse"] = pre_filtered
|
||||
if not pre_filtered:
|
||||
del hooks["preToolUse"]
|
||||
changed = True
|
||||
|
||||
# Remove postToolUse entries
|
||||
post_tool_use = hooks.get("postToolUse", [])
|
||||
post_filtered = [h for h in post_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(post_filtered) != len(post_tool_use):
|
||||
hooks["postToolUse"] = post_filtered
|
||||
if not post_filtered:
|
||||
del hooks["postToolUse"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
if not hooks:
|
||||
del settings["hooks"]
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Cursor.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
hook_exists = len(hook_files) > 0
|
||||
|
||||
settings_configured = False
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
pre_tool_use = hooks.get("preToolUse", [])
|
||||
post_tool_use = hooks.get("postToolUse", [])
|
||||
pre_configured = any(_is_elydora_hook(h) for h in pre_tool_use)
|
||||
post_configured = any(_is_elydora_hook(h) for h in post_tool_use)
|
||||
settings_configured = pre_configured and post_configured
|
||||
|
||||
installed = hook_exists and settings_configured
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook script exists but not configured in hooks.json"
|
||||
elif settings_configured:
|
||||
details = "Configured in hooks.json but hook script missing"
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="cursor", details=details)
|
||||
|
||||
|
||||
def _is_elydora_hook(hook: dict, agent_id: str = "") -> bool:
|
||||
cmd = hook.get("command", "")
|
||||
if "elydora" not in cmd.lower():
|
||||
return False
|
||||
# If agent_id is specified, only match hooks for that specific agent
|
||||
if agent_id:
|
||||
return agent_id in cmd
|
||||
return True
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: str, data: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Gemini CLI plugin — merges AfterTool hook into ~/.gemini/settings.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
SETTINGS_PATH = os.path.join(os.path.expanduser("~"), ".gemini", "settings.json")
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
class GeminiPlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Gemini CLI."""
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(config.get("private_key", ""))
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
private_key=config.get("private_key", ""),
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
hook_path = self._hook_path_for(agent_id)
|
||||
with open(hook_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
try:
|
||||
os.chmod(hook_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
|
||||
# --- BeforeTool (guard — freeze enforcement) ---
|
||||
before_tool = hooks.setdefault("BeforeTool", [])
|
||||
before_tool[:] = [h for h in before_tool if not _is_elydora_hook(h)]
|
||||
if guard_script_path:
|
||||
before_tool.append({
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": f'"{python_exe}" {guard_script_path}',
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
# --- AfterTool (audit logging) ---
|
||||
after_tool = hooks.setdefault("AfterTool", [])
|
||||
|
||||
after_tool[:] = [h for h in after_tool if not _is_elydora_hook(h)]
|
||||
|
||||
after_tool.append({
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": hook_path,
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
print(f"Elydora hook installed for Gemini CLI.")
|
||||
print(f" Hook script: {hook_path}")
|
||||
print(f" Settings: {SETTINGS_PATH}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
changed = False
|
||||
|
||||
# Remove BeforeTool entries
|
||||
before_tool = hooks.get("BeforeTool", [])
|
||||
before_filtered = [h for h in before_tool if not _is_elydora_hook(h, agent_id)]
|
||||
if len(before_filtered) != len(before_tool):
|
||||
hooks["BeforeTool"] = before_filtered
|
||||
if not before_filtered:
|
||||
del hooks["BeforeTool"]
|
||||
changed = True
|
||||
|
||||
# Remove AfterTool entries
|
||||
after_tool = hooks.get("AfterTool", [])
|
||||
after_filtered = [h for h in after_tool if not _is_elydora_hook(h, agent_id)]
|
||||
if len(after_filtered) != len(after_tool):
|
||||
hooks["AfterTool"] = after_filtered
|
||||
if not after_filtered:
|
||||
del hooks["AfterTool"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
if not hooks:
|
||||
del settings["hooks"]
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Gemini CLI.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
hook_exists = len(hook_files) > 0
|
||||
|
||||
settings_configured = False
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
before_tool = hooks.get("BeforeTool", [])
|
||||
after_tool = hooks.get("AfterTool", [])
|
||||
before_configured = any(_is_elydora_hook(h) for h in before_tool)
|
||||
after_configured = any(_is_elydora_hook(h) for h in after_tool)
|
||||
settings_configured = before_configured and after_configured
|
||||
|
||||
installed = hook_exists and settings_configured
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook script exists but not configured in settings"
|
||||
elif settings_configured:
|
||||
details = "Configured in settings but hook script missing"
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="gemini", details=details)
|
||||
|
||||
|
||||
def _is_elydora_hook(entry: dict, agent_id: str = "") -> bool:
|
||||
# Collect all command strings from the entry
|
||||
commands = []
|
||||
inner_hooks = entry.get("hooks")
|
||||
if isinstance(inner_hooks, list):
|
||||
commands.extend(
|
||||
h.get("command", "")
|
||||
for h in inner_hooks
|
||||
if isinstance(h, dict)
|
||||
)
|
||||
else:
|
||||
commands.append(entry.get("command", ""))
|
||||
|
||||
for cmd in commands:
|
||||
cmd_lower = cmd.lower()
|
||||
if "elydora" not in cmd_lower:
|
||||
continue
|
||||
# If agent_id is specified, only match hooks for that specific agent
|
||||
if agent_id and agent_id in cmd:
|
||||
return True
|
||||
if not agent_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: str, data: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Generates self-contained Python hook scripts for AI agent integrations.
|
||||
|
||||
The generated script contains all crypto and networking inline so it has zero
|
||||
import dependencies beyond the Python stdlib + cryptography (already an
|
||||
Elydora SDK dependency).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def generate_hook_script(
|
||||
*,
|
||||
org_id: str,
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
kid: str,
|
||||
base_url: str,
|
||||
) -> str:
|
||||
"""Return a complete, self-contained Python hook script as a string.
|
||||
|
||||
The script:
|
||||
1. Reads JSON from stdin (tool-use event)
|
||||
2. Constructs an Elydora Operation Record (EOR)
|
||||
3. Signs it with Ed25519
|
||||
4. POSTs it fire-and-forget to the Elydora API
|
||||
5. Always exits 0 (never blocks the host agent)
|
||||
|
||||
Chain state is persisted in ~/.elydora/{agent_id}/chain-state.json.
|
||||
"""
|
||||
shebang = sys.executable
|
||||
return f'''#!{shebang}
|
||||
"""Elydora audit hook — auto-generated, do not edit."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration (baked at install time)
|
||||
# ---------------------------------------------------------------------------
|
||||
ORG_ID = {org_id!r}
|
||||
AGENT_ID = {agent_id!r}
|
||||
PRIVATE_KEY = {private_key!r}
|
||||
KID = {kid!r}
|
||||
BASE_URL = {base_url!r}
|
||||
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
CHAIN_STATE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "chain-state.json")
|
||||
ERROR_LOG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "error.log")
|
||||
ZERO_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base64url helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def base64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
def base64url_decode(s: str) -> bytes:
|
||||
s = s.replace("-", "+").replace("_", "/")
|
||||
padding = 4 - len(s) % 4
|
||||
if padding != 4:
|
||||
s += "=" * padding
|
||||
return base64.b64decode(s)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SHA-256
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def sha256_base64url(data):
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
return base64url_encode(hashlib.sha256(data).digest())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JCS canonicalization (RFC 8785)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _jcs_number(value):
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if math.isnan(value) or math.isinf(value):
|
||||
return "null"
|
||||
if value == 0.0:
|
||||
return "0"
|
||||
return json.dumps(value)
|
||||
|
||||
def jcs_canonicalize(value):
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return _jcs_number(value)
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, list):
|
||||
return "[" + ",".join(jcs_canonicalize(v) for v in value) + "]"
|
||||
if isinstance(value, dict):
|
||||
keys = sorted(value.keys())
|
||||
pairs = []
|
||||
for k in keys:
|
||||
pairs.append(json.dumps(k, ensure_ascii=False) + ":" + jcs_canonicalize(value[k]))
|
||||
return "{{" + ",".join(pairs) + "}}"
|
||||
return json.dumps(value)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hash helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_payload_hash(payload):
|
||||
return sha256_base64url(jcs_canonicalize(payload))
|
||||
|
||||
def compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at):
|
||||
input_str = f"{{prev_chain_hash}}|{{payload_hash}}|{{operation_id}}|{{issued_at}}"
|
||||
return sha256_base64url(input_str)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ed25519 signing (uses cryptography library)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def sign_ed25519(private_key_b64url, data):
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
seed = base64url_decode(private_key_b64url)
|
||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
return base64url_encode(key.sign(data))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UUIDv7
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_uuidv7():
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
ts_bytes = struct.pack(">Q", timestamp_ms)[2:]
|
||||
rand_a = struct.unpack(">H", os.urandom(2))[0]
|
||||
rand_a = (rand_a & 0x0FFF) | 0x7000
|
||||
rand_b = bytearray(os.urandom(8))
|
||||
rand_b[0] = (rand_b[0] & 0x3F) | 0x80
|
||||
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
|
||||
h = uuid_bytes.hex()
|
||||
return f"{{h[0:8]}}-{{h[8:12]}}-{{h[12:16]}}-{{h[16:20]}}-{{h[20:32]}}"
|
||||
|
||||
def generate_nonce():
|
||||
return base64url_encode(os.urandom(16))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chain state persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_chain_state():
|
||||
try:
|
||||
with open(CHAIN_STATE_PATH, "r") as f:
|
||||
state = json.load(f)
|
||||
return state.get("prev_chain_hash", ZERO_CHAIN_HASH) or ZERO_CHAIN_HASH
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return ZERO_CHAIN_HASH
|
||||
|
||||
def write_chain_state(chain_hash):
|
||||
os.makedirs(os.path.dirname(CHAIN_STATE_PATH), exist_ok=True)
|
||||
tmp_path = CHAIN_STATE_PATH + ".tmp"
|
||||
with open(tmp_path, "w") as f:
|
||||
json.dump({{"prev_chain_hash": chain_hash}}, f)
|
||||
try:
|
||||
os.replace(tmp_path, CHAIN_STATE_PATH)
|
||||
except OSError:
|
||||
import shutil
|
||||
shutil.move(tmp_path, CHAIN_STATE_PATH)
|
||||
|
||||
def log_error(err):
|
||||
try:
|
||||
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
msg = f"{{ts}} [elydora-hook] {{err}}\\n"
|
||||
os.makedirs(os.path.dirname(ERROR_LOG_PATH), exist_ok=True)
|
||||
with open(ERROR_LOG_PATH, "a") as f:
|
||||
f.write(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main hook logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
try:
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
return
|
||||
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
|
||||
# Extract tool event data
|
||||
tool_name = "unknown"
|
||||
tool_input = {{}}
|
||||
session_id = "unknown"
|
||||
|
||||
if isinstance(event, dict):
|
||||
tool_name = event.get("tool_name", event.get("toolName", event.get("name", "unknown")))
|
||||
tool_input = event.get("tool_input", event.get("toolInput", event.get("input", {{}})))
|
||||
session_id = event.get("session_id", event.get("sessionId", event.get("session", "unknown")))
|
||||
|
||||
# Read chain state
|
||||
prev_chain_hash = read_chain_state()
|
||||
|
||||
# Build the EOR
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
nonce = generate_nonce()
|
||||
|
||||
payload = {{
|
||||
"tool_name": tool_name,
|
||||
"tool_input": tool_input,
|
||||
"session_id": session_id,
|
||||
}}
|
||||
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at)
|
||||
|
||||
eor_without_sig = {{
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": ORG_ID,
|
||||
"agent_id": AGENT_ID,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": 30000,
|
||||
"nonce": nonce,
|
||||
"operation_type": "ai.tool_use",
|
||||
"subject": {{"session_id": session_id}},
|
||||
"action": {{"tool": tool_name}},
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": prev_chain_hash,
|
||||
"agent_pubkey_kid": KID,
|
||||
}}
|
||||
|
||||
canonical = jcs_canonicalize(eor_without_sig)
|
||||
signature = sign_ed25519(PRIVATE_KEY, canonical.encode("utf-8"))
|
||||
|
||||
eor = dict(eor_without_sig)
|
||||
eor["chain_hash"] = chain_hash
|
||||
eor["signature"] = signature
|
||||
|
||||
# POST to /v1/operations (5s timeout)
|
||||
# Only update local chain state on success to prevent desync
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
url = BASE_URL.rstrip("/") + "/v1/operations"
|
||||
body = json.dumps(eor).encode("utf-8")
|
||||
headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
resp = urllib.request.urlopen(req, timeout=5)
|
||||
# Server accepted (2xx) — safe to advance local chain state
|
||||
write_chain_state(chain_hash)
|
||||
except urllib.error.HTTPError as http_err:
|
||||
if http_err.code == 400:
|
||||
# Possible chain hash mismatch — try to resync from server response
|
||||
try:
|
||||
import re
|
||||
err_body = json.loads(http_err.read().decode("utf-8"))
|
||||
if isinstance(err_body, dict) and isinstance(err_body.get("error"), dict):
|
||||
if err_body["error"].get("code") == "PREV_HASH_MISMATCH":
|
||||
match = re.search(r'Expected prev_chain_hash "([^"]+)"', err_body["error"].get("message", ""))
|
||||
if match:
|
||||
write_chain_state(match.group(1))
|
||||
log_error("Chain hash resynced to server: " + match.group(1))
|
||||
except Exception:
|
||||
pass # ignore parse errors
|
||||
else:
|
||||
log_error("API returned HTTP " + str(http_err.code))
|
||||
except Exception:
|
||||
pass # Network error — don't advance chain state, will retry with same prev_chain_hash
|
||||
|
||||
except Exception as e:
|
||||
log_error(e)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
def generate_guard_script(agent_name: str, agent_id: str) -> str:
|
||||
"""Return a self-contained Python guard script for PreToolUse freeze enforcement.
|
||||
|
||||
The script:
|
||||
1. Reads agent config from ~/.elydora/{agent_id}/config.json
|
||||
2. Checks cached status from ~/.elydora/{agent_id}/status-cache.json (60s TTL)
|
||||
3. If cache is stale, fetches agent status from GET {base_url}/v1/agents/{agent_id}
|
||||
4. If status is "frozen", writes error to stderr and exits with code 1 (blocks tool)
|
||||
5. Fail-open: if API unreachable or config missing, exits 0 (allow)
|
||||
6. Uses only stdlib — no external deps
|
||||
"""
|
||||
shebang = sys.executable
|
||||
return f'''#!{shebang}
|
||||
"""Elydora agent guard (PreToolUse) for: {agent_name}
|
||||
Generated by elydora-sdk — DO NOT EDIT."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
AGENT_NAME = {agent_name!r}
|
||||
AGENT_ID = {agent_id!r}
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
CONFIG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "config.json")
|
||||
STATUS_CACHE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "status-cache.json")
|
||||
CACHE_TTL_S = 60 # 60 seconds
|
||||
|
||||
# Consume stdin so the parent process doesn't block on a full pipe
|
||||
try:
|
||||
sys.stdin.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
# Read agent config
|
||||
try:
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
config = json.load(f)
|
||||
except Exception:
|
||||
return # Can't read config — fail-open (allow)
|
||||
|
||||
# Check cached status
|
||||
try:
|
||||
with open(STATUS_CACHE_PATH, "r") as f:
|
||||
cache = json.load(f)
|
||||
age = time.time() - cache.get("cached_at", 0)
|
||||
if age < CACHE_TTL_S:
|
||||
if cache.get("status") == "frozen":
|
||||
sys.stderr.write(
|
||||
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
|
||||
)
|
||||
sys.exit(1)
|
||||
return # Cache is fresh — use cached result
|
||||
except Exception:
|
||||
pass # No cache or invalid — need to check API
|
||||
|
||||
# Fetch agent status from API
|
||||
base_url = config.get("base_url", "https://api.elydora.com").rstrip("/")
|
||||
agent_id = config.get("agent_id", "")
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = base_url + "/v1/agents/" + urllib.request.quote(agent_id, safe="")
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Accept", "application/json")
|
||||
token = config.get("token", "")
|
||||
if token:
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
|
||||
resp = urllib.request.urlopen(req, timeout=3)
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
agent_status = "active"
|
||||
if isinstance(data, dict) and isinstance(data.get("agent"), dict):
|
||||
agent_status = data["agent"].get("status", "active")
|
||||
|
||||
# Update cache
|
||||
try:
|
||||
os.makedirs(os.path.dirname(STATUS_CACHE_PATH), exist_ok=True)
|
||||
with open(STATUS_CACHE_PATH, "w") as f:
|
||||
json.dump({{"status": agent_status, "cached_at": time.time()}}, f)
|
||||
except Exception:
|
||||
pass # Ignore cache write failures
|
||||
|
||||
if agent_status == "frozen":
|
||||
sys.stderr.write(
|
||||
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
|
||||
)
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise # Re-raise sys.exit
|
||||
except Exception:
|
||||
pass # API unreachable — fail-open (allow)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Kiro CLI plugin — merges PreToolUse/PostToolUse hooks into ~/.kiro/settings.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
SETTINGS_PATH = os.path.join(os.path.expanduser("~"), ".kiro", "settings.json")
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
class KiroCliPlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Kiro CLI."""
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(config.get("private_key", ""))
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
private_key=config.get("private_key", ""),
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
hook_path = self._hook_path_for(agent_id)
|
||||
with open(hook_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
try:
|
||||
os.chmod(hook_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
|
||||
# --- PreToolUse (guard — freeze enforcement, PascalCase with matcher) ---
|
||||
pre_tool_use = hooks.setdefault("PreToolUse", [])
|
||||
pre_tool_use[:] = [h for h in pre_tool_use if not _is_elydora_hook(h)]
|
||||
if guard_script_path:
|
||||
pre_tool_use.append({
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": f'"{python_exe}" {guard_script_path}',
|
||||
"timeout_ms": 5000,
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
# --- PostToolUse (audit logging, PascalCase with matcher) ---
|
||||
post_tool_use = hooks.setdefault("PostToolUse", [])
|
||||
|
||||
post_tool_use[:] = [h for h in post_tool_use if not _is_elydora_hook(h)]
|
||||
|
||||
post_tool_use.append({
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": hook_path,
|
||||
"timeout_ms": 5000,
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
print(f"Elydora hook installed for Kiro CLI.")
|
||||
print(f" Hook script: {hook_path}")
|
||||
print(f" Settings: {SETTINGS_PATH}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
changed = False
|
||||
|
||||
# Remove PreToolUse entries
|
||||
pre_tool_use = hooks.get("PreToolUse", [])
|
||||
pre_filtered = [h for h in pre_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(pre_filtered) != len(pre_tool_use):
|
||||
hooks["PreToolUse"] = pre_filtered
|
||||
if not pre_filtered:
|
||||
del hooks["PreToolUse"]
|
||||
changed = True
|
||||
|
||||
# Remove PostToolUse entries
|
||||
post_tool_use = hooks.get("PostToolUse", [])
|
||||
post_filtered = [h for h in post_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(post_filtered) != len(post_tool_use):
|
||||
hooks["PostToolUse"] = post_filtered
|
||||
if not post_filtered:
|
||||
del hooks["PostToolUse"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
if not hooks:
|
||||
del settings["hooks"]
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Kiro CLI.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
hook_exists = len(hook_files) > 0
|
||||
|
||||
settings_configured = False
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
pre_tool_use = hooks.get("PreToolUse", [])
|
||||
post_tool_use = hooks.get("PostToolUse", [])
|
||||
pre_configured = any(_is_elydora_hook(h) for h in pre_tool_use)
|
||||
post_configured = any(_is_elydora_hook(h) for h in post_tool_use)
|
||||
settings_configured = pre_configured and post_configured
|
||||
|
||||
installed = hook_exists and settings_configured
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook script exists but not configured in settings"
|
||||
elif settings_configured:
|
||||
details = "Configured in settings but hook script missing"
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="kirocli", details=details)
|
||||
|
||||
|
||||
def _is_elydora_hook(entry: dict, agent_id: str = "") -> bool:
|
||||
# Collect all command strings from the entry
|
||||
commands = []
|
||||
inner_hooks = entry.get("hooks")
|
||||
if isinstance(inner_hooks, list):
|
||||
commands.extend(
|
||||
h.get("command", "")
|
||||
for h in inner_hooks
|
||||
if isinstance(h, dict)
|
||||
)
|
||||
else:
|
||||
commands.append(entry.get("command", ""))
|
||||
|
||||
for cmd in commands:
|
||||
cmd_lower = cmd.lower()
|
||||
if "elydora" not in cmd_lower:
|
||||
continue
|
||||
# If agent_id is specified, only match hooks for that specific agent
|
||||
if agent_id and agent_id in cmd:
|
||||
return True
|
||||
if not agent_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: str, data: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Kiro IDE plugin — writes .kiro/hooks/elydora-audit.kiro.hook file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
KIRO_HOOK_DIR = os.path.join(os.path.expanduser("~"), ".kiro", "hooks")
|
||||
KIRO_HOOK_FILENAME = "elydora-audit.kiro.hook"
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
class KiroIdePlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Kiro IDE."""
|
||||
|
||||
def _kiro_hook_path(self) -> str:
|
||||
return os.path.join(KIRO_HOOK_DIR, KIRO_HOOK_FILENAME)
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(config.get("private_key", ""))
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
# Write the Python hook script
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
private_key=config.get("private_key", ""),
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
script_path = self._hook_path_for(agent_id)
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
try:
|
||||
os.chmod(script_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
# Write the Kiro hook definition file (JSON format matching Node SDK)
|
||||
hook_config = {
|
||||
"name": "Elydora Audit",
|
||||
"description": "Sends tool-use events to the Elydora tamper-evident audit platform",
|
||||
"version": "1.0.0",
|
||||
"hooks": {
|
||||
"pre_tool_use": {
|
||||
"command": f'"{python_exe}" {guard_script_path}' if guard_script_path else "",
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
"post_tool_use": {
|
||||
"command": script_path,
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
os.makedirs(KIRO_HOOK_DIR, exist_ok=True)
|
||||
hook_path = self._kiro_hook_path()
|
||||
with open(hook_path, "w", encoding="utf-8") as f:
|
||||
json.dump(hook_config, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"Elydora hook installed for Kiro IDE.")
|
||||
print(f" Hook script: {script_path}")
|
||||
print(f" Hook definition: {hook_path}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
hook_path = self._kiro_hook_path()
|
||||
if os.path.exists(hook_path):
|
||||
os.remove(hook_path)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Kiro IDE.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
hook_path = self._kiro_hook_path()
|
||||
hook_exists = os.path.exists(hook_path)
|
||||
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook script
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
script_exists = len(hook_files) > 0
|
||||
|
||||
hook_configured = False
|
||||
if hook_exists:
|
||||
try:
|
||||
with open(hook_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
hooks = cfg.get("hooks", {})
|
||||
hook_configured = bool(
|
||||
hooks.get("pre_tool_use") and hooks.get("post_tool_use")
|
||||
)
|
||||
except Exception:
|
||||
hook_configured = hook_exists # Fallback
|
||||
|
||||
installed = hook_exists and script_exists and hook_configured
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook definition exists but script missing"
|
||||
elif script_exists:
|
||||
details = "Script exists but hook definition missing"
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="kiroide", details=details)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Letta Code plugin — merges PreToolUse/PostToolUse hooks into ~/.letta/settings.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
SETTINGS_PATH = os.path.join(os.path.expanduser("~"), ".letta", "settings.json")
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
class LettaPlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Letta Code."""
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(config.get("private_key", ""))
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
private_key=config.get("private_key", ""),
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
hook_path = self._hook_path_for(agent_id)
|
||||
with open(hook_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
try:
|
||||
os.chmod(hook_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
|
||||
# --- PreToolUse (guard — freeze enforcement, PascalCase with matcher, no timeout_ms) ---
|
||||
pre_tool_use = hooks.setdefault("PreToolUse", [])
|
||||
pre_tool_use[:] = [h for h in pre_tool_use if not _is_elydora_hook(h)]
|
||||
if guard_script_path:
|
||||
pre_tool_use.append({
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": f'"{python_exe}" {guard_script_path}',
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
# --- PostToolUse (audit logging, PascalCase with matcher, no timeout_ms) ---
|
||||
post_tool_use = hooks.setdefault("PostToolUse", [])
|
||||
|
||||
post_tool_use[:] = [h for h in post_tool_use if not _is_elydora_hook(h)]
|
||||
|
||||
post_tool_use.append({
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": hook_path,
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
print(f"Elydora hook installed for Letta Code.")
|
||||
print(f" Hook script: {hook_path}")
|
||||
print(f" Settings: {SETTINGS_PATH}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
changed = False
|
||||
|
||||
# Remove PreToolUse entries
|
||||
pre_tool_use = hooks.get("PreToolUse", [])
|
||||
pre_filtered = [h for h in pre_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(pre_filtered) != len(pre_tool_use):
|
||||
hooks["PreToolUse"] = pre_filtered
|
||||
if not pre_filtered:
|
||||
del hooks["PreToolUse"]
|
||||
changed = True
|
||||
|
||||
# Remove PostToolUse entries
|
||||
post_tool_use = hooks.get("PostToolUse", [])
|
||||
post_filtered = [h for h in post_tool_use if not _is_elydora_hook(h, agent_id)]
|
||||
if len(post_filtered) != len(post_tool_use):
|
||||
hooks["PostToolUse"] = post_filtered
|
||||
if not post_filtered:
|
||||
del hooks["PostToolUse"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
if not hooks:
|
||||
del settings["hooks"]
|
||||
_save_json(SETTINGS_PATH, settings)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Letta Code.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
hook_exists = len(hook_files) > 0
|
||||
|
||||
settings_configured = False
|
||||
if os.path.exists(SETTINGS_PATH):
|
||||
settings = _load_json(SETTINGS_PATH)
|
||||
hooks = settings.get("hooks", {})
|
||||
pre_tool_use = hooks.get("PreToolUse", [])
|
||||
post_tool_use = hooks.get("PostToolUse", [])
|
||||
pre_configured = any(_is_elydora_hook(h) for h in pre_tool_use)
|
||||
post_configured = any(_is_elydora_hook(h) for h in post_tool_use)
|
||||
settings_configured = pre_configured and post_configured
|
||||
|
||||
installed = hook_exists and settings_configured
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook script exists but not configured in settings"
|
||||
elif settings_configured:
|
||||
details = "Configured in settings but hook script missing"
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="letta", details=details)
|
||||
|
||||
|
||||
def _is_elydora_hook(entry: dict, agent_id: str = "") -> bool:
|
||||
# Collect all command strings from the entry
|
||||
commands = []
|
||||
inner_hooks = entry.get("hooks")
|
||||
if isinstance(inner_hooks, list):
|
||||
commands.extend(
|
||||
h.get("command", "")
|
||||
for h in inner_hooks
|
||||
if isinstance(h, dict)
|
||||
)
|
||||
else:
|
||||
commands.append(entry.get("command", ""))
|
||||
|
||||
for cmd in commands:
|
||||
cmd_lower = cmd.lower()
|
||||
if "elydora" not in cmd_lower:
|
||||
continue
|
||||
# If agent_id is specified, only match hooks for that specific agent
|
||||
if agent_id and agent_id in cmd:
|
||||
return True
|
||||
if not agent_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: str, data: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
@@ -0,0 +1,308 @@
|
||||
"""OpenCode plugin — writes .opencode/plugins/elydora-audit.js (JS plugin)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
|
||||
|
||||
PLUGIN_DIR = os.path.join(os.path.expanduser("~"), ".opencode", "plugins")
|
||||
PLUGIN_FILENAME = "elydora-audit.js"
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
class OpenCodePlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for OpenCode.
|
||||
|
||||
OpenCode expects JavaScript plugins, so this generates a self-contained
|
||||
JS file that performs the same audit flow using Node.js built-ins.
|
||||
"""
|
||||
|
||||
def _plugin_path(self) -> str:
|
||||
return os.path.join(PLUGIN_DIR, PLUGIN_FILENAME)
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
org_id = config.get("org_id", "")
|
||||
private_key = config.get("private_key", "")
|
||||
kid = config.get("kid", "")
|
||||
base_url = config.get("base_url", "https://api.elydora.com")
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": org_id,
|
||||
"agent_id": agent_id,
|
||||
"kid": kid,
|
||||
"base_url": base_url,
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(private_key)
|
||||
try:
|
||||
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
js_content = _generate_js_plugin(
|
||||
org_id=org_id,
|
||||
agent_id=agent_id,
|
||||
private_key=private_key,
|
||||
kid=kid,
|
||||
base_url=base_url,
|
||||
guard_script_path=guard_script_path,
|
||||
)
|
||||
|
||||
os.makedirs(PLUGIN_DIR, exist_ok=True)
|
||||
plugin_path = self._plugin_path()
|
||||
with open(plugin_path, "w", encoding="utf-8") as f:
|
||||
f.write(js_content)
|
||||
try:
|
||||
os.chmod(plugin_path, stat.S_IRWXU)
|
||||
except Exception:
|
||||
pass # chmod may fail on Windows
|
||||
|
||||
print(f"Elydora hook installed for OpenCode.")
|
||||
print(f" Plugin: {plugin_path}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
plugin_path = self._plugin_path()
|
||||
if os.path.exists(plugin_path):
|
||||
os.remove(plugin_path)
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from OpenCode.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
plugin_path = self._plugin_path()
|
||||
installed = os.path.exists(plugin_path)
|
||||
if installed:
|
||||
details = f"Plugin: {plugin_path}"
|
||||
else:
|
||||
details = "Not installed"
|
||||
return PluginStatus(installed=installed, agent="opencode", details=details)
|
||||
|
||||
|
||||
def _generate_js_plugin(
|
||||
*,
|
||||
org_id: str,
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
kid: str,
|
||||
base_url: str,
|
||||
guard_script_path: str = "",
|
||||
) -> str:
|
||||
guard_path_escaped = guard_script_path.replace("\\", "\\\\") if guard_script_path else ""
|
||||
python_exe_escaped = sys.executable.replace("\\", "\\\\")
|
||||
return f'''// Elydora audit plugin for OpenCode — auto-generated, do not edit.
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const {{ spawnSync }} = require("child_process");
|
||||
|
||||
const ORG_ID = {_js_str(org_id)};
|
||||
const AGENT_ID = {_js_str(agent_id)};
|
||||
const PRIVATE_KEY = {_js_str(private_key)};
|
||||
const KID = {_js_str(kid)};
|
||||
const BASE_URL = {_js_str(base_url)};
|
||||
|
||||
const CHAIN_STATE_PATH = path.join(os.homedir(), ".elydora", AGENT_ID, "chain-state.json");
|
||||
|
||||
function base64urlEncode(buf) {{
|
||||
return Buffer.from(buf).toString("base64url");
|
||||
}}
|
||||
|
||||
function base64urlDecode(s) {{
|
||||
return Buffer.from(s, "base64url");
|
||||
}}
|
||||
|
||||
function sha256Base64url(data) {{
|
||||
if (typeof data === "string") data = Buffer.from(data, "utf-8");
|
||||
return base64urlEncode(crypto.createHash("sha256").update(data).digest());
|
||||
}}
|
||||
|
||||
function jcsCanonicalize(value) {{
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number") {{
|
||||
if (!isFinite(value)) return "null";
|
||||
if (Object.is(value, -0)) return "0";
|
||||
return JSON.stringify(value);
|
||||
}}
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return "[" + value.map(jcsCanonicalize).join(",") + "]";
|
||||
if (typeof value === "object") {{
|
||||
const keys = Object.keys(value).sort();
|
||||
const pairs = keys.map(k => JSON.stringify(k) + ":" + jcsCanonicalize(value[k]));
|
||||
return "{{" + pairs.join(",") + "}}";
|
||||
}}
|
||||
return JSON.stringify(value);
|
||||
}}
|
||||
|
||||
function computePayloadHash(payload) {{
|
||||
return sha256Base64url(jcsCanonicalize(payload));
|
||||
}}
|
||||
|
||||
function computeChainHash(prev, payloadHash, opId, issuedAt) {{
|
||||
return sha256Base64url(`${{prev}}|${{payloadHash}}|${{opId}}|${{issuedAt}}`);
|
||||
}}
|
||||
|
||||
function signEd25519(privKeyB64url, data) {{
|
||||
const seed = base64urlDecode(privKeyB64url);
|
||||
const key = crypto.createPrivateKey({{
|
||||
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
}});
|
||||
return base64urlEncode(crypto.sign(null, Buffer.from(data, "utf-8"), key));
|
||||
}}
|
||||
|
||||
function generateUuidv7() {{
|
||||
const ts = Date.now();
|
||||
const buf = Buffer.alloc(16);
|
||||
buf.writeUIntBE(ts, 0, 6);
|
||||
const rand = crypto.randomBytes(10);
|
||||
rand.copy(buf, 6);
|
||||
buf[6] = (buf[6] & 0x0f) | 0x70;
|
||||
buf[8] = (buf[8] & 0x3f) | 0x80;
|
||||
const h = buf.toString("hex");
|
||||
return `${{h.slice(0,8)}}-${{h.slice(8,12)}}-${{h.slice(12,16)}}-${{h.slice(16,20)}}-${{h.slice(20)}}`;
|
||||
}}
|
||||
|
||||
function generateNonce() {{
|
||||
return base64urlEncode(crypto.randomBytes(16));
|
||||
}}
|
||||
|
||||
function loadChainState() {{
|
||||
try {{
|
||||
return JSON.parse(fs.readFileSync(CHAIN_STATE_PATH, "utf-8"));
|
||||
}} catch {{
|
||||
return {{ prev_chain_hash: "" }};
|
||||
}}
|
||||
}}
|
||||
|
||||
function saveChainState(state) {{
|
||||
const dir = path.dirname(CHAIN_STATE_PATH);
|
||||
fs.mkdirSync(dir, {{ recursive: true }});
|
||||
const tmp = CHAIN_STATE_PATH + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(state));
|
||||
fs.renameSync(tmp, CHAIN_STATE_PATH);
|
||||
}}
|
||||
|
||||
const PYTHON_EXE = "{python_exe_escaped}";
|
||||
const GUARD_SCRIPT_PATH = "{guard_path_escaped}";
|
||||
|
||||
module.exports = {{
|
||||
name: "elydora-audit",
|
||||
version: "1.0.0",
|
||||
description: "Sends tool-use events to the Elydora tamper-evident audit platform",
|
||||
|
||||
hooks: {{
|
||||
preToolUse(context) {{
|
||||
// Synchronous guard — blocks tool if agent is frozen
|
||||
if (GUARD_SCRIPT_PATH) {{
|
||||
try {{
|
||||
const result = spawnSync(PYTHON_EXE, [GUARD_SCRIPT_PATH], {{
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "ignore", "pipe"],
|
||||
}});
|
||||
if (result.status !== 0) {{
|
||||
const msg = result.stderr ? result.stderr.toString().trim() : "Agent is frozen by Elydora.";
|
||||
return {{ blocked: true, reason: msg }};
|
||||
}}
|
||||
}} catch {{
|
||||
// Fail-open — allow if guard can't run
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
|
||||
postToolUse(context) {{
|
||||
const event = context || {{}};
|
||||
try {{
|
||||
const toolName = event.tool_name || event.toolName || event.name || "";
|
||||
const toolInput = event.tool_input || event.toolInput || event.input || {{}};
|
||||
const toolOutput = event.tool_output || event.toolOutput || event.output || {{}};
|
||||
|
||||
const operationId = generateUuidv7();
|
||||
const issuedAt = Date.now();
|
||||
const nonce = generateNonce();
|
||||
const payload = {{ tool_name: toolName, tool_input: toolInput, tool_output: toolOutput }};
|
||||
const payloadHash = computePayloadHash(payload);
|
||||
|
||||
const chainState = loadChainState();
|
||||
const prevChainHash = chainState.prev_chain_hash || "";
|
||||
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
||||
|
||||
const eor = {{
|
||||
op_version: "1.0",
|
||||
operation_id: operationId,
|
||||
org_id: ORG_ID,
|
||||
agent_id: AGENT_ID,
|
||||
issued_at: issuedAt,
|
||||
ttl_ms: 30000,
|
||||
nonce: nonce,
|
||||
operation_type: "tool_use",
|
||||
subject: {{ type: "tool", id: toolName }},
|
||||
action: {{ type: "execute", tool: toolName }},
|
||||
payload: payload,
|
||||
payload_hash: payloadHash,
|
||||
prev_chain_hash: prevChainHash,
|
||||
agent_pubkey_kid: KID,
|
||||
signature: "",
|
||||
}};
|
||||
|
||||
const signable = Object.fromEntries(Object.entries(eor).filter(([k]) => k !== "signature"));
|
||||
eor.signature = signEd25519(PRIVATE_KEY, jcsCanonicalize(signable));
|
||||
|
||||
// POST to API — only save chain state on 2xx response
|
||||
const url = new URL(BASE_URL.replace(/\\/$/, "") + "/v1/operations");
|
||||
const body = JSON.stringify(eor);
|
||||
const mod = url.protocol === "https:" ? https : http;
|
||||
const req = mod.request(url, {{
|
||||
method: "POST",
|
||||
headers: {{ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }},
|
||||
timeout: 5000,
|
||||
}}, (res) => {{
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {{
|
||||
saveChainState({{ prev_chain_hash: chainHash }});
|
||||
}}
|
||||
res.resume();
|
||||
}});
|
||||
req.on("error", () => {{}});
|
||||
req.write(body);
|
||||
req.end();
|
||||
}} catch (e) {{
|
||||
// Never block the host agent
|
||||
console.error("[elydora]", e.message);
|
||||
}}
|
||||
}},
|
||||
}},
|
||||
}};
|
||||
'''
|
||||
|
||||
|
||||
def _js_str(s: str) -> str:
|
||||
"""Escape a Python string for embedding as a JS string literal."""
|
||||
import json
|
||||
return json.dumps(s)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Supported agent registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
|
||||
SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
|
||||
"claudecode": {
|
||||
"name": "Claude Code",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": "~/.claude/settings.json",
|
||||
},
|
||||
"copilot": {
|
||||
"name": "Copilot CLI",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": ".github/hooks/hooks.json",
|
||||
},
|
||||
"cursor": {
|
||||
"name": "Cursor",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": "~/.cursor/hooks.json",
|
||||
},
|
||||
"gemini": {
|
||||
"name": "Gemini CLI",
|
||||
"hook_event": "AfterTool",
|
||||
"config_path": "~/.gemini/settings.json",
|
||||
},
|
||||
"kirocli": {
|
||||
"name": "Kiro CLI",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": "~/.kiro/settings.json",
|
||||
},
|
||||
"kiroide": {
|
||||
"name": "Kiro IDE",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": "~/.kiro/hooks/",
|
||||
},
|
||||
"letta": {
|
||||
"name": "Letta Code",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": "~/.letta/settings.json",
|
||||
},
|
||||
"opencode": {
|
||||
"name": "OpenCode",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": "~/.opencode/plugins/",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_agent_names() -> list[str]:
|
||||
"""Return sorted list of supported agent names."""
|
||||
return sorted(SUPPORTED_AGENTS.keys())
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Type definitions matching the Elydora backend API contract.
|
||||
|
||||
All types use TypedDict for structural typing, compatible with Python 3.9+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Literal, TypedDict
|
||||
else:
|
||||
from typing_extensions import Literal, TypedDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums (as Literal types)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AgentStatus = Literal["active", "frozen", "revoked"]
|
||||
KeyStatus = Literal["active", "retired", "revoked"]
|
||||
ExportStatus = Literal["queued", "running", "done", "failed"]
|
||||
RbacRole = Literal[
|
||||
"org_owner",
|
||||
"security_admin",
|
||||
"compliance_auditor",
|
||||
"readonly_investigator",
|
||||
"integration_engineer",
|
||||
]
|
||||
ErrorCode = Literal[
|
||||
"INVALID_SIGNATURE",
|
||||
"UNKNOWN_AGENT",
|
||||
"KEY_REVOKED",
|
||||
"AGENT_FROZEN",
|
||||
"TTL_EXPIRED",
|
||||
"REPLAY_DETECTED",
|
||||
"PREV_HASH_MISMATCH",
|
||||
"PAYLOAD_TOO_LARGE",
|
||||
"RATE_LIMITED",
|
||||
"INTERNAL_ERROR",
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"VALIDATION_ERROR",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Agent(TypedDict):
|
||||
agent_id: str
|
||||
org_id: str
|
||||
display_name: str
|
||||
responsible_entity: str
|
||||
status: AgentStatus
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class AgentKey(TypedDict):
|
||||
kid: str
|
||||
agent_id: str
|
||||
public_key: str
|
||||
algorithm: Literal["ed25519"]
|
||||
status: KeyStatus
|
||||
created_at: int
|
||||
retired_at: Optional[int]
|
||||
|
||||
|
||||
class Operation(TypedDict):
|
||||
operation_id: str
|
||||
org_id: str
|
||||
agent_id: str
|
||||
seq_no: int
|
||||
operation_type: str
|
||||
issued_at: int
|
||||
ttl_ms: int
|
||||
nonce: str
|
||||
subject: str
|
||||
action: str
|
||||
payload_hash: str
|
||||
prev_chain_hash: str
|
||||
chain_hash: str
|
||||
agent_pubkey_kid: str
|
||||
signature: str
|
||||
r2_payload_key: Optional[str]
|
||||
created_at: int
|
||||
|
||||
|
||||
class Receipt(TypedDict):
|
||||
receipt_id: str
|
||||
operation_id: str
|
||||
r2_receipt_key: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class Epoch(TypedDict):
|
||||
epoch_id: str
|
||||
org_id: str
|
||||
start_time: int
|
||||
end_time: int
|
||||
root_hash: str
|
||||
leaf_count: int
|
||||
r2_epoch_key: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class Organization(TypedDict):
|
||||
org_id: str
|
||||
name: str
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class User(TypedDict):
|
||||
user_id: str
|
||||
org_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: RbacRole
|
||||
status: Literal["active", "suspended"]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class Export(TypedDict):
|
||||
export_id: str
|
||||
org_id: str
|
||||
status: ExportStatus
|
||||
query_params: str
|
||||
r2_export_key: Optional[str]
|
||||
created_at: int
|
||||
completed_at: Optional[int]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EOR(TypedDict):
|
||||
"""Elydora Operation Record."""
|
||||
op_version: Literal["1.0"]
|
||||
operation_id: str
|
||||
org_id: str
|
||||
agent_id: str
|
||||
issued_at: int
|
||||
ttl_ms: int
|
||||
nonce: str
|
||||
operation_type: str
|
||||
subject: Dict[str, Any]
|
||||
action: Dict[str, Any]
|
||||
payload: Any # Dict[str, Any] | str | None
|
||||
payload_hash: str
|
||||
prev_chain_hash: str
|
||||
agent_pubkey_kid: str
|
||||
signature: str
|
||||
|
||||
|
||||
class EAR(TypedDict):
|
||||
"""Elydora Acknowledgment Receipt."""
|
||||
receipt_version: str
|
||||
receipt_id: str
|
||||
operation_id: str
|
||||
org_id: str
|
||||
agent_id: str
|
||||
server_received_at: int
|
||||
seq_no: int
|
||||
chain_hash: str
|
||||
queue_message_id: str
|
||||
receipt_hash: str
|
||||
elydora_kid: str
|
||||
elydora_signature: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API request types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegisterAgentKeyParam(TypedDict):
|
||||
kid: str
|
||||
public_key: str
|
||||
algorithm: Literal["ed25519"]
|
||||
|
||||
|
||||
class RegisterAgentRequest(TypedDict, total=False):
|
||||
agent_id: str
|
||||
display_name: str
|
||||
responsible_entity: str
|
||||
keys: List[RegisterAgentKeyParam]
|
||||
|
||||
|
||||
class AuditQueryParams(TypedDict, total=False):
|
||||
org_id: str
|
||||
agent_id: str
|
||||
operation_type: str
|
||||
start_time: int
|
||||
end_time: int
|
||||
cursor: str
|
||||
limit: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API response types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegisterAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
keys: List[AgentKey]
|
||||
|
||||
|
||||
class GetAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
keys: List[AgentKey]
|
||||
|
||||
|
||||
class SubmitOperationResponse(TypedDict):
|
||||
receipt: EAR
|
||||
|
||||
|
||||
class GetOperationResponse(TypedDict, total=False):
|
||||
operation: Operation
|
||||
receipt: Receipt
|
||||
|
||||
|
||||
class VerifyChecks(TypedDict, total=False):
|
||||
signature: bool
|
||||
chain: bool
|
||||
receipt: bool
|
||||
merkle: bool
|
||||
|
||||
|
||||
class VerifyOperationResponse(TypedDict, total=False):
|
||||
valid: bool
|
||||
checks: VerifyChecks
|
||||
errors: List[str]
|
||||
|
||||
|
||||
class AuditQueryResponse(TypedDict):
|
||||
operations: List[Operation]
|
||||
cursor: Optional[str]
|
||||
total_count: int
|
||||
|
||||
|
||||
class GetEpochResponse(TypedDict, total=False):
|
||||
epoch: Epoch
|
||||
anchor: Dict[str, Any]
|
||||
|
||||
|
||||
class ListEpochsResponse(TypedDict):
|
||||
epochs: List[Epoch]
|
||||
|
||||
|
||||
class CreateExportResponse(TypedDict):
|
||||
export: Export
|
||||
|
||||
|
||||
class GetExportResponse(TypedDict, total=False):
|
||||
export: Export
|
||||
download_url: str
|
||||
|
||||
|
||||
class ListExportsResponse(TypedDict):
|
||||
exports: List[Export]
|
||||
|
||||
|
||||
class JWK(TypedDict, total=False):
|
||||
kty: str
|
||||
crv: str
|
||||
x: str
|
||||
kid: str
|
||||
use: str
|
||||
alg: str
|
||||
|
||||
|
||||
class JWKSResponse(TypedDict):
|
||||
keys: List[JWK]
|
||||
|
||||
|
||||
class AuthRegisterResponse(TypedDict):
|
||||
user: User
|
||||
organization: Organization
|
||||
token: str
|
||||
|
||||
|
||||
class AuthLoginResponse(TypedDict):
|
||||
user: User
|
||||
token: str
|
||||
|
||||
|
||||
class ListAgentsResponse(TypedDict):
|
||||
agents: List[Agent]
|
||||
|
||||
|
||||
class DeleteAgentResponse(TypedDict):
|
||||
deleted: bool
|
||||
|
||||
|
||||
class GetMeResponse(TypedDict):
|
||||
user: User
|
||||
|
||||
|
||||
class IssueTokenResponse(TypedDict):
|
||||
token: str
|
||||
expires_at: Optional[int]
|
||||
|
||||
|
||||
class HealthResponse(TypedDict):
|
||||
status: str
|
||||
version: str
|
||||
protocol_version: str
|
||||
timestamp: int
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Utility helpers: UUIDv7, nonce generation, base64url encoding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
|
||||
def base64url_encode(data: bytes) -> str:
|
||||
"""Encode bytes to base64url string with no padding."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def base64url_decode(s: str) -> bytes:
|
||||
"""Decode a base64url string (with or without padding) to bytes."""
|
||||
s = s.replace("-", "+").replace("_", "/")
|
||||
padding = 4 - len(s) % 4
|
||||
if padding != 4:
|
||||
s += "=" * padding
|
||||
return base64.b64decode(s)
|
||||
|
||||
|
||||
def generate_nonce() -> str:
|
||||
"""Generate a 16-byte random nonce, base64url encoded."""
|
||||
return base64url_encode(os.urandom(16))
|
||||
|
||||
|
||||
def generate_uuidv7() -> str:
|
||||
"""Generate a UUIDv7 (time-ordered, random) as a string.
|
||||
|
||||
Layout (RFC 9562):
|
||||
- 48-bit Unix timestamp in milliseconds
|
||||
- 4-bit version (0b0111)
|
||||
- 12-bit random
|
||||
- 2-bit variant (0b10)
|
||||
- 62-bit random
|
||||
"""
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
|
||||
# 48-bit timestamp
|
||||
ts_bytes = struct.pack(">Q", timestamp_ms)[2:] # last 6 bytes of 8-byte big-endian
|
||||
|
||||
# 2 bytes: version (4 bits = 0x7) + 12 random bits
|
||||
rand_a = struct.unpack(">H", os.urandom(2))[0]
|
||||
rand_a = (rand_a & 0x0FFF) | 0x7000 # set version nibble
|
||||
|
||||
# 8 bytes: variant (2 bits = 0b10) + 62 random bits
|
||||
rand_b = bytearray(os.urandom(8))
|
||||
rand_b[0] = (rand_b[0] & 0x3F) | 0x80 # set variant bits
|
||||
|
||||
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
|
||||
|
||||
hex_str = uuid_bytes.hex()
|
||||
return (
|
||||
f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-"
|
||||
f"{hex_str[16:20]}-{hex_str[20:32]}"
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "elydora"
|
||||
version = "1.1.0"
|
||||
description = "Python SDK for the Elydora tamper-evident audit platform"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.28.0",
|
||||
"aiohttp>=3.8.0",
|
||||
"cryptography>=41.0.0",
|
||||
"typing_extensions>=4.0.0;python_version<'3.11'",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
elydora = "elydora.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"mypy>=1.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["elydora*"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
Reference in new issue
Block a user