Skip to content

RFC-008 P5: OpenCode enforcement plugin (R6, R10)#424

Merged
lantiscooperdev merged 12 commits into
mainfrom
feat/rfc-008-p5-opencode-plugin
Jun 27, 2026
Merged

RFC-008 P5: OpenCode enforcement plugin (R6, R10)#424
lantiscooperdev merged 12 commits into
mainfrom
feat/rfc-008-p5-opencode-plugin

Conversation

@lantisprime

Copy link
Copy Markdown
Owner

RFC-008 P5 — OpenCode enforcement plugin (R6, R10)

Adds the OpenCode enforcement plugin: a TypeScript adapter + node bridge that gate repo-source writes per the shared enforce-contract.mjs thin waist, deployed per-project (Principle 12), with install/uninstall, CI, and a runbook. Maps to RFC-008 R6 (per-tool plugins) and R10 (enforcement runbooks).

What ships (slices S1-S6)

  • S1: gauntlet parameterized by harness (REQ-1).
  • S2: declarative opencode plugin (manifest, _index entry, event translations).
  • S3: enforcement runbook (§1-§10, agent manifest).
  • S4: shared repo-source carve-outs, bash + node at parity (REQ-7).
  • S5: TypeScript adapter (enforcement.ts) + node bridge (enforce-bridge.mjs).
  • S6: install.mjs --tool opencode --install-enforcement + CI gating the opencode suites (REQ-11/REQ-12). Deploys the contract set project-local beside the engine so the kill switch and project-local resolution work in a clean deploy (R5/P12).

Pre-PR whole-branch review fixes (last two commits)

