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.
// 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)
}
@@ -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"},
"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"},
}
+41 -40
View File
@@ -21,49 +21,51 @@ function generatePluginScript(hookScriptPath: string, guardScriptPath: string):
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) => {
return {
"tool.execute.before": async (input, output) => {
"tool.execute.before": async () => {
// Guard — blocks tool if agent is frozen
let result;
try {
result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], {
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'],
});
} catch {
// Fail-open — allow if guard can't run
return;
} catch (error) {
throw new Error('Elydora guard failed: ' + error.message);
}
if (result.status === 2) {
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
throw new Error(msg);
if (result.error) {
throw new Error('Elydora guard failed: ' + result.error.message);
}
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) => {
try {
const toolName = input?.tool || 'unknown';
const toolInput = output?.args || input?.args || {};
const sessionId = ctx?.project?.name || 'unknown';
const data = JSON.stringify({
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
});
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], {
detached: true,
stdio: ['pipe', 'ignore', 'ignore'],
});
child.stdin.write(data);
child.stdin.end();
child.unref();
} catch {
// Fire-and-forget — never block the developer
}
const data = JSON.stringify({
tool_name: input?.tool || 'unknown',
tool_input: input?.args || output?.args || {},
session_id: input?.sessionID || ctx?.project?.name || '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.on('error', (error) => {
console.error('[elydora] audit input failed:', error.message);
});
child.stdin.end(data);
child.unref();
},
};
};
@@ -76,9 +78,7 @@ export const opencodePlugin: AgentPlugin = {
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
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, '\\\\'));
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
await fsp.writeFile(configPath, script, 'utf-8');
},
@@ -97,24 +97,25 @@ export const opencodePlugin: AgentPlugin = {
let hookConfigured = false;
let hookScriptPath = '';
let guardScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
hookConfigured = true;
// Extract hook script path from plugin script content
// The plugin script contains a spawn call like: spawn('node', ['<path>'])
const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/);
if (match) {
hookScriptPath = match[1];
const hookMatch = raw.match(/const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
const guardMatch = raw.match(/const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
if (hookMatch && guardMatch) {
hookScriptPath = JSON.parse(hookMatch[1]);
guardScriptPath = JSON.parse(guardMatch[1]);
hookConfigured = true;
}
} catch {
// File doesn't exist
}
let hookScriptExists = false;
if (hookScriptPath) {
if (hookScriptPath && guardScriptPath) {
try {
await fsp.access(hookScriptPath);
await fsp.access(guardScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
+136 -36
View File
@@ -1,8 +1,9 @@
import assert from 'node:assert/strict';
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 path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
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 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 {
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(
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/,
);
} 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
import glob
import json
import os
import stat
import sys
from .base import AgentPlugin, InstallConfig, PluginStatus
from .hook_template import generate_hook_script
PLUGIN_DIR = os.path.join(os.path.expanduser("~"), ".opencode", "plugins")
PLUGIN_FILENAME = "elydora-audit.js"
PLUGIN_DIR = os.path.join(os.path.expanduser("~"), ".config", "opencode", "plugins")
PLUGIN_FILENAME = "elydora-audit.mjs"
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
class OpenCodePlugin(AgentPlugin):
"""Install/uninstall Elydora audit hook 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.
"""
"""Install the Elydora guard and audit hooks for OpenCode."""
def _plugin_path(self) -> str:
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:
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", "")
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)
os.makedirs(agent_dir, exist_ok=True)
# Write config.json
config_data = {
"org_id": org_id,
"org_id": config.get("org_id", ""),
"agent_id": agent_id,
"kid": kid,
"base_url": base_url,
"kid": config.get("kid", ""),
"base_url": config.get("base_url", "https://api.elydora.com"),
"token": config.get("token", ""),
"agent_name": agent_name,
"agent_name": config.get("agent_name", "opencode"),
}
config_path = os.path.join(agent_dir, "config.json")
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2)
f.write("\n")
# Write private key
private_key_path = os.path.join(agent_dir, "private.key")
with open(private_key_path, "w", encoding="utf-8") as f:
f.write(private_key)
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,
_write_text(
os.path.join(agent_dir, "config.json"),
json.dumps(config_data, indent=2) + "\n",
0o600,
)
_write_text(
os.path.join(agent_dir, "private.key"),
config.get("private_key", ""),
0o600,
)
os.makedirs(PLUGIN_DIR, exist_ok=True)
plugin_path = self._plugin_path()
with open(plugin_path, "w", encoding="utf-8") as f:
f.write(js_content)
try:
os.chmod(plugin_path, stat.S_IRWXU)
except Exception:
pass # chmod may fail on Windows
hook_path = self._hook_path(agent_id)
hook_script = generate_hook_script(
org_id=config.get("org_id", ""),
agent_id=agent_id,
private_key=config.get("private_key", ""),
kid=config.get("kid", ""),
base_url=config.get("base_url", "https://api.elydora.com"),
)
_write_text(hook_path, hook_script, 0o700)
print(f"Elydora hook installed for OpenCode.")
print(f" Plugin: {plugin_path}")
os.makedirs(PLUGIN_DIR, exist_ok=True)
_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:
plugin_path = self._plugin_path()
if os.path.exists(plugin_path):
os.remove(plugin_path)
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
try:
os.remove(self._plugin_path())
except FileNotFoundError:
pass
print("Elydora hook uninstalled from OpenCode.")
def status(self) -> PluginStatus:
plugin_path = self._plugin_path()
installed = os.path.exists(plugin_path)
if installed:
details = f"Plugin: {plugin_path}"
else:
details = "Not installed"
runtime_ready = False
for config_path in glob.glob(os.path.join(ELYDORA_DIR, "*", "config.json")):
try:
with open(config_path, "r", encoding="utf-8") as file:
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)
def _generate_js_plugin(
*,
org_id: str,
agent_id: str,
private_key: str,
kid: str,
base_url: str,
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";
def _write_text(path: str, content: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as file:
file.write(content)
try:
os.chmod(path, mode)
except OSError:
pass
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)};
const AGENT_ID = {_js_str(agent_id)};
const PRIVATE_KEY = {_js_str(private_key)};
const KID = {_js_str(kid)};
const BASE_URL = {_js_str(base_url)};
def _generate_js_plugin(*, hook_script_path: str, guard_script_path: str) -> str:
return f'''// Elydora Audit Plugin for OpenCode
// Generated by elydora-sdk — DO NOT EDIT
const CHAIN_STATE_PATH = path.join(os.homedir(), ".elydora", AGENT_ID, "chain-state.json");
import {{ spawn, spawnSync }} from 'node:child_process';
function base64urlEncode(buf) {{
return Buffer.from(buf).toString("base64url");
}}
const PYTHON_EXE = {_js_str(sys.executable)};
const HOOK_SCRIPT_PATH = {_js_str(hook_script_path)};
const GUARD_SCRIPT_PATH = {_js_str(guard_script_path)};
function base64urlDecode(s) {{
return Buffer.from(s, "base64url");
}}
function sha256Base64url(data) {{
if (typeof data === "string") data = Buffer.from(data, "utf-8");
return base64urlEncode(crypto.createHash("sha256").update(data).digest());
}}
function jcsCanonicalize(value) {{
if (value === null || value === undefined) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
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);
}}
}},
export const ElydoraAuditPlugin = async () => ({{
"tool.execute.before": async () => {{
const result = spawnSync(PYTHON_EXE, [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(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:
"""Escape a Python string for embedding as a JS string literal."""
import json
return json.dumps(s)
def _js_str(value: str) -> str:
return json.dumps(value)
+2 -2
View File
@@ -43,8 +43,8 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
},
"opencode": {
"name": "OpenCode",
"hook_event": "PostToolUse",
"config_path": "~/.opencode/plugins/",
"hook_event": "tool.execute.after",
"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",
}