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 3f44428..c3bed70 100644 Binary files a/plugins/marginal/runtime/marginal_runtime.pyz and b/plugins/marginal/runtime/marginal_runtime.pyz differ 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