Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
94 changes: 94 additions & 0 deletions docs/integrations/opencode.md
Original file line number Diff line number Diff line change
@@ -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
```
7 changes: 6 additions & 1 deletion docs/integrations/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
185 changes: 185 additions & 0 deletions plugins/marginal-opencode/marginal.js
Original file line number Diff line number Diff line change
@@ -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
Binary file modified plugins/marginal/runtime/marginal_runtime.pyz
Binary file not shown.
2 changes: 1 addition & 1 deletion plugins/marginal/runtime/provenance.json
Original file line number Diff line number Diff line change
@@ -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"}
26 changes: 24 additions & 2 deletions src/marginal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,15 @@ 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)
install_parser.add_argument("--autopilot-consent", action="store_true")
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)
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand Down
Loading