RFC-008 P5: OpenCode enforcement plugin (R6, R10)#424
Conversation
…+ 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
left a comment
There was a problem hiding this comment.
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.tsnow returns the installed flat-keyinterface Hooks(dist/index.d.ts:"tool.execute.before/after","experimental.chat.system.transform",event) with two-arg(input, output)signatures;tool_argsfromoutput.args/input.args,sessionIDfrominput.sessionID, stop viasession.idle. Confirmed by loading the deployed module and driving the realtool.execute.beforeto a captured block on a gated repo-source write and allow on a carve-out/read. Non-vacuity proven: the prior nested shape leaveshooks["tool.execute.before"]undefined. - Test now exercises the real artifact.
test-opencode-adapter-conformance.mjsloads the deployed.ts(fails loud if the runtime can't strip types);plugin-validate.ymlpinned 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(notdefault) is the truthful declaration for opencode's nodeclassifyLabelsubset classifier;override_pathauthority-checks to the real bridge;_index.json+ runbook §3/§7/§10 reconciled (derived blocks regenerated from the canonical generators). The non-overridablemarker_write/unsafe_complexdeclaration 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.
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>
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.mjsthin 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)
_indexentry, event translations).enforcement.ts) + node bridge (enforce-bridge.mjs).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..HEADdiff (the layer the per-slice reviews could not provide) found issues no single-slice review saw:enforcement.tsreturned a nested hooks object, but the installed@opencode-ai/plugininterface Hooksuses flat dotted keys ("tool.execute.before") with two-arg(input, output)signatures. The host foundundefinedand the only STRONG blocking hook never fired. Rewritten to the installed shape; args read fromoutput.args/input.args, sessionID frominput.sessionID, stop via thesession.idleevent.test-opencode-adapter-conformance.mjsthat loads the deployed.tsand drives the realtool.execute.beforehook 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.ymlpinned to Node 24 for that step.classifier.mode: "default"but ships no canonical bashcommand-classifier.sh— its classifier is the nodeclassifyLabelsubset port. Reclassified tooverride(the truthful declaration) withoverride_path, the honest emitted subset + the non-overridable labels M5a requires;_index.jsonand runbook §3/§7/§10 reconciled (derived blocks regenerated from the canonical generators). A latent FP-control bug intest-validate-bp-contract.mjs(refreshed only claude-code's manifest hash) was fixed.tool_resultSTRONG→MEDIUM honesty note;bypass_knowncitation corrected.Verification
All green:
validate-bp-contractOK (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-revieweracross 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-originrepo-source.mjsstale-local-global), then unfiltered audit.🤖 Generated with Claude Code