Initial commit: Elydora Go SDK

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

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

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/elydora/sdk-go/cmd/elydora/plugins"
)
const version = "1.0.0"
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "install":
cmdInstall(os.Args[2:])
case "uninstall":
cmdUninstall(os.Args[2:])
case "status":
cmdStatus(os.Args[2:])
case "agents":
cmdAgents()
case "version", "--version", "-v":
fmt.Printf("elydora %s\n", version)
case "help", "--help", "-h":
printUsage()
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Fprintf(os.Stderr, `Elydora CLI — agent hook installer for tamper-evident audit logging.
Usage:
elydora <command> [flags]
Commands:
install Install Elydora audit hook for an agent
uninstall Remove Elydora audit hook for an agent
status Show installation status for an agent (or all agents)
agents List all supported agents
version Print version
help Show this help
Run "elydora <command> -h" for details on each command.
`)
}
// ---------------------------------------------------------------------------
// install
// ---------------------------------------------------------------------------
func cmdInstall(args []string) {
fs := flag.NewFlagSet("install", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.")
orgID := fs.String("org-id", "", "Organization ID (required)")
agentID := fs.String("agent-id", "", "Agent ID (required)")
privateKey := fs.String("private-key", "", "Base64url-encoded Ed25519 private key seed (required)")
kid := fs.String("kid", "", "Key ID (defaults to <agent-id>-key-1)")
token := fs.String("token", "", "JWT token for authenticated requests (optional)")
baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL")
fs.Parse(args)
if *agent == "" || *orgID == "" || *agentID == "" || *privateKey == "" {
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, --agent-id, and --private-key are required.")
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
}
if *kid == "" {
*kid = *agentID + "-key-1"
}
plugin := plugins.NewPlugin(*agent)
if plugin == nil {
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
os.Exit(1)
}
// Generate guard script (PreToolUse — freeze enforcement)
guardScriptPath, err := guardScriptPathForAgent(*agent)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
guardScript := plugins.GenerateGuardScript(*agent)
if err := os.MkdirAll(filepath.Dir(guardScriptPath), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating guard script directory: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(guardScriptPath, []byte(guardScript), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err)
os.Exit(1)
}
fmt.Printf(" Guard script: %s\n", guardScriptPath)
config := plugins.InstallConfig{
AgentName: *agent,
OrgID: *orgID,
AgentID: *agentID,
PrivateKey: *privateKey,
KID: *kid,
Token: *token,
BaseURL: *baseURL,
GuardScriptPath: guardScriptPath,
}
if err := plugin.Install(config); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// ---------------------------------------------------------------------------
// uninstall
// ---------------------------------------------------------------------------
func cmdUninstall(args []string) {
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (required)")
fs.Parse(args)
if *agent == "" {
fmt.Fprintln(os.Stderr, "Error: --agent is required.")
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
}
plugin := plugins.NewPlugin(*agent)
if plugin == nil {
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
os.Exit(1)
}
if err := plugin.Uninstall(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Also remove the guard script
guardPath, _ := guardScriptPathForAgent(*agent)
if guardPath != "" {
os.Remove(guardPath)
}
}
// ---------------------------------------------------------------------------
// status
// ---------------------------------------------------------------------------
func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
fs.Parse(args)
if *agent != "" {
showAgentStatus(*agent)
return
}
// Show status for all agents
names := sortedAgentNames()
for _, name := range names {
showAgentStatus(name)
}
}
func showAgentStatus(name string) {
plugin := plugins.NewPlugin(name)
if plugin == nil {
fmt.Fprintf(os.Stderr, "Unknown agent: %s\n", name)
return
}
status, err := plugin.Status()
if err != nil {
fmt.Fprintf(os.Stderr, " %s: error getting status: %v\n", name, err)
return
}
state := "not installed"
if status.Installed {
state = "installed"
} else if status.HookConfigured && !status.HookScriptExists {
state = "configured (hook script missing)"
} else if !status.HookConfigured && status.HookScriptExists {
state = "hook script exists (not configured)"
}
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
}
// ---------------------------------------------------------------------------
// agents
// ---------------------------------------------------------------------------
func cmdAgents() {
fmt.Println("Supported agents:")
fmt.Println()
fmt.Printf(" %-14s %-20s %s\n", "ID", "Name", "Config")
fmt.Printf(" %-14s %-20s %s\n", strings.Repeat("-", 14), strings.Repeat("-", 20), strings.Repeat("-", 30))
names := sortedAgentNames()
for _, name := range names {
entry := plugins.SupportedAgents[name]
configPath := entry.ConfigDir + "/" + entry.ConfigFile
fmt.Printf(" %-14s %-20s %s\n", name, entry.Name, configPath)
}
}
func sortedAgentNames() []string {
names := make([]string, 0, len(plugins.SupportedAgents))
for name := range plugins.SupportedAgents {
names = append(names, name)
}
sort.Strings(names)
return names
}
// guardScriptPathForAgent returns the path to ~/.elydora/hooks/<agent>-guard.js.
func guardScriptPathForAgent(agentName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".elydora", "hooks", agentName+"-guard.js"), nil
}
+212
View File
@@ -0,0 +1,212 @@
package plugins
import (
"fmt"
"os"
)
// AugmentPlugin manages the Elydora audit hook for Augment Code.
// It merges a PostToolUse hook into ~/.augment/settings.json.
type AugmentPlugin struct{}
func (p *AugmentPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath("augment")
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath("augment")
if err != nil {
return err
}
}
configDir, err := expandHome("~/.augment")
if err != nil {
return err
}
configPath := configDir + "/settings.json"
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- PreToolUse (guard — freeze enforcement) ---
preToolUse, _ := hooks["PreToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + guardPath,
},
},
}
preFiltered = append(preFiltered, guardEntry)
hooks["PreToolUse"] = preFiltered
// --- PostToolUse (audit logging) ---
postToolUse, _ := hooks["PostToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + scriptPath,
},
},
}
postFiltered = append(postFiltered, hookEntry)
hooks["PostToolUse"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Augment Code at %s\n", configPath)
return nil
}
func (p *AugmentPlugin) Uninstall() error {
configDir, err := expandHome("~/.augment")
if err != nil {
return err
}
configPath := configDir + "/settings.json"
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Augment Code hooks found.")
return nil
}
// Remove PreToolUse Elydora entries
preToolUse, _ := hooks["PreToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "PreToolUse")
} else {
hooks["PreToolUse"] = preFiltered
}
// Remove PostToolUse Elydora entries
postToolUse, _ := hooks["PostToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "PostToolUse")
} else {
hooks["PostToolUse"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
scriptPath, _ := hookScriptPath("augment")
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath("augment")
if gPath != "" {
os.Remove(gPath)
}
fmt.Println("Uninstalled Elydora hook for Augment Code.")
return nil
}
func (p *AugmentPlugin) Status() (PluginStatus, error) {
scriptPath, err := hookScriptPath("augment")
if err != nil {
return PluginStatus{}, err
}
configDir, err := expandHome("~/.augment")
if err != nil {
return PluginStatus{}, err
}
configPath := configDir + "/settings.json"
status := PluginStatus{
AgentName: "augment",
DisplayName: "Augment Code",
ConfigPath: configPath,
}
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
settings, err := readJSONFile(configPath)
if err != nil {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
status.HookConfigured = preConfigured && postConfigured
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil
}
+31
View File
@@ -0,0 +1,31 @@
package plugins
// InstallConfig holds the configuration needed to install an agent plugin.
type InstallConfig struct {
AgentName string
OrgID string
AgentID string
PrivateKey string
KID string
Token string
BaseURL string
HookScript string // absolute path to the generated hook script
GuardScriptPath string // absolute path to the generated guard script (PreToolUse)
}
// PluginStatus describes the current state of a plugin installation.
type PluginStatus struct {
Installed bool
AgentName string
DisplayName string
HookConfigured bool
HookScriptExists bool
ConfigPath string
}
// AgentPlugin is the interface that every agent integration must implement.
type AgentPlugin interface {
Install(config InstallConfig) error
Uninstall() error
Status() (PluginStatus, error)
}
+247
View File
@@ -0,0 +1,247 @@
package plugins
import (
"fmt"
"os"
"strings"
)
// ClaudeCodePlugin manages the Elydora audit hook for Claude Code.
// It merges a PostToolUse hook into ~/.claude/settings.json.
type ClaudeCodePlugin struct{}
func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath("claudecode")
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath("claudecode")
if err != nil {
return err
}
}
configDir, err := expandHome("~/.claude")
if err != nil {
return err
}
configPath := configDir + "/settings.json"
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- PreToolUse (guard — freeze enforcement) ---
preToolUse, _ := hooks["PreToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + guardPath,
},
},
}
preFiltered = append(preFiltered, guardEntry)
hooks["PreToolUse"] = preFiltered
// --- PostToolUse (audit logging) ---
postToolUse, _ := hooks["PostToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + scriptPath,
},
},
}
postFiltered = append(postFiltered, hookEntry)
hooks["PostToolUse"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Claude Code at %s\n", configPath)
return nil
}
func (p *ClaudeCodePlugin) Uninstall() error {
configDir, err := expandHome("~/.claude")
if err != nil {
return err
}
configPath := configDir + "/settings.json"
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Claude Code hooks found.")
return nil
}
// Remove PreToolUse Elydora entries
preToolUse, _ := hooks["PreToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "PreToolUse")
} else {
hooks["PreToolUse"] = preFiltered
}
// Remove PostToolUse Elydora entries
postToolUse, _ := hooks["PostToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "PostToolUse")
} else {
hooks["PostToolUse"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
scriptPath, _ := hookScriptPath("claudecode")
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath("claudecode")
if gPath != "" {
os.Remove(gPath)
}
fmt.Println("Uninstalled Elydora hook for Claude Code.")
return nil
}
func (p *ClaudeCodePlugin) Status() (PluginStatus, error) {
scriptPath, err := hookScriptPath("claudecode")
if err != nil {
return PluginStatus{}, err
}
configDir, err := expandHome("~/.claude")
if err != nil {
return PluginStatus{}, err
}
configPath := configDir + "/settings.json"
status := PluginStatus{
AgentName: "claudecode",
DisplayName: "Claude Code",
ConfigPath: configPath,
}
// Check if hook script exists
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
// Check if hook is configured in settings
settings, err := readJSONFile(configPath)
if err != nil {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
status.HookConfigured = preConfigured && postConfigured
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil
}
// hasElydoraEntry checks if a hook array (interface{}) contains an Elydora entry.
func hasElydoraEntry(hookArray interface{}) bool {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
return true
}
}
}
return false
}
// isElydoraHookEntry checks if a hook entry (old or new format) is an Elydora hook.
func isElydoraHookEntry(m map[string]interface{}) bool {
// New format: { "matcher": {}, "hooks": [{ "type": "command", "command": "..." }] }
if innerHooks, ok := m["hooks"].([]interface{}); ok {
for _, h := range innerHooks {
if hm, ok := h.(map[string]interface{}); ok {
if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") {
return true
}
}
}
}
// Old format: { "command": "..." }
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
return true
}
return false
}
+225
View File
@@ -0,0 +1,225 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// CursorPlugin manages the Elydora audit hook for Cursor.
// It writes/merges into ~/.cursor/hooks.json using nested settings.hooks.postToolUse[].
type CursorPlugin struct{}
func (p *CursorPlugin) configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".cursor", "hooks.json"), nil
}
func (p *CursorPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath("cursor")
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath("cursor")
if err != nil {
return err
}
}
configPath, err := p.configPath()
if err != nil {
return err
}
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
// Ensure hooks object exists
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- preToolUse (guard — freeze enforcement) ---
preToolUse, _ := hooks["preToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"command": "node " + guardPath,
}
preFiltered = append(preFiltered, guardEntry)
hooks["preToolUse"] = preFiltered
// --- postToolUse (audit logging) ---
postToolUse, _ := hooks["postToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"command": "node " + scriptPath,
}
postFiltered = append(postFiltered, hookEntry)
hooks["postToolUse"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Cursor at %s\n", configPath)
return nil
}
func (p *CursorPlugin) Uninstall() error {
scriptPath, err := hookScriptPath("cursor")
if err != nil {
return err
}
configPath, err := p.configPath()
if err != nil {
return err
}
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Cursor hooks found.")
return nil
}
// Remove preToolUse Elydora entries
preToolUse, _ := hooks["preToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "preToolUse")
} else {
hooks["preToolUse"] = preFiltered
}
// Remove postToolUse Elydora entries
postToolUse, _ := hooks["postToolUse"].([]interface{})
var postFiltered []interface{}
for _, entry := range postToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "postToolUse")
} else {
hooks["postToolUse"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
os.Remove(scriptPath)
gPath, _ := guardScriptPath("cursor")
if gPath != "" {
os.Remove(gPath)
}
fmt.Println("Uninstalled Elydora hook for Cursor.")
return nil
}
func (p *CursorPlugin) Status() (PluginStatus, error) {
scriptPath, err := hookScriptPath("cursor")
if err != nil {
return PluginStatus{}, err
}
configPath, err := p.configPath()
if err != nil {
return PluginStatus{}, err
}
status := PluginStatus{
AgentName: "cursor",
DisplayName: "Cursor",
ConfigPath: configPath,
}
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
settings, err := readJSONFile(configPath)
if err != nil {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasCursorElydoraEntry(hooks["preToolUse"])
postConfigured := hasCursorElydoraEntry(hooks["postToolUse"])
status.HookConfigured = preConfigured && postConfigured
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil
}
// hasCursorElydoraEntry checks if a Cursor hook array (flat format) contains an Elydora entry.
func hasCursorElydoraEntry(hookArray interface{}) bool {
arr, _ := hookArray.([]interface{})
for _, entry := range arr {
if m, ok := entry.(map[string]interface{}); ok {
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
return true
}
}
}
return false
}
+212
View File
@@ -0,0 +1,212 @@
package plugins
import (
"fmt"
"os"
)
// GeminiPlugin manages the Elydora audit hook for Gemini CLI.
// It merges an AfterTool hook into ~/.gemini/settings.json.
type GeminiPlugin struct{}
func (p *GeminiPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath("gemini")
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath("gemini")
if err != nil {
return err
}
}
configDir, err := expandHome("~/.gemini")
if err != nil {
return err
}
configPath := configDir + "/settings.json"
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
// --- BeforeTool (guard — freeze enforcement) ---
beforeTool, _ := hooks["BeforeTool"].([]interface{})
var preFiltered []interface{}
for _, entry := range beforeTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + guardPath,
},
},
}
preFiltered = append(preFiltered, guardEntry)
hooks["BeforeTool"] = preFiltered
// --- AfterTool (audit logging) ---
afterTool, _ := hooks["AfterTool"].([]interface{})
var postFiltered []interface{}
for _, entry := range afterTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + scriptPath,
},
},
}
postFiltered = append(postFiltered, hookEntry)
hooks["AfterTool"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Gemini CLI at %s\n", configPath)
return nil
}
func (p *GeminiPlugin) Uninstall() error {
configDir, err := expandHome("~/.gemini")
if err != nil {
return err
}
configPath := configDir + "/settings.json"
settings, err := readJSONFile(configPath)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Gemini CLI hooks found.")
return nil
}
// Remove BeforeTool Elydora entries
beforeTool, _ := hooks["BeforeTool"].([]interface{})
var preFiltered []interface{}
for _, entry := range beforeTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "BeforeTool")
} else {
hooks["BeforeTool"] = preFiltered
}
// Remove AfterTool Elydora entries
afterTool, _ := hooks["AfterTool"].([]interface{})
var postFiltered []interface{}
for _, entry := range afterTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "AfterTool")
} else {
hooks["AfterTool"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
scriptPath, _ := hookScriptPath("gemini")
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath("gemini")
if gPath != "" {
os.Remove(gPath)
}
fmt.Println("Uninstalled Elydora hook for Gemini CLI.")
return nil
}
func (p *GeminiPlugin) Status() (PluginStatus, error) {
scriptPath, err := hookScriptPath("gemini")
if err != nil {
return PluginStatus{}, err
}
configDir, err := expandHome("~/.gemini")
if err != nil {
return PluginStatus{}, err
}
configPath := configDir + "/settings.json"
status := PluginStatus{
AgentName: "gemini",
DisplayName: "Gemini CLI",
ConfigPath: configPath,
}
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
settings, err := readJSONFile(configPath)
if err != nil {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasElydoraEntry(hooks["BeforeTool"])
postConfigured := hasElydoraEntry(hooks["AfterTool"])
status.HookConfigured = preConfigured && postConfigured
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil
}
+465
View File
@@ -0,0 +1,465 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
// GenerateHookScript writes a self-contained Node.js hook script to disk.
// The script reads config/key from ~/.elydora/agents/ at runtime, persists
// chain state, and performs fire-and-forget EOR submission with inline Ed25519 crypto.
// It always exits 0 so it never blocks the host agent.
func GenerateHookScript(destPath string, config InstallConfig) error {
dir := filepath.Dir(destPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create hook script directory: %w", err)
}
// Write the agent config and key files for runtime reading
if err := writeAgentConfig(config); err != nil {
return fmt.Errorf("write agent config: %w", err)
}
script := buildHookScript(config.AgentName)
if err := os.WriteFile(destPath, []byte(script), 0755); err != nil {
return fmt.Errorf("write hook script: %w", err)
}
return nil
}
// writeAgentConfig persists the agent configuration and private key to
// ~/.elydora/agents/<name>.json and ~/.elydora/agents/<name>.key so that
// the generated hook script can read them at runtime without embedding secrets.
func writeAgentConfig(config InstallConfig) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home directory: %w", err)
}
agentsDir := filepath.Join(home, ".elydora", "agents")
if err := os.MkdirAll(agentsDir, 0755); err != nil {
return fmt.Errorf("create agents directory: %w", err)
}
baseURL := config.BaseURL
if baseURL == "" {
baseURL = "https://api.elydora.com"
}
configJSON := fmt.Sprintf(`{
"org_id": %q,
"agent_id": %q,
"kid": %q,
"base_url": %q,
"token": %q
}
`, config.OrgID, config.AgentID, config.KID, baseURL, config.Token)
configPath := filepath.Join(agentsDir, config.AgentName+".json")
if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil {
return fmt.Errorf("write agent config: %w", err)
}
keyPath := filepath.Join(agentsDir, config.AgentName+".key")
if err := os.WriteFile(keyPath, []byte(config.PrivateKey+"\n"), 0600); err != nil {
return fmt.Errorf("write agent key: %w", err)
}
return nil
}
// GenerateGuardScript returns a self-contained Node.js guard script that checks
// whether the agent is frozen and blocks tool execution if so.
// It caches the agent status locally (60s TTL) to avoid hitting the API on every
// tool call, and fails open if the API is unreachable or config is missing.
func GenerateGuardScript(agentName string) string {
return fmt.Sprintf(`#!/usr/bin/env node
'use strict';
// ---------------------------------------------------------------------------
// Elydora agent guard (PreToolUse) for: %s
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const AGENT_NAME = %q;
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json');
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.status.json');
const CACHE_TTL_MS = 60000; // 60 seconds
// Consume stdin so the parent process doesn't block on a full pipe
process.stdin.resume();
process.stdin.on('data', () => {});
async function main() {
// Read agent config
let config;
try {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return; // Can't read config — fail-open (allow)
}
// Check cached status
try {
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
const cache = JSON.parse(raw);
const age = Date.now() - (cache.cached_at || 0);
if (age < CACHE_TTL_MS) {
if (cache.status === 'frozen') {
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
process.exitCode = 1;
}
return; // Cache is fresh — use cached result
}
} catch {
// No cache or invalid — need to check API
}
// Fetch agent status from API
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
const token = config.token || '';
const agentId = config.agent_id;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const headers = { 'Accept': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
headers: headers,
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
const agentStatus = (data.agent && data.agent.status) || 'active';
// Update cache
try {
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
status: agentStatus,
cached_at: Date.now(),
}), 'utf-8');
} catch {
// Ignore cache write failures
}
if (agentStatus === 'frozen') {
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
process.exitCode = 1;
}
}
} catch {
// API unreachable — fail-open (allow)
}
}
main();
`, agentName, agentName)
}
func buildHookScript(agentName string) string {
return fmt.Sprintf(`#!/usr/bin/env node
'use strict';
// ---------------------------------------------------------------------------
// Self-contained Elydora audit hook for: %s
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const AGENT_NAME = %q;
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json');
const KEY_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.key');
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, 'chain-state.json');
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, 'error.log');
// ---------------------------------------------------------------------------
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
// ---------------------------------------------------------------------------
function base64urlEncode(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buf.toString('base64url');
}
function base64urlDecode(input) {
return Buffer.from(input, 'base64url');
}
function jcsCanonicalise(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
if (typeof value === 'string') return JSON.stringify(value);
if (Array.isArray(value)) {
return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']';
}
if (typeof value === 'object') {
const keys = Object.keys(value).sort();
const pairs = [];
for (const key of keys) {
if (value[key] !== undefined) {
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
}
}
return '{' + pairs.join(',') + '}';
}
return JSON.stringify(value);
}
function sha256Base64url(data) {
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
return base64urlEncode(crypto.createHash('sha256').update(input).digest());
}
function computePayloadHash(payload) {
return sha256Base64url(jcsCanonicalise(payload));
}
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) {
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt;
return sha256Base64url(input);
}
const PKCS8_ED25519_PREFIX = Buffer.from([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05,
0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
function signEd25519(privateKeyBase64url, data) {
const seed = Buffer.from(privateKeyBase64url, 'base64url');
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
const signature = crypto.sign(null, data, keyObject);
return base64urlEncode(signature);
}
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
function uuidv7() {
const now = Date.now();
const random = crypto.randomBytes(10);
const bytes = Buffer.alloc(16);
bytes[0] = (now / 2 ** 40) & 0xff;
bytes[1] = (now / 2 ** 32) & 0xff;
bytes[2] = (now / 2 ** 24) & 0xff;
bytes[3] = (now / 2 ** 16) & 0xff;
bytes[4] = (now / 2 ** 8) & 0xff;
bytes[5] = now & 0xff;
bytes[6] = 0x70 | (random[0] & 0x0f);
bytes[7] = random[1];
bytes[8] = 0x80 | (random[2] & 0x3f);
bytes[9] = random[3];
bytes[10] = random[4];
bytes[11] = random[5];
bytes[12] = random[6];
bytes[13] = random[7];
bytes[14] = random[8];
bytes[15] = random[9];
const hex = bytes.toString('hex');
return (
hex.slice(0, 8) + '-' +
hex.slice(8, 12) + '-' +
hex.slice(12, 16) + '-' +
hex.slice(16, 20) + '-' +
hex.slice(20, 32)
);
}
function generateNonce() {
return base64urlEncode(crypto.randomBytes(16));
}
// ---------------------------------------------------------------------------
// Chain state persistence (atomic writes)
// ---------------------------------------------------------------------------
function readChainState() {
try {
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
const state = JSON.parse(raw);
return state.prev_chain_hash || ZERO_CHAIN_HASH;
} catch {
return ZERO_CHAIN_HASH;
}
}
function writeChainState(chainHash) {
const tmpPath = CHAIN_STATE_PATH + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
try {
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
} catch {
// Windows fallback: rename can fail if target exists
try {
fs.unlinkSync(CHAIN_STATE_PATH);
} catch {}
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
}
}
// ---------------------------------------------------------------------------
// Error logging
// ---------------------------------------------------------------------------
function logError(err) {
try {
const ts = new Date().toISOString();
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n';
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
} catch {
// Silently ignore logging failures
}
}
// ---------------------------------------------------------------------------
// Main hook logic
// ---------------------------------------------------------------------------
async function main() {
try {
// Read JSON from stdin
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
if (!rawInput) return;
let hookData;
try {
hookData = JSON.parse(rawInput);
} catch {
return;
}
// Extract tool_name and tool_input from hook data
// Different agents may pass data in different shapes
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
// Read agent config and private key
let config;
let privateKey;
try {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
} catch (err) {
logError(new Error('Failed to read agent config/key: ' + err.message));
return;
}
const orgId = config.org_id;
const agentId = config.agent_id;
const kid = config.kid;
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
const token = config.token || '';
// Read chain state
const prevChainHash = readChainState();
// Build EOR
const operationId = uuidv7();
const issuedAt = Date.now();
const nonce = generateNonce();
const payload = {
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
};
const payloadHash = computePayloadHash(payload);
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
const eorWithoutSig = {
op_version: '1.0',
operation_id: operationId,
org_id: orgId,
agent_id: agentId,
issued_at: issuedAt,
ttl_ms: 30000,
nonce: nonce,
operation_type: 'ai.tool_use',
subject: { session_id: sessionId },
action: { tool: toolName },
payload: payload,
payload_hash: payloadHash,
prev_chain_hash: prevChainHash,
agent_pubkey_kid: kid,
};
const canonical = jcsCanonicalise(eorWithoutSig);
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
// POST to /v1/operations (5s timeout)
// Only update local chain state on success to prevent desync
const url = baseUrl + '/v1/operations';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(eor),
signal: controller.signal,
});
if (res.ok) {
// Server accepted — safe to advance local chain state
writeChainState(chainHash);
} else if (res.status === 400) {
// Possible chain hash mismatch — try to resync from server response
try {
const errBody = await res.json();
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
// Extract the expected hash from the error message
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
if (match) {
writeChainState(match[1]);
logError(new Error('Chain hash resynced to server: ' + match[1]));
}
}
} catch { /* ignore parse errors */ }
}
} catch {
// Network error — don't advance chain state, will retry with same prev_chain_hash
} finally {
clearTimeout(timeout);
}
} catch (err) {
logError(err);
}
}
main();
`, agentName, agentName)
}
+72
View File
@@ -0,0 +1,72 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// expandHome replaces a leading ~ with the user's home directory.
func expandHome(p string) (string, error) {
if !strings.HasPrefix(p, "~") {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, p[1:]), nil
}
// readJSONFile reads a JSON file into a map. Returns an empty map if the file does not exist.
func readJSONFile(path string) (map[string]interface{}, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return result, nil
}
// writeJSONFile writes a map to a JSON file with indentation.
func writeJSONFile(path string, data map[string]interface{}) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal json: %w", err)
}
encoded = append(encoded, '\n')
if err := os.WriteFile(path, encoded, 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// hookScriptPath returns the default path for the hook script inside ~/.elydora/hooks/.
func hookScriptPath(agentName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".elydora", "hooks", agentName+"-hook.js"), nil
}
// guardScriptPath returns the default path for the guard script inside ~/.elydora/hooks/.
func guardScriptPath(agentName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".elydora", "hooks", agentName+"-guard.js"), nil
}
+138
View File
@@ -0,0 +1,138 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// KiroPlugin manages the Elydora audit hook for Kiro.
// It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory.
type KiroPlugin struct{}
func (p *KiroPlugin) configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".kiro", "hooks", "elydora-audit.kiro.hook"), nil
}
func (p *KiroPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath("kiro")
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath("kiro")
if err != nil {
return err
}
}
hookFile, err := p.configPath()
if err != nil {
return err
}
dir := filepath.Dir(hookFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
content := buildKiroHookFile(scriptPath, guardPath)
if err := os.WriteFile(hookFile, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", hookFile, err)
}
fmt.Printf("Installed Elydora hook for Kiro at %s\n", hookFile)
return nil
}
func buildKiroHookFile(scriptPath, guardPath string) string {
hookConfig := map[string]interface{}{
"name": "Elydora Audit",
"hooks": map[string]interface{}{
"pre_tool_use": map[string]interface{}{
"command": "node " + guardPath,
"timeout_ms": 5000,
},
"post_tool_use": map[string]interface{}{
"command": "node " + scriptPath,
"timeout_ms": 5000,
},
},
}
encoded, _ := json.MarshalIndent(hookConfig, "", " ")
return string(encoded) + "\n"
}
func (p *KiroPlugin) Uninstall() error {
hookFile, err := p.configPath()
if err != nil {
return err
}
if err := os.Remove(hookFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", hookFile, err)
}
scriptPath, _ := hookScriptPath("kiro")
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath("kiro")
if gPath != "" {
os.Remove(gPath)
}
fmt.Println("Uninstalled Elydora hook for Kiro.")
return nil
}
func (p *KiroPlugin) Status() (PluginStatus, error) {
scriptPath, err := hookScriptPath("kiro")
if err != nil {
return PluginStatus{}, err
}
hookFile, err := p.configPath()
if err != nil {
return PluginStatus{}, err
}
status := PluginStatus{
AgentName: "kiro",
DisplayName: "Kiro",
ConfigPath: hookFile,
}
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
// Check if hook file exists and contains both pre_tool_use and post_tool_use
data, err := os.ReadFile(hookFile)
if err == nil {
var config map[string]interface{}
if json.Unmarshal(data, &config) == nil {
hooks, _ := config["hooks"].(map[string]interface{})
if hooks != nil {
_, hasPre := hooks["pre_tool_use"]
_, hasPost := hooks["post_tool_use"]
status.HookConfigured = hasPre && hasPost
}
}
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil
}
+161
View File
@@ -0,0 +1,161 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
// OpenCodePlugin manages the Elydora audit hook for OpenCode.
// It writes .opencode/plugins/elydora-audit.js in the home directory.
type OpenCodePlugin struct{}
func (p *OpenCodePlugin) configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".opencode", "plugins", "elydora-audit.js"), nil
}
func (p *OpenCodePlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath("opencode")
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath("opencode")
if err != nil {
return err
}
}
pluginFile, err := p.configPath()
if err != nil {
return err
}
dir := filepath.Dir(pluginFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
content := buildOpenCodePlugin(scriptPath, guardPath)
if err := os.WriteFile(pluginFile, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", pluginFile, err)
}
fmt.Printf("Installed Elydora hook for OpenCode at %s\n", pluginFile)
return nil
}
func buildOpenCodePlugin(scriptPath, guardPath string) string {
return fmt.Sprintf(`// Elydora Audit Plugin for OpenCode
// Generated by @elydora/sdk — DO NOT EDIT
const { spawn, spawnSync } = require('node:child_process');
module.exports = {
name: 'elydora-audit',
version: '1.0.0',
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
hooks: {
preToolUse(context) {
// Synchronous guard — blocks tool if agent is frozen
try {
const result = spawnSync('node', [%q], {
timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'],
});
if (result.status !== 0) {
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
return { blocked: true, reason: msg };
}
} catch {
// Fail-open — allow if guard can't run
}
},
postToolUse(context) {
try {
const input = JSON.stringify({
tool_name: context.toolName || context.tool_name || 'unknown',
tool_input: context.toolInput || context.tool_input || {},
session_id: context.sessionId || context.session_id || 'unknown',
});
const child = spawn('node', [%q], {
detached: true,
stdio: ['pipe', 'ignore', 'ignore'],
});
child.stdin.write(input);
child.stdin.end();
child.unref();
} catch {
// Fire-and-forget — never block the developer
}
},
},
};
`, guardPath, scriptPath)
}
func (p *OpenCodePlugin) Uninstall() error {
pluginFile, err := p.configPath()
if err != nil {
return err
}
if err := os.Remove(pluginFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", pluginFile, err)
}
scriptPath, _ := hookScriptPath("opencode")
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath("opencode")
if gPath != "" {
os.Remove(gPath)
}
fmt.Println("Uninstalled Elydora hook for OpenCode.")
return nil
}
func (p *OpenCodePlugin) Status() (PluginStatus, error) {
scriptPath, err := hookScriptPath("opencode")
if err != nil {
return PluginStatus{}, err
}
pluginFile, err := p.configPath()
if err != nil {
return PluginStatus{}, err
}
status := PluginStatus{
AgentName: "opencode",
DisplayName: "OpenCode",
ConfigPath: pluginFile,
}
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
if _, err := os.Stat(pluginFile); err == nil {
status.HookConfigured = true
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil
}
+39
View File
@@ -0,0 +1,39 @@
package plugins
// AgentRegistryEntry describes a supported agent integration.
type AgentRegistryEntry struct {
Name string
ConfigDir string // ~ means home dir; no ~ means project-relative
ConfigFile string
}
// SupportedAgents is the canonical registry of all supported agent integrations.
var SupportedAgents = map[string]AgentRegistryEntry{
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
"cursor": {Name: "Cursor", ConfigDir: ".cursor", ConfigFile: "hooks.json"},
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
"augment": {Name: "Augment Code", ConfigDir: "~/.augment", ConfigFile: "settings.json"},
"kiro": {Name: "Kiro", ConfigDir: ".kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
"opencode": {Name: "OpenCode", ConfigDir: ".opencode/plugins", ConfigFile: "elydora-audit.js"},
}
// NewPlugin creates a plugin instance for the given agent name.
// Returns nil if the agent is not supported.
func NewPlugin(agentName string) AgentPlugin {
switch agentName {
case "claudecode":
return &ClaudeCodePlugin{}
case "cursor":
return &CursorPlugin{}
case "gemini":
return &GeminiPlugin{}
case "augment":
return &AugmentPlugin{}
case "kiro":
return &KiroPlugin{}
case "opencode":
return &OpenCodePlugin{}
default:
return nil
}
}