From 61d30cfee9b476c8dacaff82d03bf649d474f428 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 17 Aug 2026 08:12:58 +0000 Subject: [PATCH] feat(opencode): add an Observe adapter and plugin over a stdio bridge Adds an OpenCode integration labeled Observe, built on the shared `marginal.integrations.hookkit` core. OpenCode loads plugins into its own long-running process, so the plugin owns one bridge child process and speaks newline-delimited JSON to it over pipes. There is no socket, port, or token because the only possible caller is the process that spawned it. One bridge governs every session in that process. Tool output never reaches MARGINAL. The plugin sends a SHA-256 digest of the result plus an allowlist of outcome signals, so file content and command output stay inside OpenCode. Outcome observability is honestly weaker than in engines that report success and failure as separate events: the shell tool reports an exit code, most other tools prove nothing, and those outcomes are recorded as unknown rather than assumed successful. A tool that throws produces no completion hook, so its proposal is settled as unobservable when the session closes. Installation copies one file into the engine's plugin directory and uninstall removes it, but only when the file carries MARGINAL's own marker. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +- docs/integrations/opencode.md | 94 +++++++ docs/integrations/overview.md | 7 +- plugins/marginal-opencode/marginal.js | 185 +++++++++++++ plugins/marginal/runtime/marginal_runtime.pyz | Bin 592210 -> 622462 bytes plugins/marginal/runtime/provenance.json | 2 +- src/marginal/cli.py | 26 +- .../integrations/opencode/__init__.py | 60 ++++ src/marginal/integrations/opencode/bridge.py | 260 ++++++++++++++++++ src/marginal/integrations/opencode/events.py | 119 ++++++++ .../integrations/opencode/installer.py | 257 +++++++++++++++++ .../integrations/opencode/normalization.py | 50 ++++ src/marginal/integrations/opencode/targets.py | 85 ++++++ tests/integrations/opencode/__init__.py | 0 tests/integrations/opencode/conftest.py | 92 +++++++ tests/integrations/opencode/test_bridge.py | 253 +++++++++++++++++ tests/integrations/opencode/test_events.py | 86 ++++++ tests/integrations/opencode/test_installer.py | 146 ++++++++++ tests/integrations/opencode/test_plugin.py | 63 +++++ 19 files changed, 1785 insertions(+), 5 deletions(-) create mode 100644 docs/integrations/opencode.md create mode 100644 plugins/marginal-opencode/marginal.js create mode 100644 src/marginal/integrations/opencode/__init__.py create mode 100644 src/marginal/integrations/opencode/bridge.py create mode 100644 src/marginal/integrations/opencode/events.py create mode 100644 src/marginal/integrations/opencode/installer.py create mode 100644 src/marginal/integrations/opencode/normalization.py create mode 100644 src/marginal/integrations/opencode/targets.py create mode 100644 tests/integrations/opencode/__init__.py create mode 100644 tests/integrations/opencode/conftest.py create mode 100644 tests/integrations/opencode/test_bridge.py create mode 100644 tests/integrations/opencode/test_events.py create mode 100644 tests/integrations/opencode/test_installer.py create mode 100644 tests/integrations/opencode/test_plugin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 036d0d7..e737e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,10 @@ All notable changes to MARGINAL are documented here. The project follows Semanti - `marginal.integrations.hookkit`, the engine-independent parts of a hook integration: normalized events, privacy-safe action normalization, conservative structured outcome classification, workspace state evidence that fails open, and session correlation; -- `marginal install claude-code` and `marginal uninstall claude-code`. +- an OpenCode plugin labeled **Observe**, running inside the engine process and speaking + newline-delimited JSON to one bridge child process over pipes. Tool output never reaches MARGINAL: + the plugin forwards a digest plus an allowlist of outcome signals; +- `marginal install claude-code`, `marginal install opencode`, and their `uninstall` counterparts. ### Changed diff --git a/docs/integrations/opencode.md b/docs/integrations/opencode.md new file mode 100644 index 0000000..96973e2 --- /dev/null +++ b/docs/integrations/opencode.md @@ -0,0 +1,94 @@ +# OpenCode Plugin + +Capability label: **Observe**. + +The plugin records normalized tool-call evidence and repeated-work recommendations in a local +Decision Ledger. It never blocks a tool call, never rewrites tool arguments, and never throws out of a +hook. Shadow Mode is the only mode this integration supports today. + +Validated against OpenCode 1.18.18 on Linux. + +## Install + +```bash +marginal install opencode +``` + +That copies one file to `~/.config/opencode/plugins/marginal.js`. Remove it with: + +```bash +marginal uninstall opencode +``` + +Uninstall only deletes a file carrying MARGINAL's own marker, so a hand-written plugin with the same +name is never destroyed. For the same reason, install refuses to overwrite one. + +The plugin needs the `marginal-ai` package importable by the `python3` on your `PATH`: + +```bash +python3 -m pip install --user marginal-ai +``` + +| Variable | Effect | +|---|---| +| `MARGINAL_PYTHON` | interpreter used to run the bridge | +| `MARGINAL_RUNTIME` | directory or zipapp prepended to the bridge's `PYTHONPATH` | +| `MARGINAL_DATA_ROOT` | where the Decision Ledger is written | +| `MARGINAL_PRIVACY_PROFILE` | `safe_telemetry` or `aggregate_export` instead of local-only | +| `MARGINAL_TIMEOUT_MS` | per-request bridge timeout, default 5000 | + +If the bridge cannot start, the plugin registers no hooks at all and OpenCode behaves exactly as if it +were not installed. + +## Architecture + +OpenCode loads plugins into its own long-running process, so the plugin owns one bridge child process +and speaks newline-delimited JSON to it over pipes. There is no socket, no port, and no token, because +the only possible caller is the process that spawned it. One bridge governs every session in that +OpenCode process; tool calls from different sessions interleave and stay separate. + +| Plugin hook | Bridge operation | +|---|---| +| `event` → `session.created` | `session_start` | +| `tool.execute.before` | `tool_start` | +| `tool.execute.after` | `tool_end` | +| `event` → `session.deleted`, `dispose` | `session_end` | + +OpenCode has no guaranteed session-start hook in every mode, so the first observed tool call opens the +session. + +## What it can prove + +OpenCode's shell tool reports an exit code in result metadata, which is a provable success or failure +signal. Most other tools report nothing that separates success from a completed failure, and those +outcomes are recorded as `unknown` rather than assumed successful. + +Two further limits: + +- a tool that throws produces no `tool.execute.after` call, so its proposal is settled as unobservable + when the session closes; +- per-tool token usage is not exposed, so token cost is reported as unavailable rather than as zero. + +This is a weaker evidence surface than an engine that reports success and failure as separate events. +It is enough to detect repeated work and to measure governance overhead; it is not enough to justify +enforcement, and none is attempted. + +## Privacy + +Tool output never leaves the OpenCode process. The plugin sends a SHA-256 digest of the result plus an +allowlist of outcome signals (`exit`, `exit_code`, `exitCode`, `success`, `status`), so file content and +command output never reach MARGINAL, not even in memory. + +Tool arguments do reach the bridge, because semantic identity and verification detection depend on +them. They are hashed into a semantic key and never persisted. The ledger holds digests, generic action +kinds, and tool names. + +`LOCAL_FULL` is the default profile because the ledger stays on the user's machine. Set +`MARGINAL_PRIVACY_PROFILE=safe_telemetry` for keyed pseudonyms. Pseudonymization is not anonymization. +OpenCode's own logs, database, and telemetry are outside this boundary. + +## Reading the evidence + +```bash +marginal ledger-report ~/.local/share/marginal/opencode/ledger/*/*.jsonl +``` diff --git a/docs/integrations/overview.md b/docs/integrations/overview.md index 7f2fdfd..ce673a7 100644 --- a/docs/integrations/overview.md +++ b/docs/integrations/overview.md @@ -80,7 +80,12 @@ recommendations in a local Decision Ledger, declares no control capability, and call. Its outcome evidence is engine-declared, because Claude Code reports success and failure as separate hook events. See [Claude Code plugin](claude-code.md). -OpenCode and GitHub Copilot remain roadmap work. Codex is labeled Tool Enforcement, +The OpenCode plugin is labeled **Observe**. It runs inside the engine process and speaks to one bridge +child process over pipes. Its outcome evidence is weaker than Claude Code's: the shell tool reports an +exit code, most other tools prove nothing, and those outcomes stay `unknown`. See +[OpenCode plugin](opencode.md). + +GitHub Copilot remains roadmap work. Codex is labeled Tool Enforcement, not Full Compute Enforcement, because specialized and hosted tool paths can fall outside local hook coverage. diff --git a/plugins/marginal-opencode/marginal.js b/plugins/marginal-opencode/marginal.js new file mode 100644 index 0000000..47579f9 --- /dev/null +++ b/plugins/marginal-opencode/marginal.js @@ -0,0 +1,185 @@ +// marginal-opencode-plugin +// +// Local-only MARGINAL Shadow Mode observation for OpenCode-compatible CLIs. +// +// The plugin owns one bridge child process and speaks newline-delimited JSON to it. +// It never blocks a tool call, never rewrites arguments, and never throws into +// OpenCode: if the bridge is missing or fails, every hook becomes a no-op. +// +// Tool output never leaves this process. The plugin sends a digest of the result +// plus an allowlist of outcome signals, so MARGINAL never sees file content or +// command output, not even in memory. + +import { createHash } from "node:crypto" +import { spawn } from "node:child_process" + +const ENGINE_TARGET = process.env.MARGINAL_TARGET || "opencode" +const PYTHON = process.env.MARGINAL_PYTHON || "python3" +const REQUEST_TIMEOUT_MS = Number(process.env.MARGINAL_TIMEOUT_MS || 5000) +const OUTCOME_SIGNAL_KEYS = ["exit", "exit_code", "exitCode", "success", "status"] + +const digest = (value) => { + try { + return createHash("sha256").update(JSON.stringify(value ?? null)).digest("hex") + } catch { + return "" + } +} + +// Only signals that distinguish success from a completed failure are forwarded. +// Everything else in metadata is left behind. +const outcomeSignals = (metadata) => { + const signals = {} + if (metadata && typeof metadata === "object") { + for (const key of OUTCOME_SIGNAL_KEYS) { + const value = metadata[key] + if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") { + signals[key] = value + } + } + } + return signals +} + +class Bridge { + constructor(dataRoot) { + this.pending = [] + this.buffer = "" + this.available = false + const args = ["-m", "marginal.integrations.opencode.bridge", "--target", ENGINE_TARGET] + if (dataRoot) args.push("--data-root", dataRoot) + const env = { ...process.env } + if (process.env.MARGINAL_RUNTIME) { + env.PYTHONPATH = env.PYTHONPATH + ? `${process.env.MARGINAL_RUNTIME}:${env.PYTHONPATH}` + : process.env.MARGINAL_RUNTIME + } + try { + this.child = spawn(PYTHON, args, { stdio: ["pipe", "pipe", "ignore"], env }) + } catch { + this.child = null + return + } + this.child.on("error", () => this.fail()) + this.child.on("exit", () => this.fail()) + this.child.stdout.setEncoding("utf8") + this.child.stdout.on("data", (chunk) => this.receive(chunk)) + this.available = true + } + + fail() { + this.available = false + while (this.pending.length) this.pending.shift().resolve(null) + } + + receive(chunk) { + this.buffer += chunk + let newline = this.buffer.indexOf("\n") + while (newline >= 0) { + const line = this.buffer.slice(0, newline) + this.buffer = this.buffer.slice(newline + 1) + const waiter = this.pending.shift() + if (waiter) { + let parsed = null + try { + parsed = JSON.parse(line) + } catch { + parsed = null + } + waiter.resolve(parsed) + } + newline = this.buffer.indexOf("\n") + } + } + + request(operation, payload) { + if (!this.available || !this.child || !this.child.stdin.writable) return Promise.resolve(null) + return new Promise((resolve) => { + const waiter = { resolve } + this.pending.push(waiter) + const timer = setTimeout(() => { + const index = this.pending.indexOf(waiter) + if (index >= 0) this.pending.splice(index, 1) + resolve(null) + }, REQUEST_TIMEOUT_MS) + waiter.resolve = (value) => { + clearTimeout(timer) + resolve(value) + } + try { + this.child.stdin.write(JSON.stringify({ operation, payload }) + "\n") + } catch { + this.fail() + resolve(null) + } + }) + } + + dispose() { + if (!this.child) return + try { + this.child.stdin.end() + } catch { + // the bridge is already gone + } + this.available = false + } +} + +export const MarginalPlugin = async ({ directory, worktree }) => { + const workspace = worktree || directory || process.cwd() + const bridge = new Bridge(process.env.MARGINAL_DATA_ROOT) + if (!bridge.available) return {} + const started = new Set() + + const ensureSession = async (sessionID) => { + if (started.has(sessionID)) return + started.add(sessionID) + await bridge.request("session_start", { session_id: sessionID, workspace }) + } + + return { + event: async ({ event }) => { + if (!event || typeof event.type !== "string") return + const sessionID = event.properties?.sessionID + if (!sessionID) return + if (event.type === "session.created") { + await ensureSession(sessionID) + } else if (event.type === "session.deleted") { + started.delete(sessionID) + await bridge.request("session_end", { session_id: sessionID, workspace }) + } + }, + + "tool.execute.before": async (input, output) => { + await ensureSession(input.sessionID) + await bridge.request("tool_start", { + session_id: input.sessionID, + call_id: input.callID, + tool_name: input.tool, + arguments: output?.args ?? {}, + workspace, + }) + }, + + "tool.execute.after": async (input, output) => { + await bridge.request("tool_end", { + session_id: input.sessionID, + call_id: input.callID, + tool_name: input.tool, + arguments: input.args ?? {}, + evidence_digest: digest({ title: output?.title, output: output?.output, metadata: output?.metadata }), + signals: outcomeSignals(output?.metadata), + }) + }, + + dispose: async () => { + for (const sessionID of started) { + await bridge.request("session_end", { session_id: sessionID, workspace }) + } + bridge.dispose() + }, + } +} + +export default MarginalPlugin diff --git a/plugins/marginal/runtime/marginal_runtime.pyz b/plugins/marginal/runtime/marginal_runtime.pyz index 3f4442802fe280df374a58be8836931e2516667b..c3bed70857f4467cf8681b01d901d66c0c51f8a1 100644 GIT binary patch delta 16777 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 delta 1250 zcmY*YYiLtv7(VCwPIAthRLpd3((76;RI{mTtyZL3!`d-hr?#ttn>rO{nx?s!wM|mf zs8|s#y4D~s%uBbqQk-KJ3bV42RW|7l8N)KV2}3CpHrBf8L^f~D-g8mnIedKI^SsY_ zFV8t=dV2ooiFpHedn}uGrF!Evf)KA6eAKhlE^a&V#r(nd&;Kb7zH?!}C1FoyC*sMM z1{Vzt@Pqc`VS7@f#V*;CymJf>wSw(%m1Lr2rX|x-E>%fzV^>;UEOx`&?AUL3#SJef zD#xw3stIh^=Y||M=a$N@W3Kfn@)RYsVZH8c6pC8ElL&WP=ZpUjAqEXa2`GVqOUE#p( zk4Uj~QkOY->W=oL!5?da<-GraLEUVE)%5|>8}s?SG))`uD@z{fr) zW2G{d_@RM4T%hiS*zboD*6mV30j_(K+wdzd`Q!+Y_3Jn2w55Staa(}Qy|6`R`U9|> z9qrb^(*WhY@q-TPg4BK2Oi7(RANzt(%1>q+=-(i9zbQ{cc3fO6DY#(|br_jrP-pj$ zapSM23woDH+Eg~QhaiWSxYC1D)soTghiY)DE6CI(niQJWWw?`Sj8vqnJ`QT)-?;iraj|>EVJO- z8FG94JDagC0C|`jChJ};!tyYzW)*K?I1H6M_Kg`YtA|-v&G=Y7Y|fPN_z~HGn<8Xv zju#I^DD7G=4o9GhmHE*eg*E)>X&Ki>Nj~|hj9pRcfDOrbElNIpHzH$3J2BNi%Glvj z^7Wr4+D-$F5S4N%uc2oz&A6~yN&o1D>oQKYLp}HZqNyC1tW{>HQm3iswk-H?#};J) z4io0W9T{zIB^Q+#tl+=?qai2yVx)cLiK?9nDhn|yMrAB5FO(Ti$(Y@u6zM(8?I2eU zF}xRp+4xhNl8xRDntRNoGllI+77nY-&&4{E>L5FkP92=;rLZm7pd*Ik9s|8p^qQvd(} diff --git a/plugins/marginal/runtime/provenance.json b/plugins/marginal/runtime/provenance.json index 1e042f2..e6dc26e 100644 --- a/plugins/marginal/runtime/provenance.json +++ b/plugins/marginal/runtime/provenance.json @@ -1 +1 @@ -{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"97a3774d21d4fa2e461cfabe0401ecfd474274fd9550534152cd6a44e5f3945a","source_hash":"e4f8f0486b6d71a20c3e53db928692918332c38789b0a88c4ff004e3f7ea90b5"} +{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"6b616ab35e22de6a6d021b9f3ae4c3da2f84bbe0afda1b895f4a7704e0daccee","source_hash":"001fdf044af0a01863db2a599bd527a4a1f23bd0daa09b699fed73e7f2b353b5"} diff --git a/src/marginal/cli.py b/src/marginal/cli.py index 76a7ba7..55dff38 100644 --- a/src/marginal/cli.py +++ b/src/marginal/cli.py @@ -154,7 +154,7 @@ def _build_parser() -> argparse.ArgumentParser: public_eval.add_argument("--seed", type=int, default=42) install_parser = subparsers.add_parser("install", help="install a native integration") - install_parser.add_argument("target", choices=["codex", "claude-code"]) + install_parser.add_argument("target", choices=["codex", "claude-code", "opencode"]) install_parser.add_argument("--repository", default="SignalLayerLabs/Marginal") install_parser.add_argument("--ref", default="main") install_parser.add_argument("--data-dir", type=Path) @@ -162,7 +162,7 @@ def _build_parser() -> argparse.ArgumentParser: install_parser.add_argument("--json", action="store_true", dest="as_json") uninstall_parser = subparsers.add_parser("uninstall", help="remove a native integration") - uninstall_parser.add_argument("target", choices=["codex", "claude-code"]) + uninstall_parser.add_argument("target", choices=["codex", "claude-code", "opencode"]) uninstall_parser.add_argument("--purge-data", action="store_true") uninstall_parser.add_argument("--yes", action="store_true") uninstall_parser.add_argument("--data-dir", type=Path) @@ -218,6 +218,17 @@ def main(argv: Sequence[str] | None = None) -> int: print(claude_result.message or claude_result.error_code or claude_result.selector) return 0 if claude_result.installed else 1 + if args.target == "opencode": + from .integrations.opencode.installer import install as install_opencode + from .integrations.opencode.targets import resolve_target + + opencode_result = install_opencode(target=resolve_target(args.target)) + if args.as_json: + print(json.dumps(opencode_result.to_dict(), sort_keys=True)) + else: + print(opencode_result.message or opencode_result.error_code or opencode_result.path) + return 0 if opencode_result.installed else 1 + from .integrations.codex.installer import install result = install( @@ -244,6 +255,17 @@ def main(argv: Sequence[str] | None = None) -> int: print(claude_result.message or claude_result.error_code or claude_result.selector) return 0 if not claude_result.installed else 1 + if args.target == "opencode": + from .integrations.opencode.installer import uninstall as uninstall_opencode + from .integrations.opencode.targets import resolve_target + + opencode_result = uninstall_opencode(target=resolve_target(args.target)) + if args.as_json: + print(json.dumps(opencode_result.to_dict(), sort_keys=True)) + else: + print(opencode_result.message or opencode_result.error_code or opencode_result.path) + return 0 if not opencode_result.installed else 1 + from .integrations.codex.commands import default_data_dir, purge_data from .integrations.codex.installer import uninstall diff --git a/src/marginal/integrations/opencode/__init__.py b/src/marginal/integrations/opencode/__init__.py new file mode 100644 index 0000000..a70f03f --- /dev/null +++ b/src/marginal/integrations/opencode/__init__.py @@ -0,0 +1,60 @@ +"""OpenCode integration. + +Capability label: **Observe**. The adapter records normalized tool-call evidence and +repeated-work recommendations in a local Decision Ledger. It never blocks a tool +call and never changes what OpenCode does next. + +OpenCode's plugin runs inside the engine process, so the plugin owns one bridge child +process rather than paying interpreter startup per tool call. Tool output never +reaches MARGINAL: the plugin sends a digest plus an allowlist of outcome signals. + +Outcome observability is weaker here than in engines that report success and failure +as separate events. The shell tool reports an exit code, which is provable; most +other tools report nothing that separates success from a completed failure, and +those are recorded as ``unknown``. A tool that throws produces no completion hook at +all, so its proposal is settled as unobservable when the session closes. +""" + +from .bridge import BridgeService, main, serve +from .events import ( + SessionRequest, + ToolEndRequest, + ToolStartRequest, + parse_request, +) +from .installer import ( + OpenCodeDoctorReport, + OpenCodeInstallation, + bundled_plugin_path, + inspect_opencode, + install, + is_marginal_plugin, + uninstall, +) +from .normalization import ENGINE, classify_outcome, tool_call_end, tool_call_start +from .targets import OPENCODE, TARGETS, OpenCodeTarget, resolve_target + +__all__ = [ + "ENGINE", + "OPENCODE", + "TARGETS", + "BridgeService", + "OpenCodeDoctorReport", + "OpenCodeInstallation", + "OpenCodeTarget", + "SessionRequest", + "ToolEndRequest", + "ToolStartRequest", + "bundled_plugin_path", + "classify_outcome", + "inspect_opencode", + "install", + "is_marginal_plugin", + "main", + "parse_request", + "resolve_target", + "serve", + "tool_call_end", + "tool_call_start", + "uninstall", +] diff --git a/src/marginal/integrations/opencode/bridge.py b/src/marginal/integrations/opencode/bridge.py new file mode 100644 index 0000000..80dbe55 --- /dev/null +++ b/src/marginal/integrations/opencode/bridge.py @@ -0,0 +1,260 @@ +"""Newline-delimited JSON bridge between the OpenCode plugin and MARGINAL. + +The OpenCode plugin lives in a long-running process, so it owns one bridge child +process instead of paying interpreter startup per tool call. The bridge reads one +request per line from stdin and writes one response per line to stdout. + +Requests are only ever accepted from the process that spawned the bridge, over its +own pipes. There is no socket, no port, and no token, because there is no other +possible caller. + +The bridge governs several sessions at once: a single OpenCode process can host more +than one, and tool calls from different sessions interleave. +""" + +from __future__ import annotations + +import json +import sys +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TextIO + +from marginal.privacy import PrivacyProfile + +from ..hookkit.bootstrap import ObserveSession, build_observe_session +from ..hookkit.session import HookSessionRuntime +from .events import ( + OPERATIONS, + SessionRequest, + ToolEndRequest, + ToolStartRequest, + parse_request, +) +from .normalization import ENGINE, tool_call_end, tool_call_start +from .targets import OPENCODE, OpenCodeTarget, resolve_target + +MAX_LINE_BYTES = 256 * 1024 + + +@dataclass(slots=True) +class _Session: + observe: ObserveSession + runtime: HookSessionRuntime + + +def _error(code: str) -> dict[str, Any]: + return {"ok": False, "error_code": code} + + +def _privacy_profile(configured: str) -> PrivacyProfile: + if not configured: + return PrivacyProfile.LOCAL_FULL + try: + return PrivacyProfile(configured.strip().casefold()) + except ValueError: + return PrivacyProfile.LOCAL_FULL + + +class BridgeService: + """Own every governed OpenCode session inside one bridge process.""" + + def __init__( + self, + *, + target: OpenCodeTarget = OPENCODE, + data_root: str | Path | None = None, + privacy_profile: PrivacyProfile | str = PrivacyProfile.LOCAL_FULL, + ) -> None: + self.target = target + self.data_root = Path(data_root) if data_root is not None else target.data_root() + self.privacy_profile = PrivacyProfile(privacy_profile) + self._sessions: dict[str, _Session] = {} + + @property + def session_ids(self) -> tuple[str, ...]: + return tuple(sorted(self._sessions)) + + def handle(self, operation: str, payload: Mapping[str, Any]) -> Mapping[str, Any] | None: + """Dispatch one request. Unknown operations raise rather than pass silently.""" + + if operation not in OPERATIONS: + raise ValueError(f"unsupported bridge operation: {operation}") + if operation == "status": + return { + "engine": self.target.engine, + "sessions": len(self._sessions), + "capability_level": "observe", + } + request = parse_request(operation, payload) + if operation == "session_start" and isinstance(request, SessionRequest): + self._open(request.session_id, request.workspace) + return {"sessions": len(self._sessions)} + if operation == "session_end" and isinstance(request, SessionRequest): + return self._close(request.session_id) + if operation == "tool_start" and isinstance(request, ToolStartRequest): + return self._tool_start(request) + if isinstance(request, ToolEndRequest): + return self._tool_end(request) + raise ValueError(f"payload does not match operation: {operation}") + + def close_all(self) -> None: + for session_id in tuple(self._sessions): + self._close(session_id) + + def _open(self, session_id: str, workspace: str) -> _Session: + existing = self._sessions.get(session_id) + if existing is not None: + return existing + observe = build_observe_session( + engine=self.target.engine, + session_id=session_id, + workspace=workspace, + data_root=self.data_root, + privacy_profile=self.privacy_profile, + privacy_key_path=( + self.data_root / "privacy" / "pseudonym.key" + if self.privacy_profile is PrivacyProfile.SAFE_TELEMETRY + else None + ), + ) + observe.record( + { + "event": "hook_session_start", + "capability_level": observe.runtime.capabilities.level, + "privacy_profile_selected": self.privacy_profile.value, + } + ) + session = _Session( + observe=observe, + runtime=HookSessionRuntime(observe.runtime, workspace=workspace), + ) + self._sessions[session_id] = session + return session + + def _close(self, session_id: str) -> dict[str, Any]: + session = self._sessions.pop(session_id, None) + if session is None: + return {"closed": False} + session.runtime.close() + summary = session.runtime.summary() + session.observe.record( + { + "event": "hook_session_end", + **{f"summary_{name}": value for name, value in summary.items()}, + } + ) + return {"closed": True, **summary} + + def _tool_start(self, request: ToolStartRequest) -> dict[str, Any]: + session = self._sessions.get(request.session_id) + if session is None: + # OpenCode has no guaranteed session-start hook, so the first observed + # tool call opens the session. + session = self._open(request.session_id, request.workspace or str(Path.cwd())) + started = time.perf_counter_ns() + decision = session.runtime.tool_call_start(tool_call_start(request)) + latency_ms = (time.perf_counter_ns() - started) / 1_000_000 + signal = session.runtime.last_no_progress_signal + session.observe.record( + { + "event": "hook_decision", + **(session.runtime.last_action_evidence or {}), + "reason_code": decision.reason_code, + "recommended": decision.recommended, + "recommended_stop": bool(signal and signal.should_recommend_stop), + "no_progress_reason_code": signal.reason_code if signal else "", + "governance_latency_ms": latency_ms, + "enforced": False, + } + ) + # Shadow Mode never asks OpenCode to change what it does next. + return {"allow": True} + + def _tool_end(self, request: ToolEndRequest) -> dict[str, Any]: + session = self._sessions.get(request.session_id) + if session is None: + return {"observed": False} + evidence = session.runtime.action_evidence(request.call_id) or {} + outcome = session.runtime.tool_call_end(tool_call_end(request)) + session.observe.record( + { + "event": "hook_outcome", + **evidence, + "outcome": outcome.value, + "outcome_source": "tool.execute.after", + "duration_ms": request.duration_ms, + } + ) + return {"observed": True, "outcome": outcome.value} + + +def _respond(stream: TextIO, payload: Mapping[str, Any]) -> None: + stream.write(json.dumps(dict(payload), sort_keys=True, separators=(",", ":")) + "\n") + stream.flush() + + +def serve( + service: BridgeService, + *, + source: TextIO, + destination: TextIO, +) -> int: + """Serve requests until stdin closes. A bad request never stops the bridge.""" + + for raw in source: + if len(raw.encode("utf-8", errors="replace")) > MAX_LINE_BYTES: + _respond(destination, _error("MESSAGE_TOO_LARGE")) + continue + line = raw.strip() + if not line: + continue + try: + request = json.loads(line) + except (json.JSONDecodeError, UnicodeDecodeError): + _respond(destination, _error("INVALID_MESSAGE")) + continue + if not isinstance(request, dict): + _respond(destination, _error("INVALID_MESSAGE")) + continue + operation = request.get("operation") + payload = request.get("payload", {}) + if not isinstance(operation, str) or not isinstance(payload, dict): + _respond(destination, _error("INVALID_MESSAGE")) + continue + try: + result = service.handle(operation, payload) + except Exception: + _respond(destination, _error("SERVICE_ERROR")) + continue + _respond(destination, {"ok": True, "result": result}) + service.close_all() + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Entry point the OpenCode plugin spawns.""" + + import argparse + import os + + parser = argparse.ArgumentParser(prog=f"python -m marginal.integrations.{ENGINE}.bridge") + parser.add_argument("--target", default=OPENCODE.name) + parser.add_argument("--data-root") + arguments = parser.parse_args(argv) + try: + target = resolve_target(arguments.target) + except ValueError: + return 2 + service = BridgeService( + target=target, + data_root=arguments.data_root, + privacy_profile=_privacy_profile(os.environ.get("MARGINAL_PRIVACY_PROFILE", "")), + ) + return serve(service, source=sys.stdin, destination=sys.stdout) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/marginal/integrations/opencode/events.py b/src/marginal/integrations/opencode/events.py new file mode 100644 index 0000000..5ed65b6 --- /dev/null +++ b/src/marginal/integrations/opencode/events.py @@ -0,0 +1,119 @@ +"""Strict value objects for the OpenCode plugin bridge protocol. + +The plugin runs inside the OpenCode process and speaks newline-delimited JSON to a +bridge process. Verified against OpenCode 1.18.18. + +The plugin deliberately does not send tool output. It sends a digest of the result +and a small allowlist of outcome signals, so no file content or command output ever +crosses into MARGINAL, not even in memory. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +OPERATIONS = frozenset({"session_start", "tool_start", "tool_end", "session_end", "status"}) + + +@dataclass(frozen=True, slots=True) +class SessionRequest: + """Opening or closing one OpenCode session.""" + + session_id: str + workspace: str + + +@dataclass(frozen=True, slots=True) +class ToolStartRequest: + session_id: str + call_id: str + tool_name: str + arguments: Mapping[str, Any] + workspace: str = "" + + +@dataclass(frozen=True, slots=True) +class ToolEndRequest: + """A completed tool call, described only by digest and outcome signals.""" + + session_id: str + call_id: str + tool_name: str + arguments: Mapping[str, Any] + evidence_digest: str + signals: Mapping[str, Any] + duration_ms: float | None = None + + +OpenCodeRequest = SessionRequest | ToolStartRequest | ToolEndRequest + + +def _required_text(payload: Mapping[str, Any], name: str) -> str: + value = payload.get(name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} must be a non-empty string") + return value + + +def _optional_text(payload: Mapping[str, Any], name: str) -> str: + value = payload.get(name) + if value is None: + return "" + if not isinstance(value, str): + raise ValueError(f"{name} must be a string or null") + return value + + +def _mapping(payload: Mapping[str, Any], name: str) -> dict[str, Any]: + value = payload.get(name) + if value is None: + return {} + if not isinstance(value, Mapping): + raise ValueError(f"{name} must be a mapping") + return dict(value) + + +def _optional_duration(payload: Mapping[str, Any]) -> float | None: + value = payload.get("duration_ms") + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("duration_ms must be a number or null") + duration = float(value) + if not math.isfinite(duration) or duration < 0.0: + raise ValueError("duration_ms must be finite and non-negative") + return duration + + +def parse_request(operation: str, payload: Mapping[str, Any]) -> OpenCodeRequest: + """Parse one bridge request for an operation that carries a payload.""" + + if not isinstance(payload, Mapping): + raise TypeError("bridge payload must be a mapping") + if operation in {"session_start", "session_end"}: + return SessionRequest( + session_id=_required_text(payload, "session_id"), + workspace=_required_text(payload, "workspace"), + ) + if operation == "tool_start": + return ToolStartRequest( + session_id=_required_text(payload, "session_id"), + call_id=_required_text(payload, "call_id"), + tool_name=_required_text(payload, "tool_name"), + arguments=_mapping(payload, "arguments"), + workspace=_optional_text(payload, "workspace"), + ) + if operation == "tool_end": + return ToolEndRequest( + session_id=_required_text(payload, "session_id"), + call_id=_required_text(payload, "call_id"), + tool_name=_required_text(payload, "tool_name"), + arguments=_mapping(payload, "arguments"), + evidence_digest=_optional_text(payload, "evidence_digest"), + signals=_mapping(payload, "signals"), + duration_ms=_optional_duration(payload), + ) + raise ValueError(f"unsupported bridge operation: {operation}") diff --git a/src/marginal/integrations/opencode/installer.py b/src/marginal/integrations/opencode/installer.py new file mode 100644 index 0000000..5c151e0 --- /dev/null +++ b/src/marginal/integrations/opencode/installer.py @@ -0,0 +1,257 @@ +"""Read-only OpenCode discovery and reversible plugin installation. + +OpenCode loads plugins from a directory rather than from a marketplace, so +installation copies one file and uninstallation removes it. Uninstall only ever +deletes a file that carries MARGINAL's own marker, so a hand-written plugin with the +same name is never destroyed. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from .targets import OPENCODE, PLUGIN_FILENAME, PLUGIN_MARKER, OpenCodeTarget + +_VERSION_PATTERN = re.compile(r"(\d+\.\d+\.\d+)") +_MINIMUM_VERSION = (1, 18, 0) + + +@dataclass(frozen=True, slots=True) +class CommandResult: + returncode: int + stdout: str + stderr: str + + +class CommandRunner(Protocol): + def run(self, args: list[str]) -> CommandResult: ... + + +class SubprocessRunner: + """Run the engine without a shell or inherited secret variables.""" + + def __init__(self, *, timeout_seconds: float = 30.0, max_output_bytes: int = 1_048_576): + self.timeout_seconds = timeout_seconds + self.max_output_bytes = max_output_bytes + + def run(self, args: list[str]) -> CommandResult: + environment = { + name: value + for name in ("PATH", "HOME", "XDG_CONFIG_HOME", "LANG", "LC_ALL", "SYSTEMROOT") + if (value := os.environ.get(name)) is not None + } + try: + completed = subprocess.run( + args, + check=False, + capture_output=True, + text=False, + timeout=self.timeout_seconds, + env=environment, + stdin=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return CommandResult(127, "", type(exc).__name__) + stdout = completed.stdout[: self.max_output_bytes].decode("utf-8", errors="replace") + stderr = completed.stderr[: self.max_output_bytes].decode("utf-8", errors="replace") + return CommandResult(completed.returncode, stdout, stderr) + + +@dataclass(frozen=True, slots=True) +class OpenCodeDoctorReport: + target: str + available: bool + version: str + plugins_supported: bool + plugin_installed: bool + capability_level: str + blocking_reasons: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + "target": self.target, + "available": self.available, + "version": self.version, + "plugins_supported": self.plugins_supported, + "plugin_installed": self.plugin_installed, + "capability_level": self.capability_level, + "capability_label": "Observe", + "blocking_reasons": list(self.blocking_reasons), + } + + +@dataclass(frozen=True, slots=True) +class OpenCodeInstallation: + installed: bool + changed: bool + target: str = OPENCODE.name + path: str = "" + error_code: str = "" + message: str = "" + + def to_dict(self) -> dict[str, object]: + return { + "installed": self.installed, + "changed": self.changed, + "target": self.target, + "path": self.path, + "error_code": self.error_code, + "message": self.message, + } + + +def bundled_plugin_path() -> Path: + """Return the plugin source shipped in this repository.""" + + return Path(__file__).resolve().parents[4] / "plugins" / "marginal-opencode" / PLUGIN_FILENAME + + +def _version_tuple(version: str) -> tuple[int, ...]: + try: + return tuple(int(part) for part in version.split(".")) + except ValueError: + return () + + +def is_marginal_plugin(path: Path) -> bool: + """Return True only for a plugin file MARGINAL wrote.""" + + try: + return PLUGIN_MARKER in path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return False + + +def inspect_opencode( + *, + target: OpenCodeTarget = OPENCODE, + runner: CommandRunner | None = None, +) -> OpenCodeDoctorReport: + """Report installation facts. Absence is a normal answer, not an error.""" + + executable = shutil.which(target.executable) + installed = is_marginal_plugin(target.plugin_path()) + if executable is None: + return OpenCodeDoctorReport( + target=target.name, + available=False, + version="", + plugins_supported=False, + plugin_installed=installed, + capability_level="none", + blocking_reasons=(f"{target.name.upper().replace('-', '_')}_NOT_FOUND",), + ) + selected = runner or SubprocessRunner() + result = selected.run([executable, "--version"]) + match = _VERSION_PATTERN.search(result.stdout or result.stderr) + version = match.group(1) if match else "" + reasons: list[str] = [] + if result.returncode != 0: + reasons.append("VERSION_UNAVAILABLE") + parsed = _version_tuple(version) + plugins_supported = bool(parsed) and parsed >= _MINIMUM_VERSION + if not plugins_supported: + reasons.append("PLUGIN_SUPPORT_UNVERIFIED") + return OpenCodeDoctorReport( + target=target.name, + available=True, + version=version, + plugins_supported=plugins_supported, + plugin_installed=installed, + capability_level="observe" if plugins_supported else "none", + blocking_reasons=tuple(reasons), + ) + + +def install( + *, + target: OpenCodeTarget = OPENCODE, + source: str | Path | None = None, +) -> OpenCodeInstallation: + """Copy the plugin into the engine's global plugin directory.""" + + origin = Path(source) if source is not None else bundled_plugin_path() + if not origin.is_file(): + return OpenCodeInstallation( + installed=False, + changed=False, + target=target.name, + error_code="PLUGIN_SOURCE_MISSING", + message=str(origin), + ) + destination = target.plugin_path() + if destination.exists() and not is_marginal_plugin(destination): + return OpenCodeInstallation( + installed=False, + changed=False, + target=target.name, + path=str(destination), + error_code="FOREIGN_PLUGIN_PRESENT", + message="a different plugin already owns this filename", + ) + try: + destination.parent.mkdir(parents=True, exist_ok=True) + payload = origin.read_text(encoding="utf-8") + changed = not destination.exists() or destination.read_text(encoding="utf-8") != payload + if changed: + destination.write_text(payload, encoding="utf-8") + except OSError as exc: + return OpenCodeInstallation( + installed=False, + changed=False, + target=target.name, + path=str(destination), + error_code="INSTALL_FAILED", + message=type(exc).__name__, + ) + return OpenCodeInstallation( + installed=True, + changed=changed, + target=target.name, + path=str(destination), + ) + + +def uninstall(*, target: OpenCodeTarget = OPENCODE) -> OpenCodeInstallation: + """Remove only a plugin file MARGINAL wrote. An absent plugin is a success.""" + + destination = target.plugin_path() + if not destination.exists(): + return OpenCodeInstallation( + installed=False, + changed=False, + target=target.name, + path=str(destination), + ) + if not is_marginal_plugin(destination): + return OpenCodeInstallation( + installed=False, + changed=False, + target=target.name, + path=str(destination), + error_code="FOREIGN_PLUGIN_PRESENT", + message="refusing to remove a plugin MARGINAL did not write", + ) + try: + destination.unlink() + except OSError as exc: + return OpenCodeInstallation( + installed=True, + changed=False, + target=target.name, + path=str(destination), + error_code="UNINSTALL_FAILED", + message=type(exc).__name__, + ) + return OpenCodeInstallation( + installed=False, + changed=True, + target=target.name, + path=str(destination), + ) diff --git a/src/marginal/integrations/opencode/normalization.py b/src/marginal/integrations/opencode/normalization.py new file mode 100644 index 0000000..c8f09f4 --- /dev/null +++ b/src/marginal/integrations/opencode/normalization.py @@ -0,0 +1,50 @@ +"""Map OpenCode bridge requests onto the engine-neutral hook contract. + +OpenCode has no per-tool success flag. Its shell tool reports an exit code in +result metadata, which is a provable signal; most other tools report nothing that +distinguishes success from a completed failure. Those stay ``UNKNOWN``. + +A tool that throws produces no ``tool.execute.after`` call at all, so its proposal +is never completed and is settled as unobservable when the session closes. +""" + +from __future__ import annotations + +from marginal.controls import ActionOutcomeStatus + +from ..hookkit.events import ToolCallEnd, ToolCallStart +from ..hookkit.outcomes import classify_structured_result +from .events import ToolEndRequest, ToolStartRequest + +ENGINE = "opencode" + + +def tool_call_start(request: ToolStartRequest) -> ToolCallStart: + if not isinstance(request, ToolStartRequest): + raise TypeError("request must be a ToolStartRequest") + return ToolCallStart( + session_id=request.session_id, + call_id=request.call_id, + tool_name=request.tool_name, + tool_input=request.arguments, + ) + + +def classify_outcome(request: ToolEndRequest) -> ActionOutcomeStatus: + """Classify only the outcome signals the plugin was able to prove.""" + + if not isinstance(request, ToolEndRequest): + raise TypeError("request must be a ToolEndRequest") + return classify_structured_result(dict(request.signals)) + + +def tool_call_end(request: ToolEndRequest) -> ToolCallEnd: + return ToolCallEnd( + session_id=request.session_id, + call_id=request.call_id, + tool_name=request.tool_name, + outcome=classify_outcome(request), + tool_input=request.arguments, + evidence={"digest": request.evidence_digest} if request.evidence_digest else None, + duration_ms=request.duration_ms, + ) diff --git a/src/marginal/integrations/opencode/targets.py b/src/marginal/integrations/opencode/targets.py new file mode 100644 index 0000000..ff608ad --- /dev/null +++ b/src/marginal/integrations/opencode/targets.py @@ -0,0 +1,85 @@ +"""OpenCode-compatible engines MARGINAL can install into. + +OpenCode's plugin loader is reused by compatible forks. A target records only what +differs between them: the executable name, where global configuration lives, and +where the ledger belongs. Nothing about the governance contract is per-target. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +PLUGIN_FILENAME = "marginal.js" +PLUGIN_MARKER = "marginal-opencode-plugin" +"""Marker the installer writes so uninstall only ever removes its own file.""" + + +@dataclass(frozen=True, slots=True) +class OpenCodeTarget: + """One OpenCode-compatible CLI installation layout.""" + + name: str + engine: str + executable: str + config_directory_name: str + data_directory_name: str + + def __post_init__(self) -> None: + for field_name in ( + "name", + "engine", + "executable", + "config_directory_name", + "data_directory_name", + ): + value = getattr(self, field_name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + def config_home(self) -> Path: + """Return the global configuration directory this CLI reads.""" + + configured = os.environ.get("XDG_CONFIG_HOME") + base = Path(configured) if configured else Path.home() / ".config" + return base / self.config_directory_name + + def plugin_directory(self) -> Path: + """Return the global plugin directory this CLI loads plugins from.""" + + return self.config_home() / "plugins" + + def plugin_path(self) -> Path: + return self.plugin_directory() / PLUGIN_FILENAME + + def data_root(self) -> Path: + """Return the directory the Decision Ledger is written under.""" + + override = os.environ.get(f"MARGINAL_{self.name.upper().replace('-', '_')}_DATA") + if override: + return Path(override) + configured = os.environ.get("XDG_DATA_HOME") + base = Path(configured) if configured else Path.home() / ".local" / "share" + return base / "marginal" / self.data_directory_name + + +OPENCODE = OpenCodeTarget( + name="opencode", + engine="opencode", + executable="opencode", + config_directory_name="opencode", + data_directory_name="opencode", +) + +TARGETS: dict[str, OpenCodeTarget] = {OPENCODE.name: OPENCODE} + + +def resolve_target(name: str) -> OpenCodeTarget: + """Return a supported target by name.""" + + try: + return TARGETS[name.strip().casefold()] + except (AttributeError, KeyError) as exc: + supported = ", ".join(sorted(TARGETS)) + raise ValueError(f"unsupported target {name!r}; supported targets: {supported}") from exc diff --git a/tests/integrations/opencode/__init__.py b/tests/integrations/opencode/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integrations/opencode/conftest.py b/tests/integrations/opencode/conftest.py new file mode 100644 index 0000000..ff7761d --- /dev/null +++ b/tests/integrations/opencode/conftest.py @@ -0,0 +1,92 @@ +"""Shared fixtures for the OpenCode integration tests. + +Payload shapes mirror OpenCode 1.18.18. Every identifier and value is synthetic. +""" + +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +SESSION_ID = "ses_synthetic0001" + + +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "HOME": str(repo), + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }, + ) + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + directory = tmp_path / "workspace" + directory.mkdir() + _git(directory, "init", "-q") + (directory / "example.txt").write_text("hello\n", encoding="utf-8") + _git(directory, "add", "example.txt") + _git(directory, "commit", "-qm", "initial") + return directory + + +@pytest.fixture +def requests(workspace: Path) -> "RequestFactory": + return RequestFactory(workspace) + + +class RequestFactory: + """Build bridge requests in the shape the plugin sends them.""" + + def __init__(self, workspace: Path) -> None: + self.workspace = workspace + + def session(self) -> dict[str, Any]: + return {"session_id": SESSION_ID, "workspace": str(self.workspace)} + + def tool_start( + self, + call_id: str = "call_synthetic0001", + *, + tool_name: str = "read", + arguments: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return { + "session_id": SESSION_ID, + "call_id": call_id, + "tool_name": tool_name, + "arguments": arguments or {"filePath": "./example.txt"}, + "workspace": str(self.workspace), + } + + def tool_end( + self, + call_id: str = "call_synthetic0001", + *, + tool_name: str = "read", + arguments: dict[str, Any] | None = None, + evidence_digest: str = "a" * 64, + signals: dict[str, Any] | None = None, + duration_ms: float | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "session_id": SESSION_ID, + "call_id": call_id, + "tool_name": tool_name, + "arguments": arguments or {"filePath": "./example.txt"}, + "evidence_digest": evidence_digest, + "signals": signals if signals is not None else {}, + } + if duration_ms is not None: + payload["duration_ms"] = duration_ms + return payload diff --git a/tests/integrations/opencode/test_bridge.py b/tests/integrations/opencode/test_bridge.py new file mode 100644 index 0000000..2ad536b --- /dev/null +++ b/tests/integrations/opencode/test_bridge.py @@ -0,0 +1,253 @@ +import io +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from marginal.controls import ActionOutcomeStatus +from marginal.integrations.opencode.bridge import BridgeService, serve +from marginal.ledger import read_decision_ledger +from marginal.privacy import PrivacyProfile + +from .conftest import SESSION_ID, RequestFactory + + +@pytest.fixture +def service(tmp_path: Path) -> BridgeService: + return BridgeService(data_root=tmp_path / "data") + + +def _records(service: BridgeService) -> list[dict[str, object]]: + files = sorted(service.data_root.rglob("*.jsonl")) + assert files, "the bridge must write exactly one decision ledger per session" + return read_decision_ledger(files[0]) + + +def test_status_reports_an_observe_capability(service: BridgeService) -> None: + status = service.handle("status", {}) + assert status == {"engine": "opencode", "sessions": 0, "capability_level": "observe"} + + +def test_a_full_lifecycle_is_recorded(service: BridgeService, requests: RequestFactory) -> None: + service.handle("session_start", requests.session()) + assert service.handle("tool_start", requests.tool_start()) == {"allow": True} + result = service.handle("tool_end", requests.tool_end(signals={"exit": 0})) + assert result == {"observed": True, "outcome": "success"} + service.handle("session_end", requests.session()) + events = [record["event"] for record in _records(service)] + assert events[0] == "hook_session_start" + assert "hook_decision" in events + assert "hook_outcome" in events + assert events[-1] == "hook_session_end" + + +def test_shadow_mode_always_allows(service: BridgeService, requests: RequestFactory) -> None: + service.handle("session_start", requests.session()) + for index in range(4): + call_id = f"call_synthetic000{index}" + assert service.handle("tool_start", requests.tool_start(call_id)) == {"allow": True} + service.handle("tool_end", requests.tool_end(call_id, signals={"exit": 0})) + decisions = [r for r in _records(service) if r["event"] == "hook_decision"] + assert decisions[0]["recommended"] is True + assert decisions[-1]["recommended"] is False + assert decisions[-1]["recommended_stop"] is True + assert all(record["enforced"] is False for record in decisions) + + +def test_the_first_tool_call_opens_an_unannounced_session( + service: BridgeService, requests: RequestFactory +) -> None: + service.handle("tool_start", requests.tool_start()) + assert service.session_ids == (SESSION_ID,) + + +def test_a_shell_exit_code_proves_an_outcome( + service: BridgeService, requests: RequestFactory +) -> None: + service.handle("session_start", requests.session()) + service.handle( + "tool_start", requests.tool_start(tool_name="bash", arguments={"command": "exit 3"}) + ) + result = service.handle( + "tool_end", + requests.tool_end(tool_name="bash", arguments={"command": "exit 3"}, signals={"exit": 3}), + ) + assert result == {"observed": True, "outcome": "failure"} + + +def test_a_tool_without_signals_stays_unknown( + service: BridgeService, requests: RequestFactory +) -> None: + service.handle("session_start", requests.session()) + service.handle("tool_start", requests.tool_start()) + result = service.handle("tool_end", requests.tool_end(signals={})) + assert result == {"observed": True, "outcome": "unknown"} + outcomes = [r for r in _records(service) if r["event"] == "hook_outcome"] + assert outcomes[-1]["outcome"] == "unknown" + + +def test_interleaved_sessions_stay_separate( + service: BridgeService, requests: RequestFactory, workspace: Path +) -> None: + other = {"session_id": "ses_synthetic0002", "workspace": str(workspace)} + service.handle("session_start", requests.session()) + service.handle("session_start", other) + service.handle("tool_start", requests.tool_start("call_a")) + service.handle( + "tool_start", + {**requests.tool_start("call_b"), "session_id": "ses_synthetic0002"}, + ) + assert len(service.session_ids) == 2 + service.handle("session_end", other) + assert service.session_ids == (SESSION_ID,) + assert len(sorted(service.data_root.rglob("*.jsonl"))) == 2 + + +def test_a_completion_for_an_unknown_session_is_reported( + service: BridgeService, requests: RequestFactory +) -> None: + assert service.handle("tool_end", requests.tool_end()) == {"observed": False} + + +def test_closing_an_unknown_session_is_harmless(service: BridgeService, workspace: Path) -> None: + result = service.handle( + "session_end", {"session_id": "ses_synthetic9999", "workspace": str(workspace)} + ) + assert result == {"closed": False} + + +def test_close_all_settles_every_session(service: BridgeService, requests: RequestFactory) -> None: + service.handle("tool_start", requests.tool_start()) + service.close_all() + assert service.session_ids == () + summaries = [r for r in _records(service) if r["event"] == "hook_session_end"] + assert summaries[-1]["summary_unknown_observations"] == 1 + + +def test_an_unsupported_operation_is_rejected(service: BridgeService) -> None: + with pytest.raises(ValueError): + service.handle("enforce", {}) + + +def test_a_payload_that_does_not_match_its_operation_is_rejected( + service: BridgeService, +) -> None: + with pytest.raises(ValueError): + service.handle("tool_start", {"session_id": "ses_synthetic0001"}) + + +def test_the_line_protocol_answers_every_request(tmp_path: Path, requests: RequestFactory) -> None: + service = BridgeService(data_root=tmp_path / "data") + lines = [ + json.dumps({"operation": "status", "payload": {}}), + "not json", + json.dumps({"operation": "enforce", "payload": {}}), + json.dumps(["not", "an", "object"]), + "", + json.dumps({"operation": "tool_start", "payload": requests.tool_start()}), + ] + destination = io.StringIO() + assert serve(service, source=io.StringIO("\n".join(lines) + "\n"), destination=destination) == 0 + responses = [json.loads(line) for line in destination.getvalue().splitlines()] + assert responses[0]["ok"] is True + assert responses[1] == {"ok": False, "error_code": "INVALID_MESSAGE"} + assert responses[2] == {"ok": False, "error_code": "SERVICE_ERROR"} + assert responses[3] == {"ok": False, "error_code": "INVALID_MESSAGE"} + assert responses[4]["result"] == {"allow": True} + assert service.session_ids == () + + +def test_an_oversized_line_is_rejected(tmp_path: Path) -> None: + service = BridgeService(data_root=tmp_path / "data") + destination = io.StringIO() + serve(service, source=io.StringIO("x" * 300_000 + "\n"), destination=destination) + assert json.loads(destination.getvalue()) == {"ok": False, "error_code": "MESSAGE_TOO_LARGE"} + + +def test_a_strict_profile_pseudonymizes_the_session_identity( + tmp_path: Path, requests: RequestFactory +) -> None: + service = BridgeService( + data_root=tmp_path / "data", privacy_profile=PrivacyProfile.SAFE_TELEMETRY + ) + service.handle("session_start", requests.session()) + service.handle("tool_start", requests.tool_start()) + contents = sorted(service.data_root.rglob("*.jsonl"))[0].read_text(encoding="utf-8") + assert SESSION_ID not in contents + assert "safe_telemetry" in contents + + +def test_the_ledger_holds_no_arguments_or_digested_output( + service: BridgeService, requests: RequestFactory, workspace: Path +) -> None: + service.handle("session_start", requests.session()) + service.handle( + "tool_start", + requests.tool_start(tool_name="bash", arguments={"command": "cat /etc/passwd"}), + ) + service.handle( + "tool_end", + requests.tool_end( + tool_name="bash", + arguments={"command": "cat /etc/passwd"}, + signals={"exit": 0}, + ), + ) + contents = sorted(service.data_root.rglob("*.jsonl"))[0].read_text(encoding="utf-8") + assert "/etc/passwd" not in contents + assert str(workspace) not in contents + + +def test_the_module_entry_point_serves_stdin(tmp_path: Path, requests: RequestFactory) -> None: + completed = subprocess.run( + [ + sys.executable, + "-m", + "marginal.integrations.opencode.bridge", + "--data-root", + str(tmp_path / "data"), + ], + input="\n".join( + [ + json.dumps({"operation": "session_start", "payload": requests.session()}), + json.dumps({"operation": "tool_start", "payload": requests.tool_start()}), + json.dumps( + { + "operation": "tool_end", + "payload": requests.tool_end(signals={"exit": 0}, duration_ms=8.0), + } + ), + ] + ) + + "\n", + capture_output=True, + text=True, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "PYTHONPATH": str(Path(__file__).resolve().parents[3] / "src"), + "HOME": str(tmp_path), + }, + check=False, + ) + assert completed.returncode == 0 + responses = [json.loads(line) for line in completed.stdout.splitlines()] + assert all(response["ok"] is True for response in responses) + assert responses[-1]["result"]["outcome"] == "success" + records = read_decision_ledger(sorted((tmp_path / "data").rglob("*.jsonl"))[0]) + assert [record["event"] for record in records][-1] == "hook_session_end" + + +def test_an_unknown_target_is_rejected_by_the_entry_point() -> None: + from marginal.integrations.opencode.bridge import main + + assert main(["--target", "not-a-real-engine"]) == 2 + + +def test_outcome_classification_is_available_directly(requests: RequestFactory) -> None: + from marginal.integrations.opencode import classify_outcome + from marginal.integrations.opencode.events import parse_request + + request = parse_request("tool_end", requests.tool_end(signals={"exit": 0})) + assert classify_outcome(request) is ActionOutcomeStatus.SUCCESS # type: ignore[arg-type] diff --git a/tests/integrations/opencode/test_events.py b/tests/integrations/opencode/test_events.py new file mode 100644 index 0000000..5f37b06 --- /dev/null +++ b/tests/integrations/opencode/test_events.py @@ -0,0 +1,86 @@ +import pytest + +from marginal.integrations.opencode.events import ( + SessionRequest, + ToolEndRequest, + ToolStartRequest, + parse_request, +) + +from .conftest import RequestFactory + + +def test_a_session_request_is_parsed(requests: RequestFactory) -> None: + request = parse_request("session_start", requests.session()) + assert isinstance(request, SessionRequest) + assert request.session_id == "ses_synthetic0001" + + +def test_a_tool_start_request_is_parsed(requests: RequestFactory) -> None: + request = parse_request("tool_start", requests.tool_start()) + assert isinstance(request, ToolStartRequest) + assert request.tool_name == "read" + assert request.arguments == {"filePath": "./example.txt"} + + +def test_a_tool_end_request_is_parsed(requests: RequestFactory) -> None: + payload = requests.tool_end(signals={"exit": 0}, duration_ms=9.5) + request = parse_request("tool_end", payload) + assert isinstance(request, ToolEndRequest) + assert request.evidence_digest == "a" * 64 + assert request.signals == {"exit": 0} + assert request.duration_ms == 9.5 + + +def test_a_tool_end_request_tolerates_missing_evidence(requests: RequestFactory) -> None: + payload = requests.tool_end() + del payload["evidence_digest"] + request = parse_request("tool_end", payload) + assert isinstance(request, ToolEndRequest) + assert request.evidence_digest == "" + + +@pytest.mark.parametrize("field", ["session_id", "call_id", "tool_name"]) +def test_required_identity_is_enforced(requests: RequestFactory, field: str) -> None: + payload = requests.tool_start() + payload[field] = "" + with pytest.raises(ValueError): + parse_request("tool_start", payload) + + +def test_arguments_must_be_a_mapping(requests: RequestFactory) -> None: + payload = requests.tool_start() + payload["arguments"] = ["not", "a", "mapping"] + with pytest.raises(ValueError): + parse_request("tool_start", payload) + + +def test_signals_must_be_a_mapping(requests: RequestFactory) -> None: + payload = requests.tool_end() + payload["signals"] = "exit=0" + with pytest.raises(ValueError): + parse_request("tool_end", payload) + + +def test_a_malformed_duration_is_rejected(requests: RequestFactory) -> None: + payload = requests.tool_end(duration_ms=1.0) + payload["duration_ms"] = "fast" + with pytest.raises(ValueError): + parse_request("tool_end", payload) + + +def test_a_negative_duration_is_rejected(requests: RequestFactory) -> None: + payload = requests.tool_end() + payload["duration_ms"] = -1.0 + with pytest.raises(ValueError): + parse_request("tool_end", payload) + + +def test_an_unsupported_operation_is_rejected(requests: RequestFactory) -> None: + with pytest.raises(ValueError): + parse_request("deny", requests.tool_start()) + + +def test_a_non_mapping_payload_is_rejected() -> None: + with pytest.raises(TypeError): + parse_request("tool_start", ["not", "a", "mapping"]) # type: ignore[arg-type] diff --git a/tests/integrations/opencode/test_installer.py b/tests/integrations/opencode/test_installer.py new file mode 100644 index 0000000..309d117 --- /dev/null +++ b/tests/integrations/opencode/test_installer.py @@ -0,0 +1,146 @@ +from pathlib import Path + +import pytest + +from marginal.integrations.opencode import installer +from marginal.integrations.opencode.targets import ( + OPENCODE, + PLUGIN_MARKER, + TARGETS, + resolve_target, +) + + +@pytest.fixture +def config_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + return tmp_path / "config" + + +class FakeRunner: + def __init__(self, result: installer.CommandResult) -> None: + self.result = result + + def run(self, args: list[str]) -> installer.CommandResult: + return self.result + + +def test_the_bundled_plugin_ships_with_its_marker() -> None: + source = installer.bundled_plugin_path() + assert source.is_file() + assert PLUGIN_MARKER in source.read_text(encoding="utf-8") + + +def test_targets_resolve_by_name() -> None: + assert resolve_target("opencode") is OPENCODE + assert resolve_target(" OpenCode ") is OPENCODE + with pytest.raises(ValueError): + resolve_target("not-a-real-engine") + assert set(TARGETS) >= {"opencode"} + + +def test_install_is_idempotent_and_reversible(config_home: Path) -> None: + first = installer.install() + assert first.installed is True + assert first.changed is True + plugin = Path(first.path) + assert plugin.is_file() + assert plugin.parent == config_home / "opencode" / "plugins" + + second = installer.install() + assert second.installed is True + assert second.changed is False + + removal = installer.uninstall() + assert removal.installed is False + assert removal.changed is True + assert not plugin.exists() + + +def test_uninstalling_an_absent_plugin_is_a_success(config_home: Path) -> None: + result = installer.uninstall() + assert result.installed is False + assert result.changed is False + assert result.error_code == "" + + +def test_a_foreign_plugin_is_never_overwritten_or_removed(config_home: Path) -> None: + plugin = OPENCODE.plugin_path() + plugin.parent.mkdir(parents=True, exist_ok=True) + plugin.write_text("export const Mine = async () => ({})\n", encoding="utf-8") + + blocked = installer.install() + assert blocked.installed is False + assert blocked.error_code == "FOREIGN_PLUGIN_PRESENT" + + refused = installer.uninstall() + assert refused.error_code == "FOREIGN_PLUGIN_PRESENT" + assert plugin.read_text(encoding="utf-8").startswith("export const Mine") + + +def test_a_missing_source_is_reported(config_home: Path, tmp_path: Path) -> None: + result = installer.install(source=tmp_path / "absent.js") + assert result.installed is False + assert result.error_code == "PLUGIN_SOURCE_MISSING" + + +def test_the_data_root_is_configurable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share")) + assert OPENCODE.data_root() == tmp_path / "share" / "marginal" / "opencode" + monkeypatch.setenv("MARGINAL_OPENCODE_DATA", str(tmp_path / "elsewhere")) + assert OPENCODE.data_root() == tmp_path / "elsewhere" + + +def test_a_missing_cli_is_a_normal_answer( + config_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(installer.shutil, "which", lambda name: None) + report = installer.inspect_opencode() + assert report.available is False + assert report.capability_level == "none" + assert report.blocking_reasons == ("OPENCODE_NOT_FOUND",) + + +def test_a_supported_version_reports_observe( + config_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(installer.shutil, "which", lambda name: f"/usr/bin/{name}") + runner = FakeRunner(installer.CommandResult(0, "1.18.18\n", "")) + report = installer.inspect_opencode(runner=runner) + assert report.available is True + assert report.version == "1.18.18" + assert report.capability_level == "observe" + assert report.plugin_installed is False + assert report.to_dict()["capability_label"] == "Observe" + + +def test_an_old_version_blocks_installation( + config_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(installer.shutil, "which", lambda name: f"/usr/bin/{name}") + runner = FakeRunner(installer.CommandResult(0, "1.2.3\n", "")) + report = installer.inspect_opencode(runner=runner) + assert report.plugins_supported is False + assert "PLUGIN_SUPPORT_UNVERIFIED" in report.blocking_reasons + + +def test_an_installed_plugin_is_reported( + config_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + installer.install() + monkeypatch.setattr(installer.shutil, "which", lambda name: f"/usr/bin/{name}") + report = installer.inspect_opencode( + runner=FakeRunner(installer.CommandResult(0, "1.18.18", "")) + ) + assert report.plugin_installed is True + + +def test_the_subprocess_runner_never_inherits_secrets(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "must-not-be-inherited") + result = installer.SubprocessRunner().run(["/usr/bin/env"]) + assert "must-not-be-inherited" not in result.stdout + + +def test_the_subprocess_runner_reports_a_missing_binary() -> None: + assert installer.SubprocessRunner().run(["/nonexistent/marginal-test"]).returncode == 127 diff --git a/tests/integrations/opencode/test_plugin.py b/tests/integrations/opencode/test_plugin.py new file mode 100644 index 0000000..6a660db --- /dev/null +++ b/tests/integrations/opencode/test_plugin.py @@ -0,0 +1,63 @@ +"""The shipped OpenCode plugin must keep its safety properties. + +The plugin is JavaScript, so these are source-level contract checks rather than +behavioral tests. The bridge protocol it speaks is covered in test_bridge.py. +""" + +from marginal.integrations.opencode.installer import bundled_plugin_path +from marginal.integrations.opencode.targets import PLUGIN_MARKER + + +def _source() -> str: + return bundled_plugin_path().read_text(encoding="utf-8") + + +def _statements() -> list[str]: + """Return source lines with comment-only lines removed.""" + + return [ + line + for line in _source().splitlines() + if not line.strip().startswith("//") and line.strip() + ] + + +def test_the_plugin_carries_its_ownership_marker() -> None: + assert PLUGIN_MARKER in _source() + + +def test_the_plugin_registers_only_observation_hooks() -> None: + source = _source() + assert '"tool.execute.before"' in source + assert '"tool.execute.after"' in source + assert "dispose" in source + # Blocking in OpenCode means throwing out of a hook, and Shadow Mode never does. + assert not [line for line in _statements() if "throw" in line] + assert "permission.ask" not in source + + +def test_the_plugin_never_forwards_tool_output() -> None: + assert "evidence_digest: digest(" in _source() + # Tool output is only ever read inside the digest call. + for line in _statements(): + if "output?.output" in line: + assert "digest(" in line + + +def test_the_plugin_forwards_only_allowlisted_outcome_signals() -> None: + source = _source() + assert 'OUTCOME_SIGNAL_KEYS = ["exit", "exit_code", "exitCode", "success", "status"]' in source + assert "signals: outcomeSignals(output?.metadata)" in source + + +def test_the_plugin_spawns_the_documented_bridge_module() -> None: + source = _source() + assert '"-m", "marginal.integrations.opencode.bridge"' in source + assert "MARGINAL_PYTHON" in source + assert "MARGINAL_RUNTIME" in source + + +def test_the_plugin_becomes_a_no_op_when_the_bridge_is_unavailable() -> None: + source = _source() + assert "if (!bridge.available) return {}" in source + assert "REQUEST_TIMEOUT_MS" in source