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:
commit
8916506ebc
26 files changed
+3381
No files matched your search
+26
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user