Initial commit: Elydora Go SDK

Go SDK for Elydora agent operations, cryptographic signing, and audit trail integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-02-28 18:03:11 -05:00
commit 8916506ebc
26 files changed
+3381

No files matched your search

+26
View File
@@ -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
+64
View File
@@ -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)
+36
View File
@@ -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)
}
+44
View File
@@ -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
}
+124
View File
@@ -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),
}
}
+178
View File
@@ -0,0 +1,178 @@
package elydora
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
defaultBaseURL = "https://api.elydora.com"
defaultTTLMs = 30000
defaultMaxRetries = 3
)
// Config holds the configuration for creating a new Client.
type Config struct {
OrgID string
AgentID string
PrivateKey string // base64url-encoded 32-byte Ed25519 seed
BaseURL string // defaults to "https://api.elydora.com"
TTLMs int // defaults to 30000
MaxRetries int // defaults to 3
Token string // JWT token for authenticated requests
}
// Client is the Elydora SDK client.
type Client struct {
orgID string
agentID string
privateKey string
baseURL string
ttlMs int
maxRetries int
token string
httpClient *http.Client
}
// NewClient creates a new Elydora Client with the given configuration.
func NewClient(cfg *Config) (*Client, error) {
if cfg == nil {
return nil, fmt.Errorf("elydora: config must not be nil")
}
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
ttlMs := cfg.TTLMs
if ttlMs <= 0 {
ttlMs = defaultTTLMs
}
maxRetries := cfg.MaxRetries
if maxRetries <= 0 {
maxRetries = defaultMaxRetries
}
return &Client{
orgID: cfg.OrgID,
agentID: cfg.AgentID,
privateKey: cfg.PrivateKey,
baseURL: baseURL,
ttlMs: ttlMs,
maxRetries: maxRetries,
token: cfg.Token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// doRequest executes an HTTP request with retries and error handling.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
u := c.baseURL + path
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("elydora: marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// Reset body reader for retry
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
}
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
}
req, err := http.NewRequest(method, u, bodyReader)
if err != nil {
return fmt.Errorf("elydora: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("elydora: http request: %w", err)
continue
}
respBody, err := io.ReadAll(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)
}
+243
View File
@@ -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 <command> [flags]
Commands:
install Install Elydora audit hook for an agent
uninstall Remove Elydora audit hook for an agent
status Show installation status for an agent (or all agents)
agents List all supported agents
version Print version
help Show this help
Run "elydora <command> -h" for details on each command.
`)
}
// ---------------------------------------------------------------------------
// install
// ---------------------------------------------------------------------------
func cmdInstall(args []string) {
fs := flag.NewFlagSet("install", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.")
orgID := fs.String("org-id", "", "Organization ID (required)")
agentID := fs.String("agent-id", "", "Agent ID (required)")
privateKey := fs.String("private-key", "", "Base64url-encoded Ed25519 private key seed (required)")
kid := fs.String("kid", "", "Key ID (defaults to <agent-id>-key-1)")
token := fs.String("token", "", "JWT token for authenticated requests (optional)")
baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL")
fs.Parse(args)
if *agent == "" || *orgID == "" || *agentID == "" || *privateKey == "" {
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, --agent-id, and --private-key are required.")
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
}
if *kid == "" {
*kid = *agentID + "-key-1"
}
plugin := plugins.NewPlugin(*agent)
if plugin == nil {
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
os.Exit(1)
}
// Generate guard script (PreToolUse — freeze enforcement)
guardScriptPath, err := guardScriptPathForAgent(*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/<agent>-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
}
+212
View File
@@ -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
}
+31
View File
@@ -0,0 +1,31 @@
package plugins
// InstallConfig holds the configuration needed to install an agent plugin.
type InstallConfig struct {
AgentName string
OrgID string
AgentID string
PrivateKey string
KID string
Token string
BaseURL string
HookScript string // absolute path to the generated hook script
GuardScriptPath string // absolute path to the generated guard script (PreToolUse)
}
// PluginStatus describes the current state of a plugin installation.
type PluginStatus struct {
Installed bool
AgentName string
DisplayName string
HookConfigured bool
HookScriptExists bool
ConfigPath string
}
// AgentPlugin is the interface that every agent integration must implement.
type AgentPlugin interface {
Install(config InstallConfig) error
Uninstall() error
Status() (PluginStatus, error)
}
+247
View File
@@ -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
}
+225
View File
@@ -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
}
+212
View File
@@ -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
}
+465
View File
@@ -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/<name>.json and ~/.elydora/agents/<name>.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)
}
+72
View File
@@ -0,0 +1,72 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// expandHome replaces a leading ~ with the user's home directory.
func expandHome(p string) (string, error) {
if !strings.HasPrefix(p, "~") {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, p[1:]), nil
}
// readJSONFile reads a JSON file into a map. Returns an empty map if the file does not exist.
func readJSONFile(path string) (map[string]interface{}, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return result, nil
}
// writeJSONFile writes a map to a JSON file with indentation.
func writeJSONFile(path string, data map[string]interface{}) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal json: %w", err)
}
encoded = append(encoded, '\n')
if err := os.WriteFile(path, encoded, 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// hookScriptPath returns the default path for the hook script inside ~/.elydora/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
}
+138
View File
@@ -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
}
+161
View File
@@ -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
}
+39
View File
@@ -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
}
}
+257
View File
@@ -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))
}
+21
View File
@@ -0,0 +1,21 @@
package elydora
import "fmt"
// ListEpochs lists all epochs for the organization.
func (c *Client) ListEpochs() (*ListEpochsResponse, error) {
var result ListEpochsResponse
if err := c.doGet("/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
}
+26
View File
@@ -0,0 +1,26 @@
package elydora
import "fmt"
// ElydoraError represents an error returned by the Elydora API.
type ElydoraError struct {
StatusCode int
Code ErrorCode `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Details map[string]interface{} `json:"details,omitempty"`
}
func (e *ElydoraError) Error() string {
return fmt.Sprintf("elydora: %s (code=%s, status=%d, request_id=%s)", e.Message, e.Code, e.StatusCode, e.RequestID)
}
// errorResponse is the wire format for API error responses.
type errorResponse struct {
Error struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Details map[string]interface{} `json:"details,omitempty"`
} `json:"error"`
}
+30
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/elydora/sdk-go
go 1.21
+10
View File
@@ -0,0 +1,10 @@
package elydora
// GetJWKS retrieves the JSON Web Key Set from the Elydora API.
func (c *Client) GetJWKS() (*JWKSResponse, error) {
var result JWKSResponse
if err := c.doGet("/.well-known/jwks.json", &result); err != nil {
return nil, err
}
return &result, nil
}
+91
View File
@@ -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
}
+352
View File
@@ -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
}
+74
View File
@@ -0,0 +1,74 @@
package elydora
import (
"crypto/rand"
"encoding/base64"
"encoding/binary"
"io"
"time"
)
// base64urlEncode encodes bytes to base64url without padding (RFC 4648 section 5).
func base64urlEncode(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
// base64urlDecode decodes a base64url string (with or without padding).
func base64urlDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
// generateNonce generates a 16-byte cryptographically random nonce, base64url encoded.
func generateNonce() (string, error) {
b := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", err
}
return base64urlEncode(b), nil
}
// generateUUIDv7 generates a UUIDv7 (time-ordered) as a string.
func generateUUIDv7() (string, error) {
now := time.Now()
ms := now.UnixMilli()
var uuid [16]byte
// Timestamp: 48 bits in big-endian
binary.BigEndian.PutUint16(uuid[0:2], uint16(ms>>32))
binary.BigEndian.PutUint16(uuid[2:4], uint16(ms>>16))
binary.BigEndian.PutUint16(uuid[4:6], uint16(ms))
// Fill the rest with random data
if _, err := io.ReadFull(rand.Reader, uuid[6:]); err != nil {
return "", err
}
// Set version 7 (bits 48-51)
uuid[6] = (uuid[6] & 0x0F) | 0x70
// Set variant 10 (bits 64-65)
uuid[8] = (uuid[8] & 0x3F) | 0x80
return formatUUID(uuid), nil
}
// formatUUID formats a 16-byte UUID as a canonical string.
func formatUUID(uuid [16]byte) string {
buf := make([]byte, 36)
hex := "0123456789abcdef"
positions := [16]int{0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34}
for i, p := range positions {
buf[p] = hex[uuid[i]>>4]
buf[p+1] = hex[uuid[i]&0x0F]
}
buf[8] = '-'
buf[13] = '-'
buf[18] = '-'
buf[23] = '-'
return string(buf)
}
// nowUnixMs returns the current time as Unix milliseconds.
func nowUnixMs() int64 {
return time.Now().UnixMilli()
}