A PR-level review of the full main..HEAD diff (the layer the per-slice reviews could not provide) found issues no single-slice review saw:

  • Adapter was inert (failed open). enforcement.ts returned a nested hooks object, but the installed @opencode-ai/plugin interface Hooks uses flat dotted keys ("tool.execute.before") with two-arg (input, output) signatures. The host found undefined and the only STRONG blocking hook never fired. Rewritten to the installed shape; args read from output.args/input.args, sessionID from input.sessionID, stop via the session.idle event.
  • No test loaded the adapter. Every test drove the bridge directly, so the broken adapter passed every green suite. Added test-opencode-adapter-conformance.mjs that loads the deployed .ts and drives the real tool.execute.before hook to a block on a gated write / allow on a carve-out; it fails loud (not skip) if the runtime cannot strip TS. plugin-validate.yml pinned to Node 24 for that step.
  • Honest classifier mode. opencode declared classifier.mode: "default" but ships no canonical bash command-classifier.sh — its classifier is the node classifyLabel subset port. Reclassified to override (the truthful declaration) with override_path, the honest emitted subset + the non-overridable labels M5a requires; _index.json and runbook §3/§7/§10 reconciled (derived blocks regenerated from the canonical generators). A latent FP-control bug in test-validate-bp-contract.mjs (refreshed only claude-code's manifest hash) was fixed.
  • RFC matrix tool_result STRONG→MEDIUM honesty note; bypass_known citation corrected.

Verification

All green: validate-bp-contract OK (79 checks) + self-tests 103/0; plugin-registry 200/0; opencode gauntlet 7 pass/2 deferred-P3/0; claude-code gauntlet 25/0; install E2E 25/0; adapter conformance 14/0; opencode adapter 14/0; enforce-bridge 11/0; repo-source parity 65/0; translations 9/0.

Reviewed by negative-scenario-reviewer across two rounds to ACCEPT (round 3 specifically adjudicated and confirmed the classifier-mode change is an honest solve, not a disguised bypass; NS1/NS2/NS4 reproduced).

Post-merge (tracked, not in this PR)

Global hook deploy (install.mjs --install-hooks --install-hooks-force) to clear the SessionStart deploy-audit MISSING (S4-origin repo-source.mjs stale-local-global), then unfiltered audit.

🤖 Generated with Claude Code

lantisprime and others added 12 commits June 23, 2026 16:52
…+ KB

Implementation-grade plan for RFC-008 P5 (OpenCode enforcement plugin),
authored for a low-capability executor (Haiku/DeepSeek V4 Flash). Three
adversarial review rounds folded; round-3 verdict ACCEPT (episode
20260623-084701-...-0da3). Adds the OpenCode plugin-API knowledge-base
entry (probed from @opencode-ai/plugin@1.14.50 types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add --harness param to runGauntlet + CLI; parameterize the fixture dir
in stepEventReplay so each harness reads from its own
tests/fixtures/harness-events/<harness>/ directory. Default = "claude-code"
preserves byte-identical claude-code behavior. testHarnessUnknownThrows
goes red on the old hardwired code; testHarnessDefault locks read_trace
fixture-dir assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract carve-out dirs to patterns/repo-source-carveouts.json (Rule 14
single source). repo-source.sh reads from JSON with NEW-R3-1 resolution
order: (1) $HOME/.episodic-memory/patterns/… (deployed canonical), (2)
in-repo dev path, (3) inline 5-dir fallback if both unreadable. Exact-
segment matching only — .github/ and .gitignore are NOT carved.

Add scripts/lib/repo-source.mjs as a zero-dep node mirror of the bash
predicates: isRepoSource() + toolTargetsRepoSource(). Both read the same
JSON with the same resolution order.

test-repo-source.sh: 13/13 (JSON-present + fallback modes; exact-segment).
test-repo-source-parity.mjs: 65/65 (corpus identical in both impls, both
modes; adjacent-name B-NEW-1 asserted GATED; B-NEW-3 fallback verified).
test-command-classifier.sh: 430/430 (no bash gate regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
REQ-2/3/4/6: register opencode in _index.json + manifest.json
(pre_tool_use:STRONG, tool_result:MEDIUM, session_start:MEDIUM,
stop:MEDIUM); 4 harness-events fixtures; bypass_known.json records;
test-opencode-translations.mjs (9/9 pass); gauntlet testHarnessOpencode
(steps 1,2,4,7,8 pass; 3+9 deferred until S3/S5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
REQ-5: create plugins/opencode/runbooks/enforcement.md (sentinel +
COMMON:BEGIN/END byte-match + §1-§10 headers; RESOLUTION:BEGIN/END
matrix auto-derived from manifest×taxonomy×events; §8 modality:agent;
§9 agent-manifest JSON; CONFIG:BEGIN/END block) + enforcement.quickref.md.
Validator: 2/2 pass (0 violations). Gauntlet 25/25 pass. Step 3 passes
for opencode harness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
REQ-8/9/10/13/14: enforce-bridge.mjs (zero-dep node CLI; stdin
{harness,event,normalized}; §12 two-layer AND: L1=toolTargetsRepoSource,
L2=gateDisposition; read_only label short-circuit; exit 0/2/3);
enforcement.ts (TypeScript adapter; tool.execute.before STRONG block;
tool.execute.after/session_start/stop MEDIUM observe; fail-closed on
bridge exit!=0/bad JSON). Tests: test-enforce-bridge.mjs (11/11);
test-opencode-adapter.mjs (14/14 incl. all carve-out negative controls).
enforce-contract.mjs: all required exports already present (no change).
Real-runtime OpenCode E2E: deferred-unverified (runtime not available in CI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Step 9 (stepInvocationParity / N1 sandbox) was only half-parameterized
in S1: its isolation sub-check hardwired the claude-code enforcement
model (claude-code hook stdin + a `.checkpoints/.*` marker self-arm).
OpenCode's command_shape is a stateless stdout-decision bridge
(enforce-bridge.mjs, OD-4) that writes no marker, so step 9 failed for
opencode (`dispatch armed no marker in the sandbox`).

Branch step 9 on the declared expected_outputs.shape:
  - "exit-code-only" (claude-code): unchanged marker-self-arm sandbox.
  - "json-object" (opencode): new bridgeDispatch dispatches the
    documented §9 invocation (a pre_tool_use repo-source write) and
    asserts the documented decision (action:block, exit in return_codes)
    while proving NO `.checkpoints/.*` marker is mutated under the live
    repo or a divergent process cwd. Root-source is independently
    covered by test-enforce-bridge.mjs::testBridgeCwdDivergence.

Verified: opencode gauntlet 7 pass / 2 deferred-P3 / 0 fail; claude-code
gauntlet unregressed at 25/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two pre-existing failures in test-plugin-registry.mjs (present since S2/S5
put opencode into _index.json + bypass_known.json; not previously run to
green on this branch):

1. buildLiveProject() copied plugins/_index.json (which now lists
   opencode) + plugins/claude-code/* but NOT plugins/opencode/*, so the
   live registry found opencode in the index with no manifest on disk
   (M2 manifest-unreadable + M8 entry-dir-missing). Copy the opencode
   manifest + runbooks into the live temp project too.

2. corpus fixture bad-missing-bypass-record.json declared opencode
   tool_result:STRONG to exercise the M4a no_record path, but S5 added an
   opencode/tool_result bypass record (ceiling MEDIUM) — so the fixture
   now tripped ceiling_below_tier, not no_record. Repoint the dishonest
   capability to session_end (a valid schema key with no opencode
   bypass record) to restore no_record coverage.

test-plugin-registry: 200/0 (was 198/2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--tool opencode --install-enforcement now deploys the per-project
OpenCode enforcement layer (decision A — co-deploy the closure):
  <proj>/.opencode/plugins/episodic-memory/capabilities/{enforcement.ts,enforce-bridge.mjs}
  <proj>/.opencode/plugins/episodic-memory/{manifest.json,runbooks/*}
  <proj>/.opencode/plugins/scripts/{enforce-contract.mjs,lib/*.mjs}   (bridge ../../scripts deps)
  <proj>/.opencode/plugins/patterns/repo-source-carveouts.json
  <proj>/opencode.json  plugin[] += the adapter (version-independent path)
and --uninstall-enforcement reverses it (config parse-or-abort, contained
removal, empty-dir prune). Everything is per-project, never global (P12);
the dispatch branches on tool so the claude-code .claude/hooks path is
untouched.

The bridge's transitive closure (enforce-contract.mjs + scripts/lib/*) is
co-deployed so the deployed bridge resolves its deps via ../../scripts.
resolveContractRoot() finds no bp-001.json beside the deployed module
(OD-4 ships none), so it returns null and the pre_tool_use gate
fail-closes to STRONG enforce; the R5 kill switch is still read from the
project's .episodic-memory/enforce-config.json.

tests/test-install-opencode-enforcement.mjs (19/0): real install.mjs under
an isolated HOME, then DRIVES THE DEPLOYED bridge (M4 — not the in-repo
one): repo-source write -> block, carve-out -> allow, read -> allow;
uninstall removes files + registration; no global leak.

CI: plugin-validate.yml now gates the claude-code + opencode gauntlets and
the S2-S6 suites (translations, repo-source parity bash+node, enforce-
bridge, adapter, install E2E) — they had no CI step before (Rule 13).

Deploy note: deploy-audit flags ~/.episodic-memory/scripts/lib/repo-source.mjs
MISSING — a pre-existing S4-origin stale-local-global (the dev global
predates S4); resolved by the standard post-merge install --install-hooks-force,
not by S6 (which changes no global script deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se-first)

negative-scenario-reviewer HOLD (episode
20260624-060203-hold-rfc-008-p5-opencode-enforce-blocker-a1dc), all
reviewer-reproduced:

BLOCKER-1 (P12 + R5) — the deployed bridge resolved its DATA contract via
resolveContractRoot(), which in a clean opencode deploy returned null
(R5 kill switch silently ignored: loadEnforceConfig needs the schema to
validate active:false, and with schema null it fail-closed to active:true)
and, when a legacy global ~/.episodic-memory/patterns/bp-001.json existed,
resolved the GLOBAL contract (a project's decision governed by global
ambient state — non-deterministic, violates P12). Fix: deploy the contract
set BESIDE the deployed engine — scripts/patterns/{bp-001,events,
enforce-config.schema}.json + scripts/plugins/_index.json — so
resolveContractRoot accepts candidate-0 (project-local) and NEVER the
global candidate-1. bp-001.json is the resolve sentinel only (bridge still
passes contractTier/configTier=null; no bp-001 lifecycle, OD-4).

MAJOR-1 — malformed opencode.json was asymmetric (install deployed files
then warned "register manually" -> orphaned unregistered files, silently
inactive; uninstall aborted). Fix: install now PARSES opencode.json FIRST
and aborts the whole deploy on malformed config (symmetry; nothing on disk).

MINOR-1 — bad-missing-bypass-record.json event_translations aligned to its
capabilities (session_end) so it is single-purpose (only M4a/no_record).
MINOR-2 — step-9 json-object passDetail no longer overstates; root-source
is honestly delegated to test-enforce-bridge cwd-divergence.

New E2E (install suite 19 -> 25): active:false -> repo-src ALLOW under an
empty HOME (R5, cannot pass via leaked global); project-local contract wins
over a planted global poison schema (P12); malformed opencode.json deploys
NOTHING. Full suite green; claude-code gauntlet unregressed 25/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…JOR-1)

Whole-branch review caught what six slice reviews missed: the deployed
adapter was wired to a hook API that does not exist in the installed
@opencode-ai/plugin runtime, so the plugin gated nothing (failed open),
and no test loaded the adapter to catch it.

- BLOCKER-1: rewrite enforcement.ts to the installed flat-key `interface
  Hooks` (dist/index.d.ts) — "tool.execute.before/after",
  "experimental.chat.system.transform", `event` — with the real two-arg
  (input, output) signatures. tool_args from output.args (before) /
  input.args (after); sessionID from input.sessionID; stop via the
  session.idle Event. The prior nested object left hooks["tool.execute.
  before"] undefined, so the only STRONG blocking hook never fired.
- BLOCKER-2: add tests/test-opencode-adapter-conformance.mjs — loads the
  DEPLOYED enforcement.ts as a module, asserts the flat-key shape (+
  nested-shape regression guard), and drives the real before-hook to a
  block on a gated repo-source write / allow on a carve-out/read. Fails
  loud (not skip) if the runtime cannot strip TS. plugin-validate.yml
  bumped to Node 24 (type-strip default-on) + step wired.
- MAJOR-1: reconcile RFC P5-P7 matrix tool_result STRONG->MEDIUM with an
  honesty note; correct the bypass_known citation (tool.execute.after
  output.output IS mutable, but model re-read is not E2E-proven).

Reviewed: negative-scenario-reviewer round 2 ACCEPT (deployed module
loaded, real hook driven to captured block/allow, non-vacuity proven).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
validate-bp-contract assertion 7 failed at HEAD (CI-red on the branch):
opencode declared classifier.mode=default but ships no bash
command-classifier.sh. Its classifier is the node classifyLabel port in
enforce-bridge.mjs — a harness-specific SUBSET classifier that fail-closes
unknown->shared_write. "default" was a false declaration. Solved by
declaring the truth (override), not by faking a bash classifier or
relaxing the validator.

- manifest.json: mode default->override; add override_path ->
  enforce-bridge.mjs; emits_labels trimmed to the honest subset
  (read_only, shared_write, push_or_pr_create) + the two non-overridable
  labels M5a requires an override classifier to preserve.
- _index.json: opencode entry classifier default->override (M-cross).
- runbooks/enforcement.md: §3 prose rewritten for the override subset
  classifier (and the marker_write declared-vs-emitted deferral tied to
  OD-4 + the deferred-to-P3 M5b runtime check); §7/§10 blocks regenerated
  via the exported renderResolutionMatrix/renderConfigBlock generators
  (M7c/M7f byte-match).
- test-validate-bp-contract.mjs: the stable-ID (c) FP-control refreshed
  only claude-code's manifest taxonomy_version after a taxonomy mutation,
  never opencode's — a latent bug masked while opencode died at assertion
  7. Refresh every plugin manifest.

Reviewed: negative-scenario-reviewer round 3 ACCEPT, honesty-confirmed
not-a-bypass (NS1 stash->assertion-7 fail, NS2 revert-test->assertion-15
fail, NS4 unknown-tool->block); reply 20260627-021334-...-8129.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@lantiscooperdev lantiscooperdev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — RFC-008 P5 OpenCode enforcement plugin

Reviewed the full main..HEAD diff (whole-branch, not per-slice) with adversarial focus on the adapter↔host conformance seam and the classifier-honesty contract. Two negative-scenario-reviewer rounds drove the review fixes to ACCEPT; this comment records the substantive findings and their resolution.

Verified resolved

  • Adapter conformance (was failing open). enforcement.ts now returns the installed flat-key interface Hooks (dist/index.d.ts: "tool.execute.before/after", "experimental.chat.system.transform", event) with two-arg (input, output) signatures; tool_args from output.args/input.args, sessionID from input.sessionID, stop via session.idle. Confirmed by loading the deployed module and driving the real tool.execute.before to a captured block on a gated repo-source write and allow on a carve-out/read. Non-vacuity proven: the prior nested shape leaves hooks["tool.execute.before"] undefined.
  • Test now exercises the real artifact. test-opencode-adapter-conformance.mjs loads the deployed .ts (fails loud if the runtime can't strip types); plugin-validate.yml pinned to Node 24 for that step. Closes the gap where every suite was green while the deployed adapter gated nothing.
  • Classifier mode is honest. override (not default) is the truthful declaration for opencode's node classifyLabel subset classifier; override_path authority-checks to the real bridge; _index.json + runbook §3/§7/§10 reconciled (derived blocks regenerated from the canonical generators). The non-overridable marker_write/unsafe_complex declaration is mandated by M5a as a safety floor and is gated behind the deferred-to-P3 M5b runtime check, identical to every other plugin — not a laundered claim (the §3 note tracks the OD-4 deferral).
  • Self-test bug fixed. The stable-ID (c) FP control refreshed only claude-code's manifest hash; now refreshes every plugin manifest. Reverting the fix reproduces a live assertion-15 failure, confirming the assertion still bites.

Verification (all green)

validate-bp-contract OK (79 checks) + self-tests 103/0; plugin-registry 200/0; opencode gauntlet 7/2-deferred/0; claude-code gauntlet 25/0; install E2E 25/0; adapter conformance 14/0; enforce-bridge 11/0; parity 65/0; translations 9/0.

Note (non-blocking, tracked)

Post-merge: run the global hook deploy (install.mjs --install-hooks --install-hooks-force) to clear the SessionStart deploy-audit MISSING (S4-origin repo-source.mjs stale-local-global), then the unfiltered audit.

No outstanding blockers. Substance looks sound; deferring the merge approval to a human reviewer.

@lantiscooperdev lantiscooperdev merged commit f5dbaef into main Jun 27, 2026
4 checks passed
lantiscooperdev pushed a commit that referenced this pull request Jun 27, 2026
P5 (OpenCode enforcement plugin) merged via #424 (`f5dbaef`) + doc fix
#425 (`a7f66c9`). Flip the stale `queued` P5 status in the three places
that track it, mirroring #423's P4-DONE sync:

- RFC-008/README.md phase matrix: the shared P5-P7 row now reads
  P5 DONE; P6 / P7 queued (Codex, Pi Agent still pending).
- RFC body phase-index stub (:1220): same split.
- P5-P7-tool-plugins.md status line: P5 DONE, P6/P7 queued.

P6/P7 stay queued (shared row/file). RFC-008 remains `accepted` at the
registry level (not all phases shipped), so docs/rfcs/_index.json +
README.md are unchanged; em-rfc-validate passes (8/8/8 consistent).
Rule 10 doc-sync; no code change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants