commit 8916506ebc04656af5ed963190ce213d82c20882 Author: ZacharyZhang Date: Sat Feb 28 18:03:11 2026 -0500 Initial commit: Elydora Go SDK Go SDK for Elydora agent operations, cryptographic signing, and audit trail integration. Co-Authored-By: Claude Opus 4.6 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() +}