From 8916506ebc04656af5ed963190ce213d82c20882 Mon Sep 17 00:00:00 2001 From: ZacharyZhang Date: Sat, 28 Feb 2026 18:03:11 -0500 Subject: [PATCH] Initial commit: Elydora Go SDK Go SDK for Elydora agent operations, cryptographic signing, and audit trail integration. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 26 ++ README.md | 64 ++++ agents.go | 36 +++ audit.go | 44 +++ auth.go | 124 ++++++++ client.go | 178 +++++++++++ cmd/elydora/main.go | 243 +++++++++++++++ cmd/elydora/plugins/augment.go | 212 +++++++++++++ cmd/elydora/plugins/base.go | 31 ++ cmd/elydora/plugins/claudecode.go | 247 +++++++++++++++ cmd/elydora/plugins/cursor.go | 225 ++++++++++++++ cmd/elydora/plugins/gemini.go | 212 +++++++++++++ cmd/elydora/plugins/hooktemplate.go | 465 ++++++++++++++++++++++++++++ cmd/elydora/plugins/jsonutil.go | 72 +++++ cmd/elydora/plugins/kiro.go | 138 +++++++++ cmd/elydora/plugins/opencode.go | 161 ++++++++++ cmd/elydora/plugins/registry.go | 39 +++ crypto.go | 257 +++++++++++++++ epochs.go | 21 ++ errors.go | 26 ++ exports.go | 30 ++ go.mod | 3 + jwks.go | 10 + operations.go | 91 ++++++ types.go | 352 +++++++++++++++++++++ utils.go | 74 +++++ 26 files changed, 3381 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agents.go create mode 100644 audit.go create mode 100644 auth.go create mode 100644 client.go create mode 100644 cmd/elydora/main.go create mode 100644 cmd/elydora/plugins/augment.go create mode 100644 cmd/elydora/plugins/base.go create mode 100644 cmd/elydora/plugins/claudecode.go create mode 100644 cmd/elydora/plugins/cursor.go create mode 100644 cmd/elydora/plugins/gemini.go create mode 100644 cmd/elydora/plugins/hooktemplate.go create mode 100644 cmd/elydora/plugins/jsonutil.go create mode 100644 cmd/elydora/plugins/kiro.go create mode 100644 cmd/elydora/plugins/opencode.go create mode 100644 cmd/elydora/plugins/registry.go create mode 100644 crypto.go create mode 100644 epochs.go create mode 100644 errors.go create mode 100644 exports.go create mode 100644 go.mod create mode 100644 jwks.go create mode 100644 operations.go create mode 100644 types.go create mode 100644 utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8224eea --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..334786c --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Elydora Go SDK + +Official Go SDK for the Elydora AI audit trail platform. + +## Installation + +```bash +go get github.com/elydora/sdk-go +``` + +## Quick Start + +```go +package main + +import ( + "fmt" + "log" + + elydora "github.com/elydora/sdk-go" +) + +func main() { + // Login + 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: "org-123", + AgentID: "agent-456", + 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: "inference", + Subject: map[string]interface{}{"model": "gpt-4"}, + Action: map[string]interface{}{"type": "completion"}, + Payload: map[string]interface{}{"prompt": "Hello"}, + KID: "agent-456-key-1", + }) + 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) +} +``` + +## Requirements + +- Go 1.21 or later +- No third-party dependencies (stdlib only) diff --git a/agents.go b/agents.go new file mode 100644 index 0000000..bf9a623 --- /dev/null +++ b/agents.go @@ -0,0 +1,36 @@ +package elydora + +import "fmt" + +// RegisterAgent registers a new AI agent within the organization. +func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) { + var result RegisterAgentResponse + if err := c.doPost("/agents", 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("/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("/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("/agents/%s/revoke-key", agentID), &RevokeAgentRequest{ + KID: kid, + Reason: reason, + }, nil) +} diff --git a/audit.go b/audit.go new file mode 100644 index 0000000..3ec25e0 --- /dev/null +++ b/audit.go @@ -0,0 +1,44 @@ +package elydora + +import ( + "fmt" + "net/url" + "strconv" +) + +// QueryAudit queries the audit log with the given parameters. +func (c *Client) QueryAudit(params *AuditQueryRequest) (*AuditQueryResponse, error) { + q := url.Values{} + if params.OrgID != "" { + q.Set("org_id", params.OrgID) + } + if params.AgentID != "" { + q.Set("agent_id", params.AgentID) + } + if params.OperationType != "" { + q.Set("operation_type", params.OperationType) + } + if params.StartTime != nil { + q.Set("start_time", strconv.FormatInt(*params.StartTime, 10)) + } + if params.EndTime != nil { + q.Set("end_time", strconv.FormatInt(*params.EndTime, 10)) + } + if params.Cursor != "" { + q.Set("cursor", params.Cursor) + } + if params.Limit != nil { + q.Set("limit", strconv.Itoa(*params.Limit)) + } + + path := "/audit" + if encoded := q.Encode(); encoded != "" { + path = fmt.Sprintf("/audit?%s", encoded) + } + + var result AuditQueryResponse + if err := c.doGet(path, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..3e73800 --- /dev/null +++ b/auth.go @@ -0,0 +1,124 @@ +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, "/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, "/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(resp.Body) + 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), + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..625e093 --- /dev/null +++ b/client.go @@ -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(resp.Body) + 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) +} + +// doPut performs a PUT request. +func (c *Client) doPut(path string, body interface{}, result interface{}) error { + return c.doRequest(http.MethodPut, path, body, result) +} diff --git a/cmd/elydora/main.go b/cmd/elydora/main.go new file mode 100644 index 0000000..84a3721 --- /dev/null +++ b/cmd/elydora/main.go @@ -0,0 +1,243 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/elydora/sdk-go/cmd/elydora/plugins" +) + +const version = "1.0.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 [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 -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 -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(*agent) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + guardScript := plugins.GenerateGuardScript(*agent) + 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)") + + 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) + } + + if err := plugin.Uninstall(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Also remove the guard script + guardPath, _ := guardScriptPathForAgent(*agent) + if guardPath != "" { + os.Remove(guardPath) + } +} + +// --------------------------------------------------------------------------- +// 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 + 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/hooks/-guard.js. +func guardScriptPathForAgent(agentName string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".elydora", "hooks", agentName+"-guard.js"), nil +} diff --git a/cmd/elydora/plugins/augment.go b/cmd/elydora/plugins/augment.go new file mode 100644 index 0000000..2f107c7 --- /dev/null +++ b/cmd/elydora/plugins/augment.go @@ -0,0 +1,212 @@ +package plugins + +import ( + "fmt" + "os" +) + +// AugmentPlugin manages the Elydora audit hook for Augment Code. +// It merges a PostToolUse hook into ~/.augment/settings.json. +type AugmentPlugin struct{} + +func (p *AugmentPlugin) Install(config InstallConfig) error { + scriptPath, err := hookScriptPath("augment") + 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("augment") + if err != nil { + return err + } + } + + configDir, err := expandHome("~/.augment") + if err != nil { + return err + } + configPath := 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 Augment Code at %s\n", configPath) + return nil +} + +func (p *AugmentPlugin) Uninstall() error { + configDir, err := expandHome("~/.augment") + if err != nil { + return err + } + configPath := configDir + "/settings.json" + + settings, err := readJSONFile(configPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks == nil { + fmt.Println("No Augment 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 + } + + scriptPath, _ := hookScriptPath("augment") + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath("augment") + if gPath != "" { + os.Remove(gPath) + } + fmt.Println("Uninstalled Elydora hook for Augment Code.") + return nil +} + +func (p *AugmentPlugin) Status() (PluginStatus, error) { + scriptPath, err := hookScriptPath("augment") + if err != nil { + return PluginStatus{}, err + } + + configDir, err := expandHome("~/.augment") + if err != nil { + return PluginStatus{}, err + } + configPath := configDir + "/settings.json" + + status := PluginStatus{ + AgentName: "augment", + DisplayName: "Augment Code", + ConfigPath: configPath, + } + + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + + 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 + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} diff --git a/cmd/elydora/plugins/base.go b/cmd/elydora/plugins/base.go new file mode 100644 index 0000000..56acbaf --- /dev/null +++ b/cmd/elydora/plugins/base.go @@ -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() error + Status() (PluginStatus, error) +} diff --git a/cmd/elydora/plugins/claudecode.go b/cmd/elydora/plugins/claudecode.go new file mode 100644 index 0000000..0e55864 --- /dev/null +++ b/cmd/elydora/plugins/claudecode.go @@ -0,0 +1,247 @@ +package plugins + +import ( + "fmt" + "os" + "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("claudecode") + 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("claudecode") + if err != nil { + return err + } + } + + configDir, err := expandHome("~/.claude") + if err != nil { + return err + } + configPath := 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() error { + configDir, err := expandHome("~/.claude") + if err != nil { + return err + } + configPath := 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 + } + + scriptPath, _ := hookScriptPath("claudecode") + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath("claudecode") + if gPath != "" { + os.Remove(gPath) + } + fmt.Println("Uninstalled Elydora hook for Claude Code.") + return nil +} + +func (p *ClaudeCodePlugin) Status() (PluginStatus, error) { + scriptPath, err := hookScriptPath("claudecode") + if err != nil { + return PluginStatus{}, err + } + + configDir, err := expandHome("~/.claude") + if err != nil { + return PluginStatus{}, err + } + configPath := configDir + "/settings.json" + + status := PluginStatus{ + AgentName: "claudecode", + DisplayName: "Claude Code", + ConfigPath: configPath, + } + + // Check if hook script exists + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + + // Check if hook is configured in settings + 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 + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} + +// 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 +} diff --git a/cmd/elydora/plugins/cursor.go b/cmd/elydora/plugins/cursor.go new file mode 100644 index 0000000..b1e131f --- /dev/null +++ b/cmd/elydora/plugins/cursor.go @@ -0,0 +1,225 @@ +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("cursor") + 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("cursor") + 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() error { + scriptPath, err := hookScriptPath("cursor") + 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 { + 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 + } + + os.Remove(scriptPath) + gPath, _ := guardScriptPath("cursor") + if gPath != "" { + os.Remove(gPath) + } + fmt.Println("Uninstalled Elydora hook for Cursor.") + return nil +} + +func (p *CursorPlugin) Status() (PluginStatus, error) { + scriptPath, err := hookScriptPath("cursor") + if err != nil { + return PluginStatus{}, err + } + + configPath, err := p.configPath() + if err != nil { + return PluginStatus{}, err + } + + status := PluginStatus{ + AgentName: "cursor", + DisplayName: "Cursor", + ConfigPath: configPath, + } + + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + + 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 + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} + +// 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 +} diff --git a/cmd/elydora/plugins/gemini.go b/cmd/elydora/plugins/gemini.go new file mode 100644 index 0000000..2276ed4 --- /dev/null +++ b/cmd/elydora/plugins/gemini.go @@ -0,0 +1,212 @@ +package plugins + +import ( + "fmt" + "os" +) + +// 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("gemini") + 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("gemini") + if err != nil { + return err + } + } + + configDir, err := expandHome("~/.gemini") + if err != nil { + return err + } + configPath := 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() error { + configDir, err := expandHome("~/.gemini") + if err != nil { + return err + } + configPath := 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 + } + + scriptPath, _ := hookScriptPath("gemini") + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath("gemini") + if gPath != "" { + os.Remove(gPath) + } + fmt.Println("Uninstalled Elydora hook for Gemini CLI.") + return nil +} + +func (p *GeminiPlugin) Status() (PluginStatus, error) { + scriptPath, err := hookScriptPath("gemini") + if err != nil { + return PluginStatus{}, err + } + + configDir, err := expandHome("~/.gemini") + if err != nil { + return PluginStatus{}, err + } + configPath := configDir + "/settings.json" + + status := PluginStatus{ + AgentName: "gemini", + DisplayName: "Gemini CLI", + ConfigPath: configPath, + } + + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + + 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 + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} diff --git a/cmd/elydora/plugins/hooktemplate.go b/cmd/elydora/plugins/hooktemplate.go new file mode 100644 index 0000000..f7911f2 --- /dev/null +++ b/cmd/elydora/plugins/hooktemplate.go @@ -0,0 +1,465 @@ +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/agents/ 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) + 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/agents/.json and ~/.elydora/agents/.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) + } + agentsDir := filepath.Join(home, ".elydora", "agents") + if err := os.MkdirAll(agentsDir, 0755); err != nil { + return fmt.Errorf("create agents 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 +} +`, config.OrgID, config.AgentID, config.KID, baseURL, config.Token) + + configPath := filepath.Join(agentsDir, config.AgentName+".json") + if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil { + return fmt.Errorf("write agent config: %w", err) + } + + keyPath := filepath.Join(agentsDir, config.AgentName+".key") + 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) 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 ELYDORA_DIR = path.join(os.homedir(), '.elydora'); +const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json'); +const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.status.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) +} + +func buildHookScript(agentName 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 ELYDORA_DIR = path.join(os.homedir(), '.elydora'); +const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json'); +const KEY_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.key'); +const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, 'chain-state.json'); +const ERROR_LOG_PATH = path.join(ELYDORA_DIR, '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) +} diff --git a/cmd/elydora/plugins/jsonutil.go b/cmd/elydora/plugins/jsonutil.go new file mode 100644 index 0000000..b2f90d2 --- /dev/null +++ b/cmd/elydora/plugins/jsonutil.go @@ -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/hooks/. +func hookScriptPath(agentName string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".elydora", "hooks", agentName+"-hook.js"), nil +} + +// guardScriptPath returns the default path for the guard script inside ~/.elydora/hooks/. +func guardScriptPath(agentName string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".elydora", "hooks", agentName+"-guard.js"), nil +} diff --git a/cmd/elydora/plugins/kiro.go b/cmd/elydora/plugins/kiro.go new file mode 100644 index 0000000..270086e --- /dev/null +++ b/cmd/elydora/plugins/kiro.go @@ -0,0 +1,138 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// KiroPlugin manages the Elydora audit hook for Kiro. +// It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory. +type KiroPlugin struct{} + +func (p *KiroPlugin) 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 *KiroPlugin) Install(config InstallConfig) error { + scriptPath, err := hookScriptPath("kiro") + 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("kiro") + 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 := buildKiroHookFile(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 at %s\n", hookFile) + return nil +} + +func buildKiroHookFile(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 *KiroPlugin) Uninstall() 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) + } + + scriptPath, _ := hookScriptPath("kiro") + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath("kiro") + if gPath != "" { + os.Remove(gPath) + } + + fmt.Println("Uninstalled Elydora hook for Kiro.") + return nil +} + +func (p *KiroPlugin) Status() (PluginStatus, error) { + scriptPath, err := hookScriptPath("kiro") + if err != nil { + return PluginStatus{}, err + } + + hookFile, err := p.configPath() + if err != nil { + return PluginStatus{}, err + } + + status := PluginStatus{ + AgentName: "kiro", + DisplayName: "Kiro", + ConfigPath: hookFile, + } + + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + + // 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 + } + } + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} diff --git a/cmd/elydora/plugins/opencode.go b/cmd/elydora/plugins/opencode.go new file mode 100644 index 0000000..de0cda3 --- /dev/null +++ b/cmd/elydora/plugins/opencode.go @@ -0,0 +1,161 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" +) + +// 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("opencode") + 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("opencode") + 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() 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) + } + + scriptPath, _ := hookScriptPath("opencode") + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath("opencode") + if gPath != "" { + os.Remove(gPath) + } + + fmt.Println("Uninstalled Elydora hook for OpenCode.") + return nil +} + +func (p *OpenCodePlugin) Status() (PluginStatus, error) { + scriptPath, err := hookScriptPath("opencode") + if err != nil { + return PluginStatus{}, err + } + + pluginFile, err := p.configPath() + if err != nil { + return PluginStatus{}, err + } + + status := PluginStatus{ + AgentName: "opencode", + DisplayName: "OpenCode", + ConfigPath: pluginFile, + } + + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + + if _, err := os.Stat(pluginFile); err == nil { + status.HookConfigured = true + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go new file mode 100644 index 0000000..f2cc49a --- /dev/null +++ b/cmd/elydora/plugins/registry.go @@ -0,0 +1,39 @@ +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"}, + "augment": {Name: "Augment Code", ConfigDir: "~/.augment", ConfigFile: "settings.json"}, + "kiro": {Name: "Kiro", ConfigDir: ".kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"}, + "opencode": {Name: "OpenCode", ConfigDir: ".opencode/plugins", ConfigFile: "elydora-audit.js"}, +} + +// 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 "augment": + return &AugmentPlugin{} + case "kiro": + return &KiroPlugin{} + case "opencode": + return &OpenCodePlugin{} + default: + return nil + } +} diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..54f3623 --- /dev/null +++ b/crypto.go @@ -0,0 +1,257 @@ +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 +} + +// computeChainHash computes the chain hash for an operation. +// Formula: SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + issued_at) +func computeChainHash(prevChainHash, payloadHash, operationID string, issuedAt int64) string { + input := prevChainHash + "|" + payloadHash + "|" + operationID + "|" + fmt.Sprintf("%d", issuedAt) + return sha256Base64url(input) +} + +// 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 +} + +// verifyEd25519 verifies an Ed25519 signature. +func verifyEd25519(publicKeyBase64url string, signatureBase64url string, data []byte) (bool, error) { + pubBytes, err := base64urlDecode(publicKeyBase64url) + if err != nil { + return false, fmt.Errorf("decode public key: %w", err) + } + if len(pubBytes) != ed25519.PublicKeySize { + return false, fmt.Errorf("public key must be %d bytes, got %d", ed25519.PublicKeySize, len(pubBytes)) + } + sigBytes, err := base64urlDecode(signatureBase64url) + if err != nil { + return false, fmt.Errorf("decode signature: %w", err) + } + return ed25519.Verify(ed25519.PublicKey(pubBytes), data, sigBytes), nil +} + +// derivePublicKey derives the Ed25519 public key from a private key seed (base64url). +// Returns the public key as base64url. +func derivePublicKey(privateKeyBase64url string) (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) + pub := key.Public().(ed25519.PublicKey) + return base64urlEncode(pub), 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)) +} diff --git a/epochs.go b/epochs.go new file mode 100644 index 0000000..bf5f549 --- /dev/null +++ b/epochs.go @@ -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("/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("/epochs/%s", epochID), &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..fe4c4ef --- /dev/null +++ b/errors.go @@ -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"` +} diff --git a/exports.go b/exports.go new file mode 100644 index 0000000..ff3412a --- /dev/null +++ b/exports.go @@ -0,0 +1,30 @@ +package elydora + +import "fmt" + +// CreateExport creates a new compliance export job. +func (c *Client) CreateExport(params *CreateExportRequest) (*CreateExportResponse, error) { + var result CreateExportResponse + if err := c.doPost("/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("/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("/exports/%s", exportID), &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a967b0f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/elydora/sdk-go + +go 1.21 diff --git a/jwks.go b/jwks.go new file mode 100644 index 0000000..0b0facd --- /dev/null +++ b/jwks.go @@ -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/jwks.json", &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/operations.go b/operations.go new file mode 100644 index 0000000..176f050 --- /dev/null +++ b/operations.go @@ -0,0 +1,91 @@ +package elydora + +import "fmt" + +// 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 = "GENESIS" + } + + 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("/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("/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.doGet(fmt.Sprintf("/operations/%s/verify", operationID), &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..3a4b746 --- /dev/null +++ b/types.go @@ -0,0 +1,352 @@ +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 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 +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..bfc16ee --- /dev/null +++ b/utils.go @@ -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() +}