diff --git a/cmd/elydora/plugins/opencode.go b/cmd/elydora/plugins/opencode.go index 60f73b3..dba747c 100644 --- a/cmd/elydora/plugins/opencode.go +++ b/cmd/elydora/plugins/opencode.go @@ -8,7 +8,7 @@ import ( ) // OpenCodePlugin manages the Elydora audit hook for OpenCode. -// It writes .opencode/plugins/elydora-audit.js in the home directory. +// It writes .config/opencode/plugins/elydora-audit.mjs in the home directory. type OpenCodePlugin struct{} func (p *OpenCodePlugin) configPath() (string, error) { @@ -16,7 +16,7 @@ func (p *OpenCodePlugin) configPath() (string, error) { if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } - return filepath.Join(home, ".opencode", "plugins", "elydora-audit.js"), nil + return filepath.Join(home, ".config", "opencode", "plugins", "elydora-audit.mjs"), nil } func (p *OpenCodePlugin) Install(config InstallConfig) error { @@ -62,51 +62,43 @@ 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'); +import { spawn, spawnSync } from 'node:child_process'; -module.exports = { - name: 'elydora-audit', - version: '1.0.0', - description: 'Sends tool-use events to the Elydora tamper-evident audit platform', +const GUARD_SCRIPT_PATH = %q; +const HOOK_SCRIPT_PATH = %q; - 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 - } - }, +export const ElydoraAuditPlugin = async () => ({ + "tool.execute.before": async () => { + const result = spawnSync('node', [GUARD_SCRIPT_PATH], { + timeout: 5000, + stdio: ['pipe', 'ignore', 'pipe'], + }); + if (result.error) { + throw new Error('Elydora guard failed: ' + result.error.message); + } + if (result.status !== 0) { + const message = result.stderr?.toString().trim() || 'Elydora guard blocked tool execution.'; + throw new Error(message); + } }, -}; + + "tool.execute.after": async (input) => { + const event = JSON.stringify({ + tool_name: input?.tool || 'unknown', + tool_input: input?.args || {}, + session_id: input?.sessionID || 'unknown', + }); + const child = spawn('node', [HOOK_SCRIPT_PATH], { + detached: true, + stdio: ['pipe', 'ignore', 'ignore'], + }); + child.on('error', (error) => { + console.error('[elydora] audit hook failed:', error.message); + }); + child.stdin.end(event); + child.unref(); + }, +}); `, guardPath, scriptPath) } diff --git a/cmd/elydora/plugins/opencode_test.go b/cmd/elydora/plugins/opencode_test.go new file mode 100644 index 0000000..6fe31d3 --- /dev/null +++ b/cmd/elydora/plugins/opencode_test.go @@ -0,0 +1,138 @@ +package plugins + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func runOpenCodeModule(t *testing.T, modulePath string, scriptBody string) string { + t.Helper() + nodeBinary, err := exec.LookPath("node") + if err != nil { + t.Skip("Node.js is required to execute generated OpenCode plugins") + } + moduleURL := "file:///" + filepath.ToSlash(modulePath) + moduleJSON, err := json.Marshal(moduleURL) + if err != nil { + t.Fatalf("marshal module URL: %v", err) + } + script := "const pluginModule = await import(" + string(moduleJSON) + ");\n" + scriptBody + cmd := exec.Command(nodeBinary, "--input-type=module", "--eval", script) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("execute generated plugin: %v\n%s", err, output) + } + return string(output) +} + +func writeOpenCodeModule(t *testing.T, hookPath string, guardPath string) string { + t.Helper() + modulePath := filepath.Join(t.TempDir(), "elydora-audit.mjs") + if err := os.WriteFile(modulePath, []byte(buildOpenCodePlugin(hookPath, guardPath)), 0600); err != nil { + t.Fatalf("write generated plugin: %v", err) + } + return modulePath +} + +func TestOpenCodePluginUsesCurrentAPIAndBlocksFrozenAgent(t *testing.T) { + tempDir := t.TempDir() + guardPath := filepath.Join(tempDir, "guard.cjs") + if err := os.WriteFile( + guardPath, + []byte("process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"), + 0600, + ); err != nil { + t.Fatalf("write guard: %v", err) + } + modulePath := writeOpenCodeModule(t, filepath.Join(tempDir, "hook.cjs"), guardPath) + + runOpenCodeModule(t, modulePath, ` +const hooks = await pluginModule.ElydoraAuditPlugin({ project: { name: 'project' } }); +if (typeof hooks['tool.execute.before'] !== 'function') process.exit(10); +if (typeof hooks['tool.execute.after'] !== 'function') process.exit(11); +try { + await hooks['tool.execute.before']( + { tool: 'bash', sessionID: 'session-1', callID: 'call-1' }, + { args: { command: 'echo test' } }, + ); + process.exit(12); +} catch (error) { + if (!String(error.message).includes('Agent is frozen by Elydora')) process.exit(13); +} +`) +} + +func TestOpenCodePluginForwardsToolEvent(t *testing.T) { + tempDir := t.TempDir() + capturePath := filepath.Join(tempDir, "captured-event.json") + hookPath := filepath.Join(tempDir, "hook.cjs") + captureJSON, err := json.Marshal(capturePath) + if err != nil { + t.Fatalf("marshal capture path: %v", err) + } + hookScript := ` +const fs = require('node:fs'); +const chunks = []; +process.stdin.on('data', (chunk) => chunks.push(chunk)); +process.stdin.on('end', () => fs.writeFileSync(` + string(captureJSON) + `, Buffer.concat(chunks))); +` + if err := os.WriteFile(hookPath, []byte(hookScript), 0600); err != nil { + t.Fatalf("write hook: %v", err) + } + modulePath := writeOpenCodeModule(t, hookPath, filepath.Join(tempDir, "guard.cjs")) + + runOpenCodeModule(t, modulePath, ` +const hooks = await pluginModule.ElydoraAuditPlugin({ project: { name: 'project' } }); +await hooks['tool.execute.after']( + { + tool: 'bash', + sessionID: 'session-1', + callID: 'call-1', + args: { command: 'echo test' }, + }, + { title: 'Shell', output: 'test' }, +); +`) + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(capturePath); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + payload, err := os.ReadFile(capturePath) + if err != nil { + t.Fatalf("read captured event: %v", err) + } + var event map[string]any + if err := json.Unmarshal(payload, &event); err != nil { + t.Fatalf("decode captured event: %v", err) + } + if event["tool_name"] != "bash" || event["session_id"] != "session-1" { + t.Fatalf("unexpected captured event: %s", payload) + } + toolInput, ok := event["tool_input"].(map[string]any) + if !ok || toolInput["command"] != "echo test" { + t.Fatalf("unexpected tool input: %v", event["tool_input"]) + } +} + +func TestOpenCodePluginUsesGlobalConfigDirectory(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + path, err := (&OpenCodePlugin{}).configPath() + if err != nil { + t.Fatalf("resolve config path: %v", err) + } + wantSuffix := filepath.Join(".config", "opencode", "plugins", "elydora-audit.mjs") + if !strings.HasSuffix(path, wantSuffix) { + t.Fatalf("config path = %q, want suffix %q", path, wantSuffix) + } +} diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go index 39c18a5..160bb58 100644 --- a/cmd/elydora/plugins/registry.go +++ b/cmd/elydora/plugins/registry.go @@ -14,7 +14,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, "kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro", ConfigFile: "settings.json"}, "kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"}, - "opencode": {Name: "OpenCode", ConfigDir: "~/.opencode/plugins", ConfigFile: "elydora-audit.js"}, + "opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, "copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"}, "letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"}, }