feat(integrations): OpenCode plugin — execd-side receipt emission for native tool calls#766
Open
ojongerius wants to merge 8 commits into
Open
feat(integrations): OpenCode plugin — execd-side receipt emission for native tool calls#766ojongerius wants to merge 8 commits into
ojongerius wants to merge 8 commits into
Conversation
The daemon already accepts an `action_type` frame field and prefers it over the synthetic `<channel>.<tool>` fallback (which defaults risk to medium), but the TS DaemonEmitter had no way to send it. Add an optional `EmitEvent.actionType` that maps to the frame `action_type`, so emitters that already know the taxonomic action type — such as the OpenCode plugin — can make risk-based controls effective. The daemon still re-derives risk_level from the type, so an emitter cannot downgrade risk. Additive and backwards-compatible: omitting the field preserves prior behaviour. Not a receipt-format change (the daemon already emitted these receipts). Adds round-trip tests asserting the field is present when set and absent when omitted.
…ssion New `@agent-receipts/opencode-plugin` package — the OpenCode analog of the Claude Code hook integration, covering the native-tool channel (Tier B). Hooks `tool.execute.before`/`tool.execute.after` and emits one daemon-signed Agent Receipt per native tool call (bash/edit/write/webfetch/…) via the TS SDK DaemonEmitter. Trust boundary (load-bearing): the plugin runs inside the OpenCode process, so it is an emitter only — it never instantiates a signer, signs, or holds a key (ADR-0010 daemon-sole-writer). This is the execd-side, honest-operator placement: max coverage, not adversary-resistant. The mcp-proxy MCP placement (Tier A) is the adversary-resistant one. Code, labels, and docs do not claim otherwise. - Action mapping: OpenCode tool names → AR taxonomy, forwarded as action_type; the daemon re-derives risk so mislabelling cannot downgrade it. Unmapped tools fall back to the daemon's <channel>.<tool>. - Chain mapping: one DaemonEmitter per sessionID so receipts carry the session id. Per-agent sub-chains with delegation (#753) are a filed follow-up — the tool.execute hook context exposes only { tool, sessionID, callID }, not a named-agent identity, so the keying cannot be derived without guessing. - Failure posture (ADR-0025): default catch-and-warn never aborts a tool call; strict re-throws. Best-effort ⇒ possible chain gaps ⇒ honest-operator-grade, surfaced by `agent-receipts verify`. - Config: env vars and createAgentReceiptsPlugin(config) — socket, channel, strict, tool allow/deny, action-type overrides. Tests: unit tests (mapping, filtering, intent/params bridging, per-session emitters, strict/default posture) plus a round-trip test driving the real DaemonEmitter against a fake daemon socket and asserting wire-frame shape. Docs: OpenCode section (overview, plugin installation + end-to-end verify walkthrough, Tier A mcp-proxy config) with site nav updated. Daemon- connecting TS snippets carry the no-run snippet directive.
The release commit bumped pyproject to 0.12.0a1 but left uv.lock recording 0.11.1. The pre-push uv hook regenerates the lockfile and surfaces this drift; commit the one-line sync so the working tree stays clean. Incidental to this branch — no dependency or functional change.
- pendingArgs leak: a `tool.execute.before` with no matching `after` (an aborted/cancelled tool call) previously leaked its entry for the life of the process — the map was keyed by callID, so closeSession could not reclaim it. Store the sessionID alongside each pending intent and evict a session's orphaned intents in closeSession. Adds a regression test. - Session teardown: stop closing the per-session emitter on `session.idle`. OpenCode fires session.idle after every turn (not at session end), so closing there churned sockets and could tear a connection down mid-emit (a spurious transport failure — a loud warning by default, or a thrown error from the after-hook under strict). Close only on `session.deleted` and plugin `dispose`. - Drop the dead `emitter?: "daemon"` config field: it was declared but never read by resolveConfig, so setting it was a silent no-op. The daemon-only / no-WAL decision stays documented in the README and docs. Docs prose updated to match the session.deleted teardown.
…ebzqfb # Conflicts: # sdk/py/uv.lock # sdk/ts/CHANGELOG.md
OpenCode's `write` tool creates a file OR overwrites an existing one. Mapping
it to `filesystem.file.create` (low) under-reported the destructive-overwrite
case as low risk, so risk-based parameter disclosure ("high") would not fire.
Map to the more conservative `filesystem.file.modify` (medium) since we cannot
tell create from overwrite without inspecting the filesystem. Updates the
mapping tests and the docs action-mapping table.
…flow Adds `.github/workflows/opencode-plugin.yml`, mirroring the sdk-ts workflow conventions. Path-filtered on `opencode-plugin/**` and `sdk/ts/**` — the latter because the plugin links the in-tree SDK via `file:../sdk/ts`, so a same-PR sdk/ts change that breaks the plugin must fail this check. The job builds sdk/ts dist first, then runs the plugin's typecheck, lint, build, and test.
Introduce an `integrations/` grouping for per-agent-runtime adapters and move the new plugin under it (`integrations/opencode-plugin/`), following the repo's existing family-grouping convention (`sdk/<lang>`). The Claude Code `hook/` is the other member of this family and will migrate in a follow-up; until then it stays at the root. - `file:` dependency depth: `file:../sdk/ts` → `file:../../sdk/ts`; lockfile regenerated to match. - CI workflow path filters and working-directory updated to `integrations/opencode-plugin/**`. - Relative doc links (`../hook/`, `../mcp-proxy/`, `../LICENSE`, `file:` notes) re-pointed one level up. - Root AGENTS.md monorepo layout documents the new `integrations/` dir. Verified from a clean --frozen-lockfile install at the new path: typecheck, lint, build, and 31 tests pass.
8 tasks
d2ea0dd to
3ef02a9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the OpenCode analog of the Claude Code hook integration: a TypeScript plugin (
@agent-receipts/opencode-plugin) that emits one daemon-signed Agent Receipt per native OpenCode tool call (bash/edit/write/webfetch/…). This is the Tier B / native-tool channel; the MCP channel (Tier A) is docs-only via the existingmcp-proxy.Repo structure
The package lives at
integrations/opencode-plugin/under a newintegrations/grouping for per-agent-runtime adapters — following the repo's existing family-grouping convention (sdk/). The Claude Codehook/is the other member of this family and migrates in a follow-up (#767); until then it stays at the root.Two-tier design
opencode.jsonMCP config atmcp-proxy— the adversary-resistant, out-of-process placement. Reuses a shipped component; no new code.tool.execute.before/tool.execute.afterand forwards each call toagent-receipts-daemonvia the TS SDKDaemonEmitter.Trust boundary (load-bearing)
The plugin runs inside the OpenCode process, so it is an emitter only — it never instantiates a signer, signs, or holds a key (ADR-0010, daemon-sole-writer). This is the execd-side, honest-operator placement: maximum coverage of native tool calls, but not adversary-resistant (a compromised OpenCode can omit/misreport). Code, labels, and docs do not claim otherwise. The mcp-proxy placement (Tier A) is the adversary-resistant one for MCP.
What's in the package
recorder.ts— framework-agnostic core (tool calls →DaemonEmitteremissions), one emitter persessionID.actions.ts— OpenCode tool name → AR taxonomy action type (bash→system.command.execute,edit/write/patch→filesystem.file.modify,webfetch→data.api.read), overridable.config.ts— env + programmatic config, tool allow/deny.plugin.ts— OpenCode adapter (Pluginexport, hooks,session.deleted/disposelifecycle); typechecks against@opencode-ai/plugin@1.16.2.DaemonEmitteragainst a fake daemon socket and asserting wire-frame shape.Supporting SDK change
sdk/ts: adds optionalEmitEvent.actionType→ daemon frameaction_type. The daemon already accepted this field (and prefers it over the synthetic.fallback); this exposes it through the TS emitter so the plugin's action mapping makes risk-based controls effective. Additive, backwards-compatible, not a receipt-format change. Tests + CHANGELOG included.Action & chain mapping
input→parameters_hash, RFC 8785); the daemon re-derivesrisk_levelfrom the type, so an emitter cannot downgrade risk.writemaps tofilesystem.file.modify(medium), notcreate, since OpenCode'swritecan overwrite an existing file — under-reporting a destructive overwrite as low-risk would be wrong.sessionIDmaps to its own emitter (→ the daemon root chain). Per-agent sub-chains withdelegationbacklinks (feat(agent-id): agent_id forwarding, Delegation struct, multi-chain daemon routing #753) are a filed follow-up: thetool.executehook context exposes only{ tool, sessionID, callID }, not a named-agent identity, so the sub-chain keying cannot be derived without guessing.Session.parentIDis the hook the follow-up will use.Failure posture (ADR-0025)
Default catch-and-warn — a tool call is never aborted because the daemon is unreachable; the drop is logged loudly (⇒ possible chain gaps, surfaced by
agent-receipts verify— honest-operator-grade, not a completeness guarantee).strictre-throws emit failures.Docs & CI
agent-receipts verifywalkthrough, Tier A mcp-proxy config) with the site nav updated in this PR. Daemon-connecting TS snippets carry theno-runsnippet directive..github/workflows/opencode-plugin.yml— path-filtered onintegrations/opencode-plugin/**andsdk/ts/**(the plugin links the in-tree SDK viafile:), builds the SDK first, then runs typecheck / lint / build / test.Acceptance criteria
strictsurfaces the failure (ADR-0025).agent-receipts verifypasses.Non-goals / follow-ups
hook/→integrations/to complete the grouping — tracked in Move hook/ into integrations/ to complete the integrations/ grouping #767.file:../../sdk/tsdev dependency should be swapped for a versioned@agnt-rcpt/sdk-tsat publish time.Verification
opencode-plugin: typecheck clean, biome clean, 31 tests pass (verified from a clean--frozen-lockfileinstall atintegrations/opencode-plugin/).sdk/ts: 433 tests pass (incl. the 2 newactionTypetests).main(incl. the ADR-0026 / 0.12.0 graduation that landed during development; orthogonal to this change).