fix: update OpenCode plugin integration
This commit is contained in:
1 parent
0234652963
commit
598b0bd2bb
3 files changed
+163
-33
No files matched your search
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OpenCodePlugin manages the Elydora audit hook for OpenCode.
|
// 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{}
|
type OpenCodePlugin struct{}
|
||||||
|
|
||||||
func (p *OpenCodePlugin) configPath() (string, error) {
|
func (p *OpenCodePlugin) configPath() (string, error) {
|
||||||
@@ -16,7 +16,7 @@ func (p *OpenCodePlugin) configPath() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
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 {
|
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
|
return fmt.Sprintf(`// Elydora Audit Plugin for OpenCode
|
||||||
// Generated by @elydora/sdk — DO NOT EDIT
|
// Generated by @elydora/sdk — DO NOT EDIT
|
||||||
|
|
||||||
const { spawn, spawnSync } = require('node:child_process');
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
module.exports = {
|
const GUARD_SCRIPT_PATH = %q;
|
||||||
name: 'elydora-audit',
|
const HOOK_SCRIPT_PATH = %q;
|
||||||
version: '1.0.0',
|
|
||||||
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
|
||||||
|
|
||||||
hooks: {
|
export const ElydoraAuditPlugin = async () => ({
|
||||||
preToolUse(context) {
|
"tool.execute.before": async () => {
|
||||||
// Synchronous guard — blocks tool if agent is frozen
|
const result = spawnSync('node', [GUARD_SCRIPT_PATH], {
|
||||||
try {
|
|
||||||
const result = spawnSync('node', [%q], {
|
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: ['pipe', 'ignore', 'pipe'],
|
stdio: ['pipe', 'ignore', 'pipe'],
|
||||||
});
|
});
|
||||||
if (result.status !== 0) {
|
if (result.error) {
|
||||||
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
|
throw new Error('Elydora guard failed: ' + result.error.message);
|
||||||
return { blocked: true, reason: msg };
|
|
||||||
}
|
}
|
||||||
} catch {
|
if (result.status !== 0) {
|
||||||
// Fail-open — allow if guard can't run
|
const message = result.stderr?.toString().trim() || 'Elydora guard blocked tool execution.';
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
postToolUse(context) {
|
"tool.execute.after": async (input) => {
|
||||||
try {
|
const event = JSON.stringify({
|
||||||
const input = JSON.stringify({
|
tool_name: input?.tool || 'unknown',
|
||||||
tool_name: context.toolName || context.tool_name || 'unknown',
|
tool_input: input?.args || {},
|
||||||
tool_input: context.toolInput || context.tool_input || {},
|
session_id: input?.sessionID || 'unknown',
|
||||||
session_id: context.sessionId || context.session_id || 'unknown',
|
|
||||||
});
|
});
|
||||||
|
const child = spawn('node', [HOOK_SCRIPT_PATH], {
|
||||||
const child = spawn('node', [%q], {
|
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ['pipe', 'ignore', 'ignore'],
|
stdio: ['pipe', 'ignore', 'ignore'],
|
||||||
});
|
});
|
||||||
child.stdin.write(input);
|
child.on('error', (error) => {
|
||||||
child.stdin.end();
|
console.error('[elydora] audit hook failed:', error.message);
|
||||||
|
});
|
||||||
|
child.stdin.end(event);
|
||||||
child.unref();
|
child.unref();
|
||||||
} catch {
|
|
||||||
// Fire-and-forget — never block the developer
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
};
|
|
||||||
`, guardPath, scriptPath)
|
`, guardPath, scriptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
|||||||
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
||||||
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro", ConfigFile: "settings.json"},
|
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro", ConfigFile: "settings.json"},
|
||||||
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
|
"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"},
|
"copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},
|
||||||
"letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"},
|
"letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user