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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user