fix: sync OpenCode plugin integrations

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 02:09:34 -04:00
1 parent c2f49e1524
commit 34300ba38f
8 files changed
+592 -382

No files matched your search

+36 -44
View File
@@ -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 { timeout: 5000,
const result = spawnSync('node', [%q], { stdio: ['pipe', 'ignore', 'pipe'],
timeout: 5000, });
stdio: ['pipe', 'ignore', 'pipe'], if (result.error) {
}); throw new Error('Elydora guard failed: ' + result.error.message);
if (result.status !== 0) { }
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.'; if (result.status !== 0) {
return { blocked: true, reason: msg }; const message = result.stderr?.toString().trim() || 'Elydora guard blocked tool execution.';
} throw new Error(message);
} 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
}
},
}, },
};
"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) `, guardPath, scriptPath)
} }
@@ -0,0 +1,135 @@
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)
}
}
+1 -1
View File
@@ -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"},
} }
+41 -40
View File
@@ -21,49 +21,51 @@ function generatePluginScript(hookScriptPath: string, guardScriptPath: string):
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
const HOOK_SCRIPT_PATH = ${JSON.stringify(hookScriptPath)};
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardScriptPath)};
export const ElydoraAuditPlugin = async (ctx) => { export const ElydoraAuditPlugin = async (ctx) => {
return { return {
"tool.execute.before": async (input, output) => { "tool.execute.before": async () => {
// Guard — blocks tool if agent is frozen // Guard — blocks tool if agent is frozen
let result; let result;
try { try {
result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], { result = spawnSync('node', [GUARD_SCRIPT_PATH], {
timeout: 5000, timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'], stdio: ['pipe', 'ignore', 'pipe'],
}); });
} catch { } catch (error) {
// Fail-open — allow if guard can't run throw new Error('Elydora guard failed: ' + error.message);
return;
} }
if (result.status === 2) { 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);
throw new Error(msg); }
if (result.status !== 0) {
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
throw new Error(prefix + message);
} }
}, },
"tool.execute.after": async (input, output) => { "tool.execute.after": async (input, output) => {
try { const data = JSON.stringify({
const toolName = input?.tool || 'unknown'; tool_name: input?.tool || 'unknown',
const toolInput = output?.args || input?.args || {}; tool_input: input?.args || output?.args || {},
const sessionId = ctx?.project?.name || 'unknown'; session_id: input?.sessionID || ctx?.project?.name || 'unknown',
});
const data = JSON.stringify({ const child = spawn('node', [HOOK_SCRIPT_PATH], {
tool_name: toolName, detached: true,
tool_input: toolInput, stdio: ['pipe', 'ignore', 'ignore'],
session_id: sessionId, });
}); child.on('error', (error) => {
console.error('[elydora] audit hook failed:', error.message);
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], { });
detached: true, child.stdin.on('error', (error) => {
stdio: ['pipe', 'ignore', 'ignore'], console.error('[elydora] audit input failed:', error.message);
}); });
child.stdin.write(data); child.stdin.end(data);
child.stdin.end(); child.unref();
child.unref();
} catch {
// Fire-and-forget — never block the developer
}
}, },
}; };
}; };
@@ -76,9 +78,7 @@ export const opencodePlugin: AgentPlugin = {
await fsp.mkdir(configDir, { recursive: true }); await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath(); const configPath = resolveConfigPath();
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath) const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
.replace('__ELYDORA_HOOK_SCRIPT_PATH__', config.hookScriptPath.replace(/\\/g, '\\\\'))
.replace('__ELYDORA_GUARD_SCRIPT_PATH__', config.guardScriptPath.replace(/\\/g, '\\\\'));
await fsp.writeFile(configPath, script, 'utf-8'); await fsp.writeFile(configPath, script, 'utf-8');
}, },
@@ -97,24 +97,25 @@ export const opencodePlugin: AgentPlugin = {
let hookConfigured = false; let hookConfigured = false;
let hookScriptPath = ''; let hookScriptPath = '';
let guardScriptPath = '';
try { try {
const raw = await fsp.readFile(configPath, 'utf-8'); const raw = await fsp.readFile(configPath, 'utf-8');
hookConfigured = true; const hookMatch = raw.match(/const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
const guardMatch = raw.match(/const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
// Extract hook script path from plugin script content if (hookMatch && guardMatch) {
// The plugin script contains a spawn call like: spawn('node', ['<path>']) hookScriptPath = JSON.parse(hookMatch[1]);
const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/); guardScriptPath = JSON.parse(guardMatch[1]);
if (match) { hookConfigured = true;
hookScriptPath = match[1];
} }
} catch { } catch {
// File doesn't exist // File doesn't exist
} }
let hookScriptExists = false; let hookScriptExists = false;
if (hookScriptPath) { if (hookScriptPath && guardScriptPath) {
try { try {
await fsp.access(hookScriptPath); await fsp.access(hookScriptPath);
await fsp.access(guardScriptPath);
hookScriptExists = true; hookScriptExists = true;
} catch { } catch {
// File doesn't exist // File doesn't exist
+136 -36
View File
@@ -1,8 +1,9 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import test from 'node:test'; import test from 'node:test';
@@ -21,46 +22,145 @@ function runNode(args, env) {
}); });
} }
test('OpenCode rejects tool execution when the Elydora guard blocks', async () => { async function createFixture({ guardSource, hookSource }) {
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-')); const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-'));
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
await mkdir(agentDir, { recursive: true });
const guardScriptPath = path.join(agentDir, 'guard.cjs');
const hookScriptPath = path.join(agentDir, 'hook.cjs');
if (guardSource !== undefined) await writeFile(guardScriptPath, guardSource);
await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n');
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
const installScript = `
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
await opencodePlugin.install(JSON.parse(process.env.ELYDORA_TEST_CONFIG));
`;
const installResult = await runNode(
['--input-type=module', '--eval', installScript],
{
HOME: homeDir,
USERPROFILE: homeDir,
ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }),
},
);
assert.equal(installResult.code, 0, installResult.stderr);
const generatedPath = path.join(
homeDir,
'.config',
'opencode',
'plugins',
'elydora-audit.mjs',
);
const generatedPlugin = await import(`${pathToFileURL(generatedPath).href}?test=${Date.now()}`);
const hooks = await generatedPlugin.ElydoraAuditPlugin({ project: { name: 'project-fallback' } });
return {
guardScriptPath,
homeDir,
hooks,
async close() {
await rm(homeDir, { recursive: true, force: true });
},
};
}
async function readPluginStatus(homeDir) {
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
const statusScript = `
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
console.log(JSON.stringify(await opencodePlugin.status()));
`;
const result = await runNode(
['--input-type=module', '--eval', statusScript],
{ HOME: homeDir, USERPROFILE: homeDir },
);
assert.equal(result.code, 0, result.stderr);
return JSON.parse(result.stdout);
}
test('OpenCode rejects tool execution when the Elydora guard blocks', async () => {
const fixture = await createFixture({
guardSource: "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
});
try { try {
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
await mkdir(agentDir, { recursive: true });
const guardScriptPath = path.join(agentDir, 'guard.cjs');
const hookScriptPath = path.join(agentDir, 'hook.cjs');
await writeFile(
guardScriptPath,
"process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
);
await writeFile(hookScriptPath, 'process.exit(0);\n');
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
const installScript = `
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
await opencodePlugin.install(JSON.parse(process.env.ELYDORA_TEST_CONFIG));
`;
const installResult = await runNode(
['--input-type=module', '--eval', installScript],
{
HOME: homeDir,
USERPROFILE: homeDir,
ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }),
},
);
assert.equal(installResult.code, 0, installResult.stderr);
const generatedPath = path.join(
homeDir,
'.config',
'opencode',
'plugins',
'elydora-audit.mjs',
);
const generatedPlugin = await import(`${pathToFileURL(generatedPath).href}?test=${Date.now()}`);
const hooks = await generatedPlugin.ElydoraAuditPlugin({ project: { name: 'test' } });
await assert.rejects( await assert.rejects(
hooks['tool.execute.before']({ tool: 'bash' }, { args: { command: 'echo test' } }), fixture.hooks['tool.execute.before'](
{ tool: 'bash', sessionID: 'session-1', callID: 'call-1' },
{ args: { command: 'echo test' } },
),
/Agent is frozen by Elydora/, /Agent is frozen by Elydora/,
); );
} finally { } finally {
await rm(homeDir, { recursive: true, force: true }); await fixture.close();
}
});
test('OpenCode blocks when the guard process cannot start', async () => {
const fixture = await createFixture({});
try {
await assert.rejects(
fixture.hooks['tool.execute.before'](
{ tool: 'bash', sessionID: 'session-1', callID: 'call-1' },
{ args: { command: 'echo test' } },
),
/Elydora guard failed/,
);
} finally {
await fixture.close();
}
});
test('OpenCode forwards the current tool event contract to the audit hook', async () => {
const capturePath = path.join(os.tmpdir(), `elydora-event-${process.pid}-${Date.now()}.json`);
const hookSource = `
const fs = require('node:fs');
const chunks = [];
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
`;
const fixture = await createFixture({
guardSource: 'process.exit(0);\n',
hookSource,
});
try {
await fixture.hooks['tool.execute.after'](
{
tool: 'bash',
sessionID: 'session-1',
callID: 'call-1',
args: { command: 'echo test' },
},
{ title: 'Shell', output: 'test' },
);
let payload;
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
try {
payload = await readFile(capturePath, 'utf-8');
break;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
await delay(20);
}
}
assert(payload, 'audit hook did not receive the tool event');
assert.deepEqual(JSON.parse(payload), {
tool_name: 'bash',
tool_input: { command: 'echo test' },
session_id: 'session-1',
});
} finally {
await fixture.close();
await rm(capturePath, { force: true });
}
});
test('OpenCode status requires both generated runtime scripts', async () => {
const fixture = await createFixture({ guardSource: 'process.exit(0);\n' });
try {
assert.equal((await readPluginStatus(fixture.homeDir)).installed, true);
await rm(fixture.guardScriptPath);
assert.equal((await readPluginStatus(fixture.homeDir)).installed, false);
} finally {
await fixture.close();
} }
}); });
+115 -259
View File
@@ -1,308 +1,164 @@
"""OpenCode plugin — writes .opencode/plugins/elydora-audit.js (JS plugin).""" """OpenCode integration using its current ESM plugin API."""
from __future__ import annotations from __future__ import annotations
import glob
import json import json
import os import os
import stat
import sys import sys
from .base import AgentPlugin, InstallConfig, PluginStatus from .base import AgentPlugin, InstallConfig, PluginStatus
from .hook_template import generate_hook_script
PLUGIN_DIR = os.path.join(os.path.expanduser("~"), ".opencode", "plugins") PLUGIN_DIR = os.path.join(os.path.expanduser("~"), ".config", "opencode", "plugins")
PLUGIN_FILENAME = "elydora-audit.js" PLUGIN_FILENAME = "elydora-audit.mjs"
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora") ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
class OpenCodePlugin(AgentPlugin): class OpenCodePlugin(AgentPlugin):
"""Install/uninstall Elydora audit hook for OpenCode. """Install the Elydora guard and audit hooks for OpenCode."""
OpenCode expects JavaScript plugins, so this generates a self-contained
JS file that performs the same audit flow using Node.js built-ins.
"""
def _plugin_path(self) -> str: def _plugin_path(self) -> str:
return os.path.join(PLUGIN_DIR, PLUGIN_FILENAME) return os.path.join(PLUGIN_DIR, PLUGIN_FILENAME)
@staticmethod
def _hook_path(agent_id: str) -> str:
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
def install(self, config: InstallConfig) -> None: def install(self, config: InstallConfig) -> None:
agent_id = config.get("agent_id", "") agent_id = config.get("agent_id", "")
agent_name = config.get("agent_name", "")
org_id = config.get("org_id", "")
private_key = config.get("private_key", "")
kid = config.get("kid", "")
base_url = config.get("base_url", "https://api.elydora.com")
guard_script_path = config.get("guard_script_path", "") guard_script_path = config.get("guard_script_path", "")
if not agent_id:
raise ValueError("agent_id is required")
if not guard_script_path:
raise ValueError("guard_script_path is required")
# Create per-agent directory
agent_dir = os.path.join(ELYDORA_DIR, agent_id) agent_dir = os.path.join(ELYDORA_DIR, agent_id)
os.makedirs(agent_dir, exist_ok=True) os.makedirs(agent_dir, exist_ok=True)
# Write config.json
config_data = { config_data = {
"org_id": org_id, "org_id": config.get("org_id", ""),
"agent_id": agent_id, "agent_id": agent_id,
"kid": kid, "kid": config.get("kid", ""),
"base_url": base_url, "base_url": config.get("base_url", "https://api.elydora.com"),
"token": config.get("token", ""), "token": config.get("token", ""),
"agent_name": agent_name, "agent_name": config.get("agent_name", "opencode"),
} }
config_path = os.path.join(agent_dir, "config.json") _write_text(
with open(config_path, "w", encoding="utf-8") as f: os.path.join(agent_dir, "config.json"),
json.dump(config_data, f, indent=2) json.dumps(config_data, indent=2) + "\n",
f.write("\n") 0o600,
)
# Write private key _write_text(
private_key_path = os.path.join(agent_dir, "private.key") os.path.join(agent_dir, "private.key"),
with open(private_key_path, "w", encoding="utf-8") as f: config.get("private_key", ""),
f.write(private_key) 0o600,
try:
os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR)
except Exception:
pass # chmod may fail on Windows
js_content = _generate_js_plugin(
org_id=org_id,
agent_id=agent_id,
private_key=private_key,
kid=kid,
base_url=base_url,
guard_script_path=guard_script_path,
) )
os.makedirs(PLUGIN_DIR, exist_ok=True) hook_path = self._hook_path(agent_id)
plugin_path = self._plugin_path() hook_script = generate_hook_script(
with open(plugin_path, "w", encoding="utf-8") as f: org_id=config.get("org_id", ""),
f.write(js_content) agent_id=agent_id,
try: private_key=config.get("private_key", ""),
os.chmod(plugin_path, stat.S_IRWXU) kid=config.get("kid", ""),
except Exception: base_url=config.get("base_url", "https://api.elydora.com"),
pass # chmod may fail on Windows )
_write_text(hook_path, hook_script, 0o700)
print(f"Elydora hook installed for OpenCode.") os.makedirs(PLUGIN_DIR, exist_ok=True)
print(f" Plugin: {plugin_path}") _write_text(
self._plugin_path(),
_generate_js_plugin(
hook_script_path=hook_path,
guard_script_path=guard_script_path,
),
0o700,
)
print("Elydora hook installed for OpenCode.")
print(f" Plugin: {self._plugin_path()}")
def uninstall(self, agent_id: str = "") -> None: def uninstall(self, agent_id: str = "") -> None:
plugin_path = self._plugin_path() try:
if os.path.exists(plugin_path): os.remove(self._plugin_path())
os.remove(plugin_path) except FileNotFoundError:
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir) pass
print("Elydora hook uninstalled from OpenCode.") print("Elydora hook uninstalled from OpenCode.")
def status(self) -> PluginStatus: def status(self) -> PluginStatus:
plugin_path = self._plugin_path() plugin_path = self._plugin_path()
installed = os.path.exists(plugin_path) runtime_ready = False
if installed: for config_path in glob.glob(os.path.join(ELYDORA_DIR, "*", "config.json")):
details = f"Plugin: {plugin_path}" try:
else: with open(config_path, "r", encoding="utf-8") as file:
details = "Not installed" config = json.load(file)
except (OSError, json.JSONDecodeError):
continue
agent_dir = os.path.dirname(config_path)
if config.get("agent_name") == "opencode":
runtime_ready = os.path.isfile(os.path.join(agent_dir, "hook.py")) and os.path.isfile(
os.path.join(agent_dir, "guard.py")
)
if runtime_ready:
break
installed = os.path.isfile(plugin_path) and runtime_ready
details = f"Plugin: {plugin_path}" if installed else "Not installed"
return PluginStatus(installed=installed, agent="opencode", details=details) return PluginStatus(installed=installed, agent="opencode", details=details)
def _generate_js_plugin( def _write_text(path: str, content: str, mode: int) -> None:
*, with open(path, "w", encoding="utf-8") as file:
org_id: str, file.write(content)
agent_id: str, try:
private_key: str, os.chmod(path, mode)
kid: str, except OSError:
base_url: str, pass
guard_script_path: str = "",
) -> str:
guard_path_escaped = guard_script_path.replace("\\", "\\\\") if guard_script_path else ""
python_exe_escaped = sys.executable.replace("\\", "\\\\")
return f'''// Elydora audit plugin for OpenCode — auto-generated, do not edit.
"use strict";
const crypto = require("crypto");
const https = require("https");
const http = require("http");
const fs = require("fs");
const path = require("path");
const os = require("os");
const {{ spawnSync }} = require("child_process");
const ORG_ID = {_js_str(org_id)}; def _generate_js_plugin(*, hook_script_path: str, guard_script_path: str) -> str:
const AGENT_ID = {_js_str(agent_id)}; return f'''// Elydora Audit Plugin for OpenCode
const PRIVATE_KEY = {_js_str(private_key)}; // Generated by elydora-sdk — DO NOT EDIT
const KID = {_js_str(kid)};
const BASE_URL = {_js_str(base_url)};
const CHAIN_STATE_PATH = path.join(os.homedir(), ".elydora", AGENT_ID, "chain-state.json"); import {{ spawn, spawnSync }} from 'node:child_process';
function base64urlEncode(buf) {{ const PYTHON_EXE = {_js_str(sys.executable)};
return Buffer.from(buf).toString("base64url"); const HOOK_SCRIPT_PATH = {_js_str(hook_script_path)};
}} const GUARD_SCRIPT_PATH = {_js_str(guard_script_path)};
function base64urlDecode(s) {{ export const ElydoraAuditPlugin = async () => ({{
return Buffer.from(s, "base64url"); "tool.execute.before": async () => {{
}} const result = spawnSync(PYTHON_EXE, [GUARD_SCRIPT_PATH], {{
timeout: 5000,
function sha256Base64url(data) {{ stdio: ['pipe', 'ignore', 'pipe'],
if (typeof data === "string") data = Buffer.from(data, "utf-8"); }});
return base64urlEncode(crypto.createHash("sha256").update(data).digest()); if (result.error) {{
}} throw new Error('Elydora guard failed: ' + result.error.message);
}}
function jcsCanonicalize(value) {{ if (result.status !== 0) {{
if (value === null || value === undefined) return "null"; const message = result.stderr?.toString().trim() || 'Elydora guard blocked tool execution.';
if (typeof value === "boolean") return value ? "true" : "false"; throw new Error(message);
if (typeof value === "number") {{ }}
if (!isFinite(value)) return "null";
if (Object.is(value, -0)) return "0";
return JSON.stringify(value);
}}
if (typeof value === "string") return JSON.stringify(value);
if (Array.isArray(value)) return "[" + value.map(jcsCanonicalize).join(",") + "]";
if (typeof value === "object") {{
const keys = Object.keys(value).sort();
const pairs = keys.map(k => JSON.stringify(k) + ":" + jcsCanonicalize(value[k]));
return "{{" + pairs.join(",") + "}}";
}}
return JSON.stringify(value);
}}
function computePayloadHash(payload) {{
return sha256Base64url(jcsCanonicalize(payload));
}}
function computeChainHash(prev, payloadHash, opId, issuedAt) {{
return sha256Base64url(`${{prev}}|${{payloadHash}}|${{opId}}|${{issuedAt}}`);
}}
function signEd25519(privKeyB64url, data) {{
const seed = base64urlDecode(privKeyB64url);
const key = crypto.createPrivateKey({{
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]),
format: "der",
type: "pkcs8",
}});
return base64urlEncode(crypto.sign(null, Buffer.from(data, "utf-8"), key));
}}
function generateUuidv7() {{
const ts = Date.now();
const buf = Buffer.alloc(16);
buf.writeUIntBE(ts, 0, 6);
const rand = crypto.randomBytes(10);
rand.copy(buf, 6);
buf[6] = (buf[6] & 0x0f) | 0x70;
buf[8] = (buf[8] & 0x3f) | 0x80;
const h = buf.toString("hex");
return `${{h.slice(0,8)}}-${{h.slice(8,12)}}-${{h.slice(12,16)}}-${{h.slice(16,20)}}-${{h.slice(20)}}`;
}}
function generateNonce() {{
return base64urlEncode(crypto.randomBytes(16));
}}
function loadChainState() {{
try {{
return JSON.parse(fs.readFileSync(CHAIN_STATE_PATH, "utf-8"));
}} catch {{
return {{ prev_chain_hash: "" }};
}}
}}
function saveChainState(state) {{
const dir = path.dirname(CHAIN_STATE_PATH);
fs.mkdirSync(dir, {{ recursive: true }});
const tmp = CHAIN_STATE_PATH + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(state));
fs.renameSync(tmp, CHAIN_STATE_PATH);
}}
const PYTHON_EXE = "{python_exe_escaped}";
const GUARD_SCRIPT_PATH = "{guard_path_escaped}";
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
if (GUARD_SCRIPT_PATH) {{
try {{
const result = spawnSync(PYTHON_EXE, [GUARD_SCRIPT_PATH], {{
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) {{
const event = context || {{}};
try {{
const toolName = event.tool_name || event.toolName || event.name || "";
const toolInput = event.tool_input || event.toolInput || event.input || {{}};
const toolOutput = event.tool_output || event.toolOutput || event.output || {{}};
const operationId = generateUuidv7();
const issuedAt = Date.now();
const nonce = generateNonce();
const payload = {{ tool_name: toolName, tool_input: toolInput, tool_output: toolOutput }};
const payloadHash = computePayloadHash(payload);
const chainState = loadChainState();
const prevChainHash = chainState.prev_chain_hash || "";
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
const eor = {{
op_version: "1.0",
operation_id: operationId,
org_id: ORG_ID,
agent_id: AGENT_ID,
issued_at: issuedAt,
ttl_ms: 30000,
nonce: nonce,
operation_type: "tool_use",
subject: {{ type: "tool", id: toolName }},
action: {{ type: "execute", tool: toolName }},
payload: payload,
payload_hash: payloadHash,
prev_chain_hash: prevChainHash,
agent_pubkey_kid: KID,
signature: "",
}};
const signable = Object.fromEntries(Object.entries(eor).filter(([k]) => k !== "signature"));
eor.signature = signEd25519(PRIVATE_KEY, jcsCanonicalize(signable));
// POST to API — only save chain state on 2xx response
const url = new URL(BASE_URL.replace(/\\/$/, "") + "/v1/operations");
const body = JSON.stringify(eor);
const mod = url.protocol === "https:" ? https : http;
const req = mod.request(url, {{
method: "POST",
headers: {{ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }},
timeout: 5000,
}}, (res) => {{
if (res.statusCode >= 200 && res.statusCode < 300) {{
saveChainState({{ prev_chain_hash: chainHash }});
}}
res.resume();
}});
req.on("error", () => {{}});
req.write(body);
req.end();
}} catch (e) {{
// Never block the host agent
console.error("[elydora]", e.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(PYTHON_EXE, [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();
}},
}});
''' '''
def _js_str(s: str) -> str: def _js_str(value: str) -> str:
"""Escape a Python string for embedding as a JS string literal.""" return json.dumps(value)
import json
return json.dumps(s)
+2 -2
View File
@@ -43,8 +43,8 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
}, },
"opencode": { "opencode": {
"name": "OpenCode", "name": "OpenCode",
"hook_event": "PostToolUse", "hook_event": "tool.execute.after",
"config_path": "~/.opencode/plugins/", "config_path": "~/.config/opencode/plugins/",
}, },
} }
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import time
import pytest
from elydora.plugins import opencode
from elydora.plugins.registry import SUPPORTED_AGENTS
AGENT_ID = "agent-1"
def install_plugin(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> tuple[Path, Path, Path]:
plugin_dir = tmp_path / ".config" / "opencode" / "plugins"
elydora_dir = tmp_path / ".elydora"
monkeypatch.setattr(opencode, "PLUGIN_DIR", str(plugin_dir))
monkeypatch.setattr(opencode, "ELYDORA_DIR", str(elydora_dir))
agent_dir = elydora_dir / AGENT_ID
agent_dir.mkdir(parents=True)
guard_path = agent_dir / "guard.py"
guard_path.write_text(
"import sys\nsys.stderr.write('Agent is frozen by Elydora.')\nraise SystemExit(2)\n",
encoding="utf-8",
)
opencode.OpenCodePlugin().install({
"agent_id": AGENT_ID,
"agent_name": "opencode",
"org_id": "org-1",
"private_key": "test-key",
"kid": "kid-1",
"base_url": "https://api.elydora.test",
"guard_script_path": str(guard_path),
})
return plugin_dir / "elydora-audit.mjs", agent_dir / "hook.py", guard_path
def run_node(script: str, home_dir: Path) -> subprocess.CompletedProcess[str]:
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is required to execute generated OpenCode plugins")
env = {
**os.environ,
"HOME": str(home_dir),
"USERPROFILE": str(home_dir),
}
return subprocess.run(
[node, "--input-type=module", "--eval", script],
capture_output=True,
check=False,
env=env,
text=True,
)
def test_generated_opencode_plugin_uses_current_api_and_blocks_frozen_agent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
plugin_path, hook_path, _guard_path = install_plugin(monkeypatch, tmp_path)
assert hook_path.is_file()
script = f"""
const pluginModule = await import({json.dumps(plugin_path.as_uri())});
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);
}}
"""
result = run_node(script, tmp_path)
assert result.returncode == 0, result.stderr
assert opencode.OpenCodePlugin().status()["installed"] is True
assert SUPPORTED_AGENTS["opencode"]["config_path"] == "~/.config/opencode/plugins/"
def test_generated_opencode_plugin_forwards_tool_event(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
plugin_path, hook_path, _guard_path = install_plugin(monkeypatch, tmp_path)
capture_path = tmp_path / "captured-event.json"
hook_path.write_text(
"import pathlib, sys\n"
f"pathlib.Path({str(capture_path)!r}).write_text(sys.stdin.read(), encoding='utf-8')\n",
encoding="utf-8",
)
script = f"""
const pluginModule = await import({json.dumps(plugin_path.as_uri())});
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' }},
);
"""
result = run_node(script, tmp_path)
assert result.returncode == 0, result.stderr
deadline = time.monotonic() + 3
while not capture_path.exists() and time.monotonic() < deadline:
time.sleep(0.02)
assert capture_path.is_file()
event = json.loads(capture_path.read_text(encoding="utf-8"))
assert event == {
"tool_name": "bash",
"tool_input": {"command": "echo test"},
"session_id": "session-1",
}