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:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-02 13:41:42 -05:00
1 parent 15547a8c35
commit e390e68594
207 files changed
+32790 -63

No files matched your search

+33
View File
@@ -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
+280
View File
@@ -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
+64
View File
@@ -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
}
+10
View File
@@ -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
View File
@@ -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
}
+178
View File
@@ -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
}
+293
View File
@@ -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
}
+31
View File
@@ -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)
}
+281
View File
@@ -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
}
+261
View File
@@ -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 ""
}
+237
View File
@@ -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
}
+214
View File
@@ -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
}
+470
View File
@@ -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)
}
+72
View File
@@ -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
}
+224
View File
@@ -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
}
+143
View File
@@ -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
}
+222
View File
@@ -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
}
+181
View File
@@ -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
}
+45
View File
@@ -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
}
}
+219
View File
@@ -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))
}
+21
View File
@@ -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
}
+26
View File
@@ -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"`
}
+79
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/Elydora-Infrastructure/Elydora-Go-SDK
go 1.21
+62
View File
@@ -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),
}
}
+10
View File
@@ -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
}
+95
View File
@@ -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
}
+384
View File
@@ -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
}
+74
View File
@@ -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()
}
+7
View File
@@ -0,0 +1,7 @@
{
"version": 1,
"hooks": {
"preToolUse": [],
"postToolUse": []
}
}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
*.log
.DS_Store
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
src/
tsconfig.json
.gitignore
node_modules/
*.tsbuildinfo
+268
View File
@@ -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
+51
View File
@@ -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"
}
}
}
+26
View File
@@ -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"
}
}
+11
View File
@@ -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');
}
+325
View File
@@ -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);
});
+428
View File
@@ -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));
}
+157
View File
@@ -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));
+23
View File
@@ -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;
}
}
+71
View File
@@ -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';
+26
View File
@@ -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>;
}
+171
View File
@@ -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,
};
},
};
+164
View File
@@ -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,
};
},
};
+151
View File
@@ -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,
};
},
};
+169
View File
@@ -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,
};
},
};
+401
View File
@@ -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();
`;
}
+173
View File
@@ -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,
};
},
};
+94
View File
@@ -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,
};
},
};
+173
View File
@@ -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,
};
},
};
+134
View File
@@ -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,
};
},
};
+16
View File
@@ -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' }],
]);
+330
View File
@@ -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;
}
+68
View File
@@ -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');
}
+20
View File
@@ -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"]
}
+20
View File
@@ -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
+307
View File
@@ -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
+96
View File
@@ -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",
]
+413
View File
@@ -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)
+217
View File
@@ -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()
+401
View File
@@ -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)
+167
View File
@@ -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"))
+30
View File
@@ -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})"
)
+1
View File
@@ -0,0 +1 @@
"""Elydora agent plugins for AI coding tools."""
+45
View File
@@ -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."""
+211
View File
@@ -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")
+200
View File
@@ -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")
+182
View File
@@ -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")
+207
View File
@@ -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()
'''
+211
View File
@@ -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")
+146
View File
@@ -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)
+209
View File
@@ -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")
+308
View File
@@ -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)
+54
View File
@@ -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())
+317
View File
@@ -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
+59
View File
@@ -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]}"
)
+42
View File
@@ -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*"]
+3
View File
@@ -0,0 +1,3 @@
from setuptools import setup
setup